אנימציית החלקת כרטיסים: בסגנון Tinder עם פיזיקת קפיץ
ערימת הכרטיסים עם החלקה היא דפוס מבוסס היטב לא רק באפליקציות היכרויות. בחירת מוצרים, דירוג תוכן, ממשקי חידונים — זה עובד בכל מקום שבו נדרשת החלטה בינארית מהירה. האתגר הטכני: הכרטיס עוקב אחרי האצבע, מסתובב ביחס ישר לתזוזה אופקית (זווית אופיינית של 15-25°), כאשר מגיעים לסף (40% מרוחב המסך, מהירות > 800 dp/s) הוא עף לצד, והכרטיס הבא מתכוונן ל-95% ומתרומם ב-20 dp. ללא אנימציית קפיץ מתאימה, המשתמש מרגיש "נוקשות" — הכרטיס או איטי מדי או עף כמו פלסטיק. כל אנימציה דורשת כוונון מדויק של מסה (0.8–1.2), קשיחות (150–300), ושיכוך (20–30) להתנהגות טבעית. יישמנו למעלה מ-30 פרויקטים עם אנימציות כאלה, וצברנו ניסיון בכוונון פרמטרים פיזיקליים ל-UX ייחודי.
בעיות שאנחנו פותרים
- סיבוב וסף. זווית הסיבוב חייבת להיות תלויה ביחס ישר בתזוזה האופקית, והסף במהירות, כך שהחלקות קצרות וחדות יפעלו כראוי. שגיאה במקדם (למשל, 10° במקום 25°) הופכת את האנימציה ללא טבעית.
- אנימציית ערימה. הכרטיסים התחתונים חייבים להתכוונן ולהתרומם כאשר הכרטיס העליון נדחה. ללא עיכוב (100-200 אלפיות השנייה), הערימה מרגישה מקוטעת.
- ביטול ואנימציה הפוכה. בעת ביטול, הכרטיס חייב לחזור בצורה חלקה באמצעות אנימציית קפיץ עם יחס שיכוך של 0.7-0.8.
למה אנימציית קפיץ עדיפה על פני סטנדרטית?
אנימציית קפיץ (מסה-קפיץ-שיכוך) מחקה זריקה אמיתית, בניגוד לאינטרפולציה לינארית. משתמשים תופסים אותה כטבעית יותר — שיפור בשמירת משתמשים ב-10-15% לפי מבחני A/B. אנימציית קפיץ מגיבה פי 3 יותר לתנועות חדות מכיוון שהמודל הפיזיקלי מתחשב באינרציה ובגמישות.
איך פרמטרי הקפיץ משפיעים על ההתנהגות
| פרמטר | iOS (UISpringTimingParameters) | Android (Spring.DampingRatio) | השפעה |
|---|---|---|---|
| מסה | 0.8-1.2 | לא בשימוש | מסה גבוהה יותר מאטה את התנועה, מה שהופך אותה לחלקה יותר |
| קשיחות | 150-300 | Spring.StiffnessMediumLow (200) | קשיחות גבוהה יותר מאיצה את החזרה, אך עלולה לגרום לקפיצה |
| שיכוך | 20-30 | 0.5-0.8 (dampingRatio) | שיכוך נמוך יותר נותן יותר קפיציות, גבוה יותר גורם לדעיכה חלקה |
איך אנחנו עושים את זה: פירוט קוד
לעיון מעמיק ב-UISpringTimingParameters ראו תיעוד המפתחים של Apple.
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: Jetpack Compose עם 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() } }
@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)
}
} Flutter: יישום מותאם אישית
Animatable הוא ווידג'ט מובנה של Flutter עם החלקה, אבל רק אופקית או אנכית, ללא סיבוב. לתבנית Tinder מלאה, השתמשו ב-@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) } } + Dismissible מותאם אישית בדומה לגישת iOS. הספרייה GestureDetector מכסה את רוב המקרים ללא צורך להמציא את הגלגל מחדש.
איך להשיג אנימציה חלקה?
הגורם המרכזי הוא שימוש במנועי פיזיקה: AnimationController ב-iOS, flutter_card_swiper: ^7.0.0 ב-Android, UISpringTimingParameters עם עקומת Spring.DampingRatio. המהנדסים שלנו מכווננים את פרמטרי המסה, הקשיחות והשיכוך ל-UX הספציפי. זה נותן תחושה פיזיקלית לכרטיס. אנחנו מבטיחים שלאחר הכוונון האנימציה תרוץ ב-60 fps במכשירים מ-iPhone 8 ו-Android 8.
טעויות יישום אופייניות
- סף מהירות שגוי (מתחת ל-600 dp/s מוביל להפעלות מקריות, מעל 1200 dp/s דורש מאמץ רב מדי).
- חוסר התכווננות של הכרטיס הבא (בעת דחייה הוא צריך להתכווץ ב-5% ולהזיז ב-10-20 dp).
- שכחת הגדרת initialVelocity באנימציית הקפיץ לחזרה — בלעדיה הכרטיס נעצר בפתאומיות.
- שימוש באנימציה לינארית במקום קפיץ — גורם לתנועה לא טבעית.
השוואת גישות לפי מורכבות
| פלטפורמה | זמן יישום בסיסי | קושי התאמה אישית | ניסיון נדרש |
|---|---|---|---|
| iOS | 1-2 ימים | נמוך | Swift, UIKit/SwiftUI |
| Android | 1-2 ימים | בינוני | Kotlin, Compose |
| Flutter | 0.5-1 יום | בינוני | Dart, ווידג'טים |
תהליך
- ניתוח — הגדרת תרחישי UX, מהירות יעד (בדרך כלל 500-1500 dp/s), זווית סיבוב (15-25°), התנהגות ערימה.
- עיצוב — יצירת אב טיפוס אנימציה ב-Figma או After Effects.
- יישום — כתיבת קוד באמצעות ממשקי API של הפלטפורמה (UISpringTimingParameters, Compose animation).
- בדיקות — בדיקה על מכשירים אמיתיים, מדידת fps (יעד 60 fps), התאמת פרמטרים.
- השקה — שחרור ל-TestFlight / Google Play Internal Testing.
לוחות זמנים משוערים
- אנימציה בסיסית (החלקה + סיבוב) — מיום אחד.
- עם ביטול, שכבות וערימה מורכבת — עד 3 ימים.
- העלות מחושבת באופן אישי — צרו קשר להערכת תקציב.
מה כלול
- קוד מקור עם הערות (iOS/Android/Flutter).
- אינטגרציה עם API קיים (אם נדרש).
- תיעוד על כוונון פרמטרים.
- תמיכה למשך שבועיים לאחר ההשקה.
חסכו עד 3 ימי פיתוח על אנימציות. עלות הפיתוח מחושבת באופן אישי. קבלו ייעוץ ממהנדס עם ניסיון של למעלה מ-5 שנים בפיתוח מובייל. הזמינו פיתוח אנימציית החלקת כרטיסים, ואנחנו נכוון את הפיזיקה ל-UX שלכם.







