אנימציית החלקת כרטיסים: בסגנון Tinder עם פיזיקת קפיץ

משתמשים מצפים שהממשק יגיב לכל תנועת אצבע, אבל פתרונות סטנדרטיים מרגישים לעתים קרובות "נוקשים" ופוגעים בחוויית המוצר. אנחנו מפתחים אנימציות החלקת כרטיסים בסגנון Tinder ל-iOS ואנדרואיד, ומכוונים את פיזיקת הקפיץ כך שהכרטיסים ינועו באופן טבעי ונעים. הצוות שלנו מספק את הפרויקט במפתח מלא—מאב-טיפוס ועד הטמעה, עם תמיכה מתמשכת וכיוונון עדין ל-UX שלכם.

פיתוח ותמיכה בכל סוגי היישומים הניידים:

יישומים ניידים למידע ובידור
אפליקציות חדשות, משחקים, מדריכים, קטלוגים מקוונים, אפליקציות מזג אוויר, אפליקציות כושר ובריאות, אפליקציות נסיעות, אפליקציות חינוכיות, רשתות חברתיות ומסנג'רים, חידונים, בלוגים ופודקאסטים, פורומים, אגרגטורים
יישומים ניידים למסחר אלקטרוני
חנויות מקוונות, אפליקציות B2B, מרקטפלייסים, בורסות מקוונות, שירותי קאשבק, בורסות, פלטפורמות דרופשיפינג, תוכניות נאמנות, משלוחי מזון ומוצרים, מערכות תשלום.
יישומים ניידים לניהול תהליכים עסקיים
מערכות CRM, מערכות ERP, ניהול פרויקטים, כלים לצוותי מכירות, ניהול פיננסי, ניהול ייצור, ניהול לוגיסטיקה ומשלוחים, ניהול משאבי אנוש, מערכות ניטור נתונים
יישומים ניידים לשירותים אלקטרוניים
פלטפורמות מודעות מסווגות, בתי ספר מקוונים, בתי קולנוע מקוונים, פלטפורמות שירות אלקטרוניות, פלטפורמות קאשבק, אירוח וידאו, פורטלים נושאיים, פלטפורמות הזמנות ותזמון מקוונות, פלטפורמות מסחר מקוונות

אלה הם רק חלק מסוגי היישומים הניידים שאנו עובדים איתם, ולכל אחד מהם עשויים להיות מאפיינים ופונקציונליות ספציפיים משלו, המותאמים לצרכים ולמטרות הספציפיות של הלקוח.

השירותים שאנו מציעים
מציג 1 מתוך 1כל 1734 השירותים
אנימציית החלקת כרטיסים: בסגנון Tinder עם פיזיקת קפיץ
בינוני
מ- 1 יום עד 3 ימים

הכישורים שלנו:

שאלות נפוצות

העבודות האחרונות

  • פיתוח אפליקציית מובייל עבור FEEDME
    פיתוח אפליקציית מובייל עבור FEEDME
    971
  • פיתוח אפליקציית מובייל עבור XOOMER
    פיתוח אפליקציית מובייל עבור XOOMER
    868
  • פיתוח אפליקציית מובייל עבור RHL
    פיתוח אפליקציית מובייל עבור RHL
    1288
  • פיתוח אפליקציית מובייל עבור ZIPPY
    פיתוח אפליקציית מובייל עבור ZIPPY
    1143
  • פיתוח אפליקציית מובייל עבור Affhome
    פיתוח אפליקציית מובייל עבור Affhome
    1076
  • פיתוח אפליקציית מובייל עבור חברת FLAVORS
    פיתוח אפליקציית מובייל עבור חברת FLAVORS
    677

אנימציית החלקת כרטיסים: בסגנון 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, ווידג'טים

תהליך

  1. ניתוח — הגדרת תרחישי UX, מהירות יעד (בדרך כלל 500-1500 dp/s), זווית סיבוב (15-25°), התנהגות ערימה.
  2. עיצוב — יצירת אב טיפוס אנימציה ב-Figma או After Effects.
  3. יישום — כתיבת קוד באמצעות ממשקי API של הפלטפורמה (UISpringTimingParameters, Compose animation).
  4. בדיקות — בדיקה על מכשירים אמיתיים, מדידת fps (יעד 60 fps), התאמת פרמטרים.
  5. השקה — שחרור ל-TestFlight / Google Play Internal Testing.

לוחות זמנים משוערים

  • אנימציה בסיסית (החלקה + סיבוב) — מיום אחד.
  • עם ביטול, שכבות וערימה מורכבת — עד 3 ימים.
  • העלות מחושבת באופן אישי — צרו קשר להערכת תקציב.

מה כלול

  • קוד מקור עם הערות (iOS/Android/Flutter).
  • אינטגרציה עם API קיים (אם נדרש).
  • תיעוד על כוונון פרמטרים.
  • תמיכה למשך שבועיים לאחר ההשקה.

חסכו עד 3 ימי פיתוח על אנימציות. עלות הפיתוח מחושבת באופן אישי. קבלו ייעוץ ממהנדס עם ניסיון של למעלה מ-5 שנים בפיתוח מובייל. הזמינו פיתוח אנימציית החלקת כרטיסים, ואנחנו נכוון את הפיזיקה ל-UX שלכם.