Custom Pull-to-Refresh animation in mobile app

NOVASOLUTIONS.TECHNOLOGY is engaged in the development, support and maintenance of iOS, Android, PWA mobile applications. We have extensive experience and expertise in publishing mobile applications in popular markets like Google Play, App Store, Amazon, AppGallery and others.
Development and support of all types of mobile applications:
Information and entertainment mobile applications
News apps, games, reference guides, online catalogs, weather apps, fitness and health apps, travel apps, educational apps, social networks and messengers, quizzes, blogs and podcasts, forums, aggregators
E-commerce mobile applications
Online stores, B2B apps, marketplaces, online exchanges, cashback services, exchanges, dropshipping platforms, loyalty programs, food and goods delivery, payment systems.
Business process management mobile applications
CRM systems, ERP systems, project management, sales team tools, financial management, production management, logistics and delivery management, HR management, data monitoring systems
Electronic services mobile applications
Classified ads platforms, online schools, online cinemas, electronic service platforms, cashback platforms, video hosting, thematic portals, online booking and scheduling platforms, online trading platforms

These are just some of the types of mobile applications we work with, and each of them may have its own specific features and functionality, tailored to the specific needs and goals of the client.

Showing 1 of 1 servicesAll 1735 services
Custom Pull-to-Refresh animation in mobile app
Medium
from 4 hours to 2 business days
FAQ
Our competencies:
Development stages
Latest works
  • image_mobile-applications_feedme_467_0.webp
    Development of a mobile application for FEEDME
    756
  • image_mobile-applications_xoomer_471_0.webp
    Development of a mobile application for XOOMER
    624
  • image_mobile-applications_rhl_428_0.webp
    Development of a mobile application for RHL
    1052
  • image_mobile-applications_zippy_411_0.webp
    Development of a mobile application for ZIPPY
    947
  • image_mobile-applications_affhome_429_0.webp
    Development of a mobile application for Affhome
    862
  • image_mobile-applications_flavors_409_0.webp
    Development of a mobile application for the FLAVORS company
    445

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.