Реализация анимации Pull-to-Refresh (кастомная) в мобильном приложении

TRUETECH занимается разработкой, поддержкой и обслуживанием мобильных приложений iOS, Android, PWA. Имеем большой опыт и экспертизу для публикации мобильных приложений в популярные маркеты Google Play, App Store, Amazon, AppGallery и другие.

Разработка и поддержка любых видов мобильных приложений:

Информационные и развлекательные мобильные приложения
Новостные приложения, игры, справочники, онлайн-каталоги, погодные, фитнес и здоровье, туристические, образовательные, социальные сети и мессенджеры, квиз, блоги и подкасты, форумы, агрегаторы
Мобильные приложения электронной коммерции
Интернет-магазины, B2B-приложения, маркетплейсы, онлайн-обменники, кэшбэк-сервисы, биржи, дропшиппинг-платформы, программы лояльности, доставка еды и товаров, платежные системы
Мобильные приложения для управления бизнес-процессами
CRM-системы, ERP-системы, управление проектами, инструменты для команды продаж, учет финансов, управление производством, логистика и доставка, управление персоналом, системы мониторинга данных
Мобильные приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, платформы предоставления электронных услуг, платформы кешбека, видеохостинги, тематические порталы, платформы онлайн-бронирования и записи, платформы онлайн-торговли

Это лишь некоторые из типы мобильных приложений, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента.

Услуги, которые мы предлагаем
Показано 1 из 1Все 1735 услуг
Реализация анимации Pull-to-Refresh (кастомная) в мобильном приложении
Средний
от 4 часов до 2 дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_mobile-applications_feedme_467_0.webp
    Разработка мобильного приложения для компании FEEDME
    792
  • image_mobile-applications_xoomer_471_0.webp
    Разработка мобильного приложения для компании XOOMER
    671
  • image_mobile-applications_rhl_428_0.webp
    Разработка мобильного приложения для компании RHL
    1097
  • image_mobile-applications_zippy_411_0.webp
    Разработка мобильного приложения для компании ZIPPY
    969
  • image_mobile-applications_affhome_429_0.webp
    Разработка мобильного приложения для компании Affhome
    914
  • image_mobile-applications_flavors_409_0.webp
    Разработка мобильного приложения для компании FLAVORS
    495

Реализация анимации Pull-to-Refresh (кастомная) в мобильном приложении

Стандартный UIRefreshControl на iOS и SwipeRefreshLayout на Android выполняют свою задачу. Но если дизайнер принёс брендовый индикатор загрузки — анимированный логотип, прогресс-бар с фирменными цветами, кастомный spinner — стандартный компонент не подойдёт, его нельзя так кастомизировать.

Задача: отслеживать жест pull, синхронизировать анимацию с progress вытягивания, запустить 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, sync с жестом
        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. Через state управляем переключением между анимацией тяги и loop-анимацией загрузки.

Сроки

Кастомный pull-to-refresh с Lottie-анимацией или простым кастомным индикатором: 4–8 часов. С полностью кастомным gesture tracking, нестандартными пороговыми значениями и анимацией завершения: 1–2 дня. Стоимость рассчитывается индивидуально.