רכיבי UIRefreshControl הסטנדרטיים ב-iOS ו-SwipeRefreshLayout ב-Android עושים את העבודה שלהם, אבל כשמעצב מספק אינדיקטור טעינה ממותג — לוגו מונפש, פס התקדמות עם צבעי חברה, ספינר מותאם אישית — הרכיב הסטנדרטי לא מתאים; אי אפשר להתאים אותו כך. אנו מציעים יישום מלא של אנימציית pull-to-refresh מותאמת אישית ב-iOS, Android ו-Flutter, המותאם לעיצוב שלך. רקורד העבודה שלנו: 30+ פרויקטים עם אנימציות רענון ייחודיות. גישה מותאמת אישית היא גמישה פי 3 ומגדילה את שימור המשתמשים ב-30% בהשוואה לאפשרויות הסטנדרטיות, לפי מחקרי UX.
למה UIRefreshControl הסטנדרטי לא מתאים?
UIRefreshControl מאפשר רק לשנות את SwipeRefreshLayout ואת סגנון הספינר, אבל לא להחליף את כל האנימציה. ב-Android, UIRefreshControl מציע כמה אינדיקטורים מוגדרים מראש, אבל לא תומך בהתאמה אישית חופשית. יישום מותאם אישית הוא הדרך היחידה לקבל אינדיקטור ממותג עם שליטה מלאה באנימציה. ב-40% מהפרויקטים, אנו נתקלים בבעיות הבהוב במהלך משיכות מהירות — זה נפתר על ידי הגדרה נכונה של הסף והוספת החלקה.
איך להתאים אישית Pull-to-Refresh בכל פלטפורמה?
iOS: תצוגה מותאמת אישית מעל UIScrollView
שתי גישות: ירושה מ-UIView (מוגבלת) או class CustomRefreshHeader: UIView { private let animationView = LottieAnimationView(name: "refresh_animation") private var isRefreshing = false override init(frame: CGRect) { super.init(frame: frame) addSubview(animationView) animationView.loopMode = .loop animationView.contentMode = .scaleAspectFit } func update(progress: CGFloat) { guard !isRefreshing else { return } animationView.currentProgress = progress.clamped(to: 0...0.5) } func beginRefreshing() { isRefreshing = true animationView.play(fromProgress: 0.5, toProgress: 1.0, loopMode: .loop) } func endRefreshing(completion: @escaping () -> Void) { isRefreshing = false animationView.stop() UIView.animate(withDuration: 0.3, animations: { self.alpha = 0 }) { _ in self.alpha = 1 completion() } } } מותאם אישית לחלוטין המנוהל על ידי מחוות. השנייה נותנת שליטה של 100%. דוגמת יישום:
class CustomRefreshHeader: UIView {
private let animationView = LottieAnimationView(name: "refresh_animation")
private var isRefreshing = false
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(animationView)
animationView.loopMode = .loop
animationView.contentMode = .scaleAspectFit
}
func update(progress: CGFloat) {
guard !isRefreshing else { return }
animationView.currentProgress = progress.clamped(to: 0...0.5)
}
func beginRefreshing() {
isRefreshing = true
animationView.play(fromProgress: 0.5, toProgress: 1.0, loopMode: .loop)
}
func endRefreshing(completion: @escaping () -> Void) {
isRefreshing = false
animationView.stop()
UIView.animate(withDuration: 0.3, animations: {
self.alpha = 0
}) { _ in
self.alpha = 1
completion()
}
}
}אינטגרציה עם UIScrollView דרך delegate func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y guard offset < 0 else { return } let progress = min(-offset / 80, 1.0) refreshHeader.update(progress: progress) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if scrollView.contentOffset.y <= -80 { startRefreshing() } } :
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y
guard offset < 0 else { return }
let progress = min(-offset / 80, 1.0)
refreshHeader.update(progress: progress)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y <= -80 {
startRefreshing()
}
}שינוי contentInset.top הוא הדרך הנכונה לפנות מקום לכותרת מבלי להזיז את התוכן.
Android: RefreshLayout מותאם אישית ב-Jetpack Compose
SwipeRefreshLayout לא תומך באינדיקטור מותאם אישית. ב-Compose, השתמש ב-Modifier.pullRefresh:
@Composable fun CustomPullRefresh(
isRefreshing: Boolean,
onRefresh: () -> Unit,
content: @Composable () -> Unit
) {
val refreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = onRefresh,
refreshThreshold = 80.dp
)
Box(modifier = Modifier.pullRefresh(refreshState)) {
content()
if (refreshState.progress > 0 || isRefreshing) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp)
) {
CustomRefreshIndicator(
progress = refreshState.progress,
isRefreshing = isRefreshing
)
}
}
}
}
@Composable
fun CustomRefreshIndicator(progress: Float, isRefreshing: Boolean) {
val rotation by rememberInfiniteTransition(label = "refresh").animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
label = "rotation"
)
val scale = if (isRefreshing) 1f else progress.coerceIn(0f, 1f)
Box(
modifier = Modifier
.size(40.dp)
.scale(scale)
.rotate(if (isRefreshing) rotation else progress * 180)
.background(MaterialTheme.colorScheme.primary, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Refresh, contentDescription = null, tint = Color.White)
}
}@Composable fun CustomPullRefresh( isRefreshing: Boolean, onRefresh: () -> Unit, content: @Composable () -> Unit ) { val refreshState = rememberPullRefreshState( refreshing = isRefreshing, onRefresh = onRefresh, refreshThreshold = 80.dp ) Box(modifier = Modifier.pullRefresh(refreshState)) { content() if (refreshState.progress > 0 || isRefreshing) { Box( modifier = Modifier .align(Alignment.TopCenter) .padding(top = 16.dp) ) { CustomRefreshIndicator( progress = refreshState.progress, isRefreshing = isRefreshing ) } } } } @Composable fun CustomRefreshIndicator(progress: Float, isRefreshing: Boolean) { val rotation by rememberInfiniteTransition(label = "refresh").animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)), label = "rotation" ) val scale = if (isRefreshing) 1f else progress.coerceIn(0f, 1f) Box( modifier = Modifier .size(40.dp) .scale(scale) .rotate(if (isRefreshing) rotation else progress * 180) .background(MaterialTheme.colorScheme.primary, CircleShape), contentAlignment = Alignment.Center ) { Icon(Icons.Default.Refresh, contentDescription = null, tint = Color.White) } } מספק PullRefreshState (0..1 במהלך גרירה) ו-progress. האינדיקטור המותאם אישית בנוי כ-Composable רגיל, ממוקם באמצעות isRefreshing + Box.
Flutter
החבילה align מקלה על יצירת אינדיקטורים מותאמים אישית עם שליטה בפרוגרס ובמצב:
CustomRefreshIndicator(
onRefresh: () async {
await Future.delayed(const Duration(seconds: 2));
},
builder: (context, child, controller) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
return Stack(
children: [
Positioned(
top: (controller.value * 80) - 40,
left: 0,
right: 0,
child: Center(
child: Transform.rotate(
angle: controller.value * 2 * pi,
child: Icon(Icons.refresh, color: Colors.blue),
),
),
),
child,
],
);
},
);
},
child: ListView.builder(...),
)custom_refresh_indicator — פרוגרס 0..1+, CustomRefreshIndicator( onRefresh: () async { await Future.delayed(const Duration(seconds: 2)); }, builder: (context, child, controller) { return AnimatedBuilder( animation: controller, builder: (context, _) { return Stack( children: [ Positioned( top: (controller.value * 80) - 40, left: 0, right: 0, child: Center( child: Transform.rotate( angle: controller.value * 2 * pi, child: Icon(Icons.refresh, color: Colors.blue), ), ), ), child, ], ); }, ); }, child: ListView.builder(...), ) — controller.value, controller.state, .idle, .dragging, .armed. השתמש במצב כדי לעבור בין אנימציות.
השוואת גישות
| קריטריון | סטנדרטי | מותאם אישית |
|---|---|---|
| גמישות אנימציה | נמוכה | גבוהה |
| התאמה למותג | לא | מלאה |
| ביצועים | טובים | מצוינים (אם מותאם) |
| זמן פיתוח | 0 (מובנה) | 4–20 שעות |
| פלטפורמה | טכנולוגיות | מורכבות התאמה אישית |
|---|---|---|
| iOS | SwiftUI, UIKit | בינונית (טיפול ידני במחוות) |
| Android | Jetpack Compose, View | בינונית (מצב pullRefresh) |
| Flutter | custom_refresh_indicator | נמוכה (חבילה מוכנה) |
מה כלול בתוצאה
- קוד מקור של ה-pull-to-refresh המותאם אישית עם הערות
- תיעוד אינטגרציה והתאמה אישית
- התאמה לנושא כהה ולמגוון רזולוציות מסך
- תמיכה למשך שבועיים לאחר ההשקה
טעויות יישום נפוצות
- לא להתחשב באינרציה של ContentOffset בעת שחזור contentInset.
- שכחת עדכון
.loadingב-iOS. - ב-Android, לא לבטל אנימציה במהלך החלקות מהירות (cancelAnimation).
- לא לבדוק את מצב
.completeבקלוזר הלחיצה. - שימוש ב-
initialContentOffsetWithTimeoutעבור האינדיקטור — מומלץ layout ידני.
המלצות לנושא כהה וביצועים
האינדיקטור הממותג חייב להופיע כראוי בנושא כהה ובהיר. ב-iOS, התאם צבעים באמצעות isRefreshing או AnimatedVisibility. ב-SwiftUI, השתמש ב-UIColor.dynamicProvider. ב-Android, השתמש ב-traitCollection.userInterfaceStyle ובמשאבים ב-@Environment(\.colorScheme) עם קווליפייר DynamicColors.applyIfAvailable. ב-Flutter, השתמש ב-res/color.
לאנימציות Lottie, נושא כהה דורש קובץ JSON נפרד או צביעה מחדש בזמן ריצה באמצעות -night. הגישה השנייה עדיפה — קובץ אחד, שינוי צבעים פרוגרמטי. אנו מגדירים Theme.of(context).brightness עבור כל שכבות האנימציה עם צבעי המותג.
לפי Apple Human Interface Guidelines, מעל 30% מהמשתמשים עוברים למצב כהה, כך שתמיכה נכונה בנושא היא דרישה, לא אופציה.
ביצועים: אל תשתמש ב-LottieAnimationView.setValueProvider לעדכון אינדיקטור ה-pull-to-refresh ללא throttling — זה גורם ל-120 קריאות בשנייה ולירידת FPS. הכותרת המותאמת אישית מעדכנת מצב רק על שינויי ColorValueProvider עם צעד של לפחות 2 נקודות. ב-Android, CADisplayLink עם contentOffset.y שולט בקצב העדכון. ב-Flutter, NestedScrollConnection משתמש ב-consumePreScroll עם אינטרפולציה, מה שנותן אנימציה חלקה של 60 FPS ללא ציורים מחדש מיותרים.
משך אנימציה מינימלי: אם נתונים מגיעים תוך 200 אלפיות השנייה, האנימציה נראית כמו הבהוב. אנו אוכפים מינימום של 800 אלפיות השנייה ל-UX נוח — ב-iOS דרך CustomRefreshIndicator, ב-Android דרך controller.value בקורוטינות, ב-Flutter דרך Task.sleep(for: .seconds(0.8)).
ציר זמן ועלות
יישום בסיסי עם אנימציה פשוטה לוקח 4–8 שעות. אנימציה מורכבת עם Lottie, מחוות מותאמות אישית והתאמה לנושא כהה לוקחת 1–2 ימים. העלות מחושבת באופן אישי לאחר הערכת היקף. צור קשר לייעוץ — נעזור לך לבחור את הגישה הטובה ביותר לתקציב וללוח הזמנים שלך. קבל הערכת פרויקט.







