Реализация анимации свайпа карточек (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 дня. Стоимость рассчитывается индивидуально.







