Реалізація анімації 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 виконують свою роль. Але коли дизайнер принесе брендовий індикатор завантаження — анімований логотип, прогресс-бар з фірмовими кольорами, кастомний спінер — стандартний компонент не підійде, його неможливо так кастомізувати.

Завдання: відстежувати жест 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 дні. Вартість розраховується індивідуально.