Custom Pull-to-Refresh Animation Implementation in Mobile Apps
Standard UIRefreshControl on iOS and SwipeRefreshLayout on Android fulfill their purpose. But when designers bring branded loading indicators—animated logo, progress bar with brand colors, custom spinner—the standard component won't suffice; it can't be customized that way.
The task: track the pull gesture, synchronize animation with pull progress, start loop animation during loading, smoothly hide on completion.
iOS: Custom UIRefreshControl via Subclassing
UIRefreshControl is open for subclassing, but customization options are limited. A more flexible approach is a custom View over 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, sync with gesture
animationView.currentProgress = progress.clamped(to: 0...0.5) // first 50% of animation—during pull
}
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()
}
}
}
Integration with 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 // make space for header
}
refreshHeader.beginRefreshing()
// load data...
loadData { [weak self] in
self?.endRefreshing()
}
}
func endRefreshing() {
refreshHeader.endRefreshing {
UIView.animate(withDuration: 0.3) {
self.scrollView.contentInset.top = 0
}
}
}
}
Changing contentInset.top is the correct way to "free space" for the refresh header without changing contentOffset. Both animate simultaneously; header doesn't jump.
Android: Custom RefreshLayout
SwipeRefreshLayout doesn't support custom indicators—either fork it or build your own. The most practical way is NestedScrollView with custom Header View and NestedScrollConnection in Compose.
In 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()
// Custom indicator:
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 is a Material3 component providing PullRefreshState with progress (0..1 during pull) and isRefreshing. Build custom indicator as a regular Composable, position via 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: [
// Custom indicator
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 is progress 0..1+, controller.state is .idle, .dragging, .armed, .loading, .complete. Manage state transitions between pull animation and load loop animation.
Timeline
Custom pull-to-refresh with Lottie animation or simple custom indicator: 4–8 hours. With fully custom gesture tracking, non-standard thresholds, and completion animation: 1–2 days. Cost is calculated individually.







