אנימציות מחוות: מהחלקה ללחיצה ארוכה
המשתמש מחליק כרטיס, אבל הוא מגמגם או לא עף מקצה המסך. הסיבה היא לרוב חישוב שגוי של סף ומהירות. או היעדר החזרה קפיצית כאשר חורגים מהגבולות. אנו פותרים בעיות כאלה באמצעות מודלים מתמטיים: מערכות קפיציות עם שיכוך, טרנספורמציות אפיניות, ומשוב הפטי. בפועל אנו משתמשים בשיכוך קפיצי damping=0.7, velocity=0.5 כדי שהאנימציה תרגיש טבעית. לדוגמה, בכרטיס החלקה-למחיקה, המשוב החזותי מקדים את האצבע ולא נגרר אחריה—הודות לכוונון מדויק של סף ומהירות.
בלב ממשקי המחוות (ראה זיהוי מחוות) עומדת המתמטיקה. אנו מיישמים מודלים עם שיכוך וקשיחות. הניסיון שלנו: מעל 50 פרויקטים עם אנימציות מחוות ב-Swift 5.9+, Kotlin, ו-Jetpack Compose. כפי שצוין בהנחיות ממשק המשתמש של Apple, מחוות חייבות להיות אינטואיטיביות ומגיבות.
טעויות אופייניות שאנו מתקנים
- חוסר בדיקת מהירות בהחלקה: בלעדיה, משתמשים יכולים למחוק אלמנט בטעות.
- נקודת עיגון שגויה בצביטה, הגורמת לקנה מידה מהמרכז במקום מנקודת המגע.
- אין החזרה קפיצית כאשר חורגים מהגבולות: הממשק נראה מקוטע.
- התעלמות ממשוב הפטי בלחיצה ארוכה—למחווה חסרה תחושה מישושית.
אנו מבטיחים שכל האנימציות יתאימו לסטנדרטים המקוריים של הפלטפורמה: ב-iOS—UIKit ו-SwiftUI, ב-Android—Jetpack Compose ו-MotionLayout. במידת הצורך, אנו משלבים Core Animation לאופטימיזציית ביצועים. לפני התחלה, אנו מבצעים ביקורת על היישום הקיים—לעתים קרובות הבעיות מסתתרות בתצורת מזהה המחוות או בנקודת העיגון.
קבלו ביקורת על אנימציות האפליקציה שלכם—אנו נמצא ונתקן באגים.
יישום החלקה עם השלמה קפיצית
מקרה אופייני: החלקת כרטיס עם אנימציית סגירה כאשר מגיעים לסף. מבנה:
class SwipeableCardView: UIView {
private var initialCenter: CGPoint = .zero
private let dismissThreshold: CGFloat = 120
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: superview)
let velocity = gesture.velocity(in: superview)
switch gesture.state {
case .changed:
center = CGPoint(x: initialCenter.x + translation.x, y: initialCenter.y + translation.y)
let progress = abs(translation.x) / dismissThreshold
let angle = (translation.x / UIScreen.main.bounds.width) * 0.4
transform = CGAffineTransform(rotationAngle: angle)
alpha = 1 - min(progress * 0.3, 0.3)
case .ended:
let shouldDismiss = abs(translation.x) > dismissThreshold || abs(velocity.x) > 800
if shouldDismiss {
let direction: CGFloat = translation.x > 0 ? 1 : -1
let targetX = direction * UIScreen.main.bounds.width * 1.5
UIView.animate(
withDuration: 0.28,
delay: 0,
options: .curveEaseOut
) {
self.center.x = targetX
self.alpha = 0
} completion: { _ in
self.removeFromSuperview()
}
} else {
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0.5
) {
self.center = self.initialCenter
self.transform = .identity
self.alpha = 1
}
}
default:
break
}
}
}סף מהירות class SwipeableCardView: UIView { private var initialCenter: CGPoint = .zero private let dismissThreshold: CGFloat = 120 @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: superview) let velocity = gesture.velocity(in: superview) switch gesture.state { case .changed: center = CGPoint(x: initialCenter.x + translation.x, y: initialCenter.y + translation.y) let progress = abs(translation.x) / dismissThreshold let angle = (translation.x / UIScreen.main.bounds.width) * 0.4 transform = CGAffineTransform(rotationAngle: angle) alpha = 1 - min(progress * 0.3, 0.3) case .ended: let shouldDismiss = abs(translation.x) > dismissThreshold || abs(velocity.x) > 800 if shouldDismiss { let direction: CGFloat = translation.x > 0 ? 1 : -1 let targetX = direction * UIScreen.main.bounds.width * 1.5 UIView.animate( withDuration: 0.28, delay: 0, options: .curveEaseOut ) { self.center.x = targetX self.alpha = 0 } completion: { _ in self.removeFromSuperview() } } else { UIView.animate( withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5 ) { self.center = self.initialCenter self.transform = .identity self.alpha = 1 } } default: break } } } הוא פרט קריטי. ללא בדיקת מהירות, משתמש שמחליק במהירות על פני מרחק קצר יקבל חזרה במקום סגירה. זה מתסכל. עבור גלילת יתר, אנו מיישמים אפקט גומי: 800 pt/s, כאשר d הוא מקדם המתיחה (0.55–0.75).
יישום אפקט גומי שלב אחר שלב
- קבעו את גבולות המכולה.
- חשבו את החריגה מעבר לגבול:
x' = x * d / (1 + x * 0.0015). - החילו טרנספורמציה:
overflow = offset - bound. - החזירו את האובייקט בשחרור עם אנימציה קפיצית.
למה נקודת עיגון נכונה חשובה בצביטה
טעות נפוצה ביישום צביטה היא אי הגדרת clamped = overflow * damping / (1 + overflow * 0.0015) של התצוגה לנקודה שבה האצבעות נפגשות. ללא זה, האובייקט משנה קנה מידה מהמרכז שלו במקום מנקודת המגע.
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
guard let view = gesture.view else { return }
if gesture.state == .began {
let location = gesture.location(in: view.superview)
let anchorX = (location.x - view.frame.minX) / view.frame.width
let anchorY = (location.y - view.frame.minY) / view.frame.height
view.layer.anchorPoint = CGPoint(x: anchorX, y: anchorY)
view.center = location
}
let newScale = currentScale * gesture.scale
view.transform = CGAffineTransform(scaleX: newScale, y: newScale)
gesture.scale = 1.0
if gesture.state == .ended {
let clampedScale = max(minScale, min(maxScale, newScale))
if newScale != clampedScale {
UIView.animate(
withDuration: 0.35,
delay: 0,
usingSpringWithDamping: 0.65,
initialSpringVelocity: 0.3
) {
view.transform = CGAffineTransform(scaleX: clampedScale, y: clampedScale)
}
}
currentScale = clampedScale
}
}שינוי anchorPoint מזיז את @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { guard let view = gesture.view else { return } if gesture.state == .began { let location = gesture.location(in: view.superview) let anchorX = (location.x - view.frame.minX) / view.frame.width let anchorY = (location.y - view.frame.minY) / view.frame.height view.layer.anchorPoint = CGPoint(x: anchorX, y: anchorY) view.center = location } let newScale = currentScale * gesture.scale view.transform = CGAffineTransform(scaleX: newScale, y: newScale) gesture.scale = 1.0 if gesture.state == .ended { let clampedScale = max(minScale, min(maxScale, newScale)) if newScale != clampedScale { UIView.animate( withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: 0.3 ) { view.transform = CGAffineTransform(scaleX: clampedScale, y: clampedScale) } } currentScale = clampedScale } } —לכן anchorPoint הוא חובה מיד לאחר שינוי העוגן. לטיפול בו-זמני בצביטה והזזה, השתמשו בנציג:
func gestureRecognizer(_ a: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith b: UIGestureRecognizer) -> Bool {
return (a is UIPinchGestureRecognizer || a is UIPanGestureRecognizer) && (b is UIPinchGestureRecognizer || b is UIPanGestureRecognizer)
} איך להוסיף משוב הפטי בלחיצה ארוכה
לחיצה ארוכה היא מחווה עם מצב המתנה. אנימציה חזותית צריכה להראות 'התקדמות' עד להפעלה:
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
UIView.animate(withDuration: 0.15) {
self.targetView.transform = CGAffineTransform(scaleX: 0.93, y: 0.93)
}
startContextMenuAnimation()
case .ended, .cancelled:
UIView.animate(
withDuration: 0.3,
delay: 0,
usingSpringWithDamping: 0.55,
initialSpringVelocity: 8
) {
self.targetView.transform = .identity
}
default:
break
}
}אנימציית הכיווץ ל-center לפני הופעת תפריט ההקשר היא דפוס מאפליקציות iOS מקוריות. היא נותנת למשתמש אישור חזותי שהמחווה 'נתפסה'.
Jetpack Compose: קוד תמציתי למחוות
ב-Jetpack Compose, מחוות מיושמות באמצעות view.center = location:
Modifier.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
offsetX += pan.x
offsetY += pan.y
scale = (scale * zoom).coerceIn(0.5f, 3f)
}
}func gestureRecognizer(_ a: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith b: UIGestureRecognizer) -> Bool { return (a is UIPinchGestureRecognizer || a is UIPanGestureRecognizer) && (b is UIPinchGestureRecognizer || b is UIPanGestureRecognizer) } משלב הזזה + צביטה במטפל אחד. להחזרה קפיצית כאשר חורגים מהגבולות, השתמשו ב-@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: UIImpactFeedbackGenerator(style: .medium).impactOccurred() UIView.animate(withDuration: 0.15) { self.targetView.transform = CGAffineTransform(scaleX: 0.93, y: 0.93) } startContextMenuAnimation() case .ended, .cancelled: UIView.animate( withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 8 ) { self.targetView.transform = .identity } default: break } } עם 0.93 ו-Modifier.pointerInput.
UIKit לעומת SwiftUI להחלקה
| פרמטר | UIKit (UIPanGestureRecognizer) | SwiftUI (DragGesture) |
|---|---|---|
| אנימציה קפיצית | Modifier.pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> offsetX += pan.x offsetY += pan.y scale = (scale * zoom).coerceIn(0.5f, 3f) } } |
detectTransformGestures |
| סף מהירות | בדיקה ידנית של Animatable |
זמין דרך animateTo |
| מורכבות | בינונית (דורש נציג) | נמוכה (הצהרתי) |
התהליך שלנו
| שלב | תיאור | לוח זמנים (ימי עבודה) |
|---|---|---|
| ניתוח | לימוד מוקאפים ודרישות אנימציה | 0.5 |
| עיצוב | יצירת אבות טיפוס ומפרטי מחוות | 0.5 |
| יישום | קוד ב-Swift/Kotlin עם אינטגרציה לפרויקט | 1–2 |
| בדיקות | בדיקה על מכשירים אמיתיים, ניפוי באגים | 0.5 |
| פריסה | הגשה ל-TestFlight/Google Play Console | 0.5 |
מה כלול
- קוד מקור עם הערות ותיעוד
- אינטגרציה עם הארכיטקטורה הקיימת (אם נדרש)
- הגדרת חתימת קוד ופרופילי provisioning ל-iOS
- ייעוץ בנושא הנחיות App Store Review ו-Google Play
- חודש תמיכה לאחר המסירה
הערכות לוח זמנים
יישום סוג מחווה יחיד (החלקה או צביטה) עם החזרה קפיצית — 1–2 ימים. סט מלא — החלקה + צביטה + לחיצה ארוכה עם הפטיקה בשתי הפלטפורמות — 3–5 ימים. נספק הערכה מדויקת לאחר סקירת הפרויקט שלכם.
קבלו ייעוץ ממהנדס אנימציות המחוות שלנו עבור האפליקציה שלכם.







