Реалізація кастомної анімації Pull-to-Refresh у мобільних додатках
Стандартний UIRefreshControl на iOS та SwipeRefreshLayout на Android виконують свою роль. Але коли дизайнер принесе брендовий індикатор завантаження — анімований логотип, прогресс-бар з фірмовими кольорами, кастомний спінер — стандартний компонент не підійде, його неможливо так кастомізувати.
Завдання: відстежувати жест pull, синхронізувати анімацію з прогресом витягування, запустити loop-анімацію під час завантаження, плавно приховати при завершенні.
iOS: кастомний UIRefreshControl через subclassing
UIRefreshControl відкритий для subclassing, але можливості кастомізації обмежені. Більш гнучкий підхід — кастомний View над UIScrollView:
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 }
// progress: 0..1, синхронізація з жестом
animationView.currentProgress = progress.clamped(to: 0...0.5) // перші 50% анімації — під час тяги
}
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:
class ViewController: UIViewController, UIScrollViewDelegate {
let refreshHeader = CustomRefreshHeader(frame: CGRect(x: 0, y: -80, width: UIScreen.main.bounds.width, height: 80))
let threshold: CGFloat = -80
override func viewDidLoad() {
scrollView.addSubview(refreshHeader)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y
guard offset < 0 else { return }
let progress = min(-offset / (-threshold), 1.0)
refreshHeader.update(progress: progress)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y <= threshold {
startRefreshing()
}
}
func startRefreshing() {
UIView.animate(withDuration: 0.3) {
self.scrollView.contentInset.top = 80 // робимо місце для header
}
refreshHeader.beginRefreshing()
// завантажуємо дані...
loadData { [weak self] in
self?.endRefreshing()
}
}
func endRefreshing() {
refreshHeader.endRefreshing {
UIView.animate(withDuration: 0.3) {
self.scrollView.contentInset.top = 0
}
}
}
}
Зміна contentInset.top — правильний спосіб «звільнити місце» для refresh header без зміни contentOffset. Обидва анімуються одночасно, header не прискорюється.
Android: кастомний RefreshLayout
SwipeRefreshLayout не підтримує кастомні індикатори — потрібен fork або своя реалізація. Найпрактичніший підхід — NestedScrollView з кастомним Header View та NestedScrollConnection у Compose.
У Compose:
@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)
}
}
Modifier.pullRefresh — Material3 компонент, надає PullRefreshState з progress (0..1 під час тяги) та isRefreshing. Кастомний індикатор будуємо як звичайний Composable, позиціюємо через Box + align.
Flutter
// pubspec: custom_refresh_indicator: ^4.0.0
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. Керуємо переходами стану між анімацією тяги та loop-анімацією завантаження.
Терміни
Кастомний pull-to-refresh з Lottie-анімацією або простим кастомним індикатором: 4–8 годин. З повністю кастомним gesture tracking, нестандартними порогами та анімацією завершення: 1–2 дні. Вартість розраховується індивідуально.







