Реалізація Tinder-style Card Swipe анімації у мобільних додатках
Стек карточок зі свайпом — це усталений паттерн далеко за межами dating-додатків. Підбір товарів, оцінка контенту, quiz-інтерфейси — паттерн працює везде, де потрібні швидкі бінарні рішення. Технічне завдання: карточка слідує за пальцем, вертається пропорційно горизонтальному зміщенню, досягає порогу й летить прочі; нижня карточка масштабується та піднімається.
Ключові параметри анімації
Rotation: кут пропорційний drag. Формула: rotation = translationX / screenWidth * 25°. При максимальному зміщенні (~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 widget зі свайпом, але тільки горизонтальний/вертикальний без rotation. Для повного Tinder-паттерну — кастомний GestureDetector + AnimationController аналогічно iOS.
Бібліотека flutter_card_swiper: ^7.0.0 покриває більшість випадків без велосипедостроєння.
Терміни
Базовий свайп стека карточок (одна платформа): 1–2 дні. З undo-анімацією, кастомними оверлеями та підтримкою вертикального свайпу: 2–3 дні. Вартість розраховується індивідуально.







