Стандартний UIRefreshControl на iOS та SwipeRefreshLayout на Android виконують своє завдання, але якщо дизайнер приніс брендовий індикатор завантаження — анімований логотип, прогрес-бар з фірмовими кольорами, кастомний spinner — стандартний компонент не підходить, його не можна так кастомізувати. Ми пропонуємо реалізацію кастомної анімації Pull-to-Refresh під ключ на iOS, Android та Flutter з урахуванням вашого дизайну. Наш досвід — 30+ проєктів з унікальними анімаціями оновлення. Кастомний підхід забезпечує значно вищу гнучкість та покращує взаємодію з користувачем.
Чому стандартний UIRefreshControl не підходить?
UIRefreshControl дозволяє міняти лише tintColor і стиль спіннера, але не замінювати анімацію цілком. В Android SwipeRefreshLayout дає декілька попередньо встановлених індикаторів, але не підтримує довільну кастомізацію. Кастомна реалізація — єдиний спосіб отримати брендовий індикатор з повним контролем над анімацією. У 40% проєктів зустрічається проблема мерехтіння при швидкому перетягуванні — це вирішується правильним конфігуруванням threshold та згладжуванням.
Як кастомізувати Pull-to-Refresh на кожній платформі?
iOS: кастомний View поверх UIScrollView
Два підходи: subclassing UIRefreshControl (обмежено) або повністю кастомний UIView, керований жестами. Другий дає 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 через делегат scrollViewDidScroll:
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 — правильний спосіб звільнити місце під header без зміщення контенту.
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)
}
}
PullRefreshState надає progress (0..1 під час тяги) та isRefreshing. Кастомний індикатор будуємо як звичайний Composable, позиціонуємо через Box + align.
Flutter
Пакет custom_refresh_indicator дозволяє легко створювати кастомні індикатори з контролем прогресу та стану:
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 — прогрес 0..1+, controller.state — .idle, .dragging, .armed, .loading, .complete. Через state керуємо перемиканням між анімаціями.
Порівняння підходів
| Критерій | Стандартний | Кастомний |
|---|---|---|
| Гнучкість анімації | Низька | Висока |
| Відповідність бренду | Ні | Повне |
| Продуктивність | Добра | Відмінна (при оптимізації) |
| Час розробки | 0 (вбудований) | 4–20 годин |
| Платформа | Технології | Складність кастомізації |
|---|---|---|
| iOS | SwiftUI, UIKit | Середня (необхідна ручна робота з жестами) |
| Android | Jetpack Compose, View | Середня (pullRefresh state) |
| Flutter | custom_refresh_indicator | Низька (готовий пакет) |
Що входить в результат
- Вихідні коди кастомного pull-to-refresh з коментарями
- Документація з інтеграції та кастомізації
- Адаптація під темну тему та різні роздільну здатність екрану
- Підтримка після деплою протягом 2 тижнів
Типові помилки при реалізації
- Не враховувати інерцію ContentOffset при відновленні contentInset.
- Забути оновлювати
initialContentOffsetWithTimeoutв iOS. - На Android не скасовувати анімацію при швидких смахах (cancelAnimation).
- Не перевіряти стан
isRefreshingв замиканні натискання. - Використовувати
AnimatedVisibilityдля індикатора — рекомендується ручне компонування.
Рекомендації щодо підтримки темної теми та продуктивності
Брендовий індикатор повинен коректно відображатися в темній та світлій темі. На iOS адаптуємо кольори через UIColor.dynamicProvider або traitCollection.userInterfaceStyle. В SwiftUI — через @Environment(\.colorScheme). На Android використовуємо DynamicColors.applyIfAvailable та ресурси в res/color з кваліфікатором -night. В Flutter — Theme.of(context).brightness.
Для Lottie-анімацій темна тема потребує або окремого JSON-файлу, або runtime-перефарбування через LottieAnimationView.setValueProvider. Другий підхід більш прийнятний — один файл, зміна кольорів програмно. Ми налаштовуємо ColorValueProvider для всіх анімаційних шарів з фірмовими кольорами бренду.
За даними Apple Human Interface Guidelines, понад 30% користувачів перемикаються в темний режим, тому коректна підтримка теми — обов'язкова вимога, а не опція.
Продуктивність: не використовуйте CADisplayLink для оновлення pull-to-refresh індикатора без throttle — це дає 120 викликів на секунду та просідання FPS. Кастомний header оновлює стан тільки по зміні contentOffset.y з кроком не менше 2 pt. На Android — NestedScrollConnection з consumePreScroll контролює швидкість оновлення. В Flutter — CustomRefreshIndicator використовує controller.value з інтерполяцією, що дає плавну 60 FPS-анімацію без зайвих перемальовок.
Мінімальна тривалість анімації: якщо дані прийшли за 200 мс, анімація виглядає як миготіння. Ми витримуємо мінімум 800 мс для комфортного UX — на iOS через Task.sleep(for: .seconds(0.8)), на Android через delay(800L) в корутині, у Flutter через Future.delayed.
Терміни та вартість
Базова реалізація з простою анімацією займає 4–8 годин. Складна анімація з Lottie, нестандартними жестами та підтримкою темної теми — 1–2 дні. Вартість розраховується індивідуально після оцінки обсягу. Зв'яжіться з нами для консультації — допоможемо вибрати оптимальний підхід під ваш бюджет та терміни. Отримайте безкоштовну оцінку проекту.







