Реализация анимации свайпа карточек (Tinder-style) в мобильном приложении

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

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

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

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

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

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

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

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

  • 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

Реализация анимации свайпа карточек (Tinder-style) в мобильном приложении

Стек карточек со свайпом — устоявшийся паттерн не только в dating-приложениях. Подбор товаров, оценка контента, quiz-интерфейсы — паттерн работает везде, где нужно быстрое бинарное решение. Техническая задача: карточка следует за пальцем, вращается пропорционально горизонтальному смещению, при достижении threshold улетает в сторону, нижняя карточка масштабируется и поднимается.

Ключевые параметры анимации

Rotation: угол поворота пропорционален горизонтальному drag. Коэффициент: rotation = translationX / screenWidth * 25°. При максимальном смещении (threshold ~40% ширины экрана) — поворот 25 градусов. Выглядит естественно.

Threshold: обычно 30–40% ширины экрана или velocity > 800 dp/s. Velocity-based threshold — важнее: пользователь может коротко и резко свайпнуть, не доводя до 40%.

Карточки под верхней: вторая карточка scale 0.95, третья — 0.90. При dismiss верхней — вторая анимированно масштабируется до 1.0, третья до 0.95. Анимация в обратном направлении при нажатии "отмена" (undo).

iOS: UIPanGestureRecognizer + UISpringTimingParameters

class SwipeCardView: UIView {
    private var initialCenter = CGPoint.zero
    private let threshold: CGFloat = UIScreen.main.bounds.width * 0.35

    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { true }

    @objc func handlePan(_ gesture: UIPanGestureRecognizer) {
        let translation = gesture.translation(in: superview)
        let velocity = gesture.velocity(in: superview)

        switch gesture.state {
        case .began:
            initialCenter = center
        case .changed:
            center = CGPoint(x: initialCenter.x + translation.x, y: initialCenter.y + translation.y)
            let rotation = (translation.x / UIScreen.main.bounds.width) * 0.4  // радианы
            transform = CGAffineTransform(rotationAngle: rotation)

            // Overlay opacity для индикации направления
            let progress = abs(translation.x) / threshold
            likeOverlay.alpha = translation.x > 0 ? min(progress, 1.0) : 0
            nopeOverlay.alpha = translation.x < 0 ? min(progress, 1.0) : 0

        case .ended, .cancelled:
            let shouldDismiss = abs(translation.x) > threshold || abs(velocity.x) > 800

            if shouldDismiss {
                dismissCard(direction: translation.x > 0 ? .right : .left, velocity: velocity)
            } else {
                returnToCenter(velocity: velocity)
            }
        default: break
        }
    }

    private func returnToCenter(velocity: CGPoint) {
        let params = UISpringTimingParameters(mass: 1, stiffness: 200, damping: 28,
                                              initialVelocity: CGVector(dx: velocity.x/500, dy: velocity.y/500))
        let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)
        animator.addAnimations {
            self.center = self.initialCenter
            self.transform = .identity
            self.likeOverlay.alpha = 0
            self.nopeOverlay.alpha = 0
        }
        animator.startAnimation()
    }

    private func dismissCard(direction: SwipeDirection, velocity: CGPoint) {
        let targetX: CGFloat = direction == .right ? UIScreen.main.bounds.width * 1.5 : -UIScreen.main.bounds.width * 1.5
        let params = UISpringTimingParameters(mass: 0.8, stiffness: 150, damping: 20,
                                              initialVelocity: CGVector(dx: velocity.x/300, dy: velocity.y/300))
        let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)
        animator.addAnimations {
            self.center.x = targetX
            self.transform = CGAffineTransform(rotationAngle: direction == .right ? 0.5 : -0.5)
        }
        animator.addCompletion { _ in
            self.removeFromSuperview()
            self.onDismiss?(direction)
        }
        animator.startAnimation()
    }
}

Android Compose: drag + animate

@Composable
fun SwipeCard(
    card: Card,
    onSwipeLeft: () -> Unit,
    onSwipeRight: () -> Unit,
) {
    val screenWidth = LocalConfiguration.current.screenWidthDp.dp
    val threshold = screenWidth * 0.35f

    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }
    val rotation by remember { derivedStateOf { (offsetX / with(LocalDensity.current) { screenWidth.toPx() }) * 25f } }

    val animOffsetX = remember { Animatable(0f) }
    val animOffsetY = remember { Animatable(0f) }
    val coroutineScope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .offset { IntOffset(animOffsetX.value.roundToInt(), animOffsetY.value.roundToInt()) }
            .rotate(rotation)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { },
                    onDrag = { _, dragAmount ->
                        coroutineScope.launch {
                            animOffsetX.snapTo(animOffsetX.value + dragAmount.x)
                            animOffsetY.snapTo(animOffsetY.value + dragAmount.y)
                        }
                    },
                    onDragEnd = {
                        coroutineScope.launch {
                            val currentX = animOffsetX.value
                            val thresholdPx = with(density) { threshold.toPx() }

                            if (abs(currentX) > thresholdPx) {
                                val targetX = if (currentX > 0) size.width * 2f else -size.width * 2f
                                launch { animOffsetX.animateTo(targetX, spring(stiffness = Spring.StiffnessMediumLow)) }
                                launch { animOffsetY.animateTo(animOffsetY.value + 200f, spring()) }
                                delay(400)
                                if (currentX > 0) onSwipeRight() else onSwipeLeft()
                            } else {
                                launch { animOffsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) }
                                launch { animOffsetY.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) }
                            }
                        }
                    }
                )
            }
    ) {
        CardContent(card = card)
    }
}

Стек карточек

Управление стеком — через LazyColumn не подходит: карточки накладываются одна на другую. Правильно — Box (ZStack) с zIndex:

Box {
    cards.takeLast(3).reversed().forEachIndexed { index, card ->
        val stackIndex = 2 - index  // 0 = нижняя, 2 = верхняя
        SwipeCard(
            card = card,
            scale = 1f - (stackIndex * 0.05f),
            verticalOffset = (stackIndex * 12).dp,
            zIndex = stackIndex.toFloat(),
            onSwipe = { direction -> handleSwipe(card, direction) }
        )
    }
}

При dismiss верхней карточки — анимируем scale и offset остальных через animateFloatAsState.

Flutter: Dismissible и кастомный GestureDetector

Dismissible — встроенный виджет Flutter с свайпом, но только горизонтальный или вертикальный, без rotation. Для полного Tinder-паттерна — кастомный GestureDetector + AnimationController аналогично iOS-подходу.

Библиотека flutter_card_swiper: ^7.0.0 покрывает большинство кейсов без велосипедостроения.

Сроки

Базовый свайп стека карточек (одна платформа): 1–2 дня. С undo-анимацией, кастомными оверлеями и поддержкой вертикального свайпа: 2–3 дня. Стоимость рассчитывается индивидуально.