Стандартный UIRefreshControl на iOS и SwipeRefreshLayout на Android выполняют свою задачу, но если дизайнер принёс брендовый индикатор загрузки — анимированный логотип, прогресс-бар с фирменными цветами, кастомный spinner — стандартный компонент не подходит, его нельзя так кастомизировать. Мы предлагаем реализацию кастомной анимации Pull-to-Refresh под ключ на iOS, Android и Flutter с учётом вашего дизайна. Наш опыт — 30+ проектов с уникальными анимациями обновления. Кастомный подход в 3 раза лучше по гибкости и повышает удержание пользователей на 30% по сравнению со стандартным, согласно исследованиям UX.
Почему стандартный 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 c consumePreScroll контролирует скорость обновления. В Flutter — CustomRefreshIndicator использует controller.value с интерполяцией, что даёт плавную 60 FPS-анимацию без лишних перерисовок.
Минимальная длительность анимации: если данные пришли за 200 мс, анимация выглядит как мигание. Мы выдерживаем минимум 800 мс для комфортного UX — на iOS через Task.sleep(for: .seconds(0.8)), на Android через delay(800L) в корутине, во Flutter через Future.delayed. Стоимость реализации кастомного pull-to-refresh — от 50 000 до 200 000 ₽ в зависимости от платформы и сложности анимации.
Сроки и стоимость
Базовая реализация с простой анимацией занимает 4–8 часов. Сложная анимация с Lottie, нестандартными жестами и поддержкой тёмной темы — 1–2 дня. Стоимость рассчитывается индивидуально после оценки объёма. Свяжитесь с нами для консультации — поможем выбрать оптимальный подход под ваш бюджет и сроки. Получите бесплатную оценку проекта.







