Mobile App Gesture Animations (Swipe, Pinch, Long Press)
Gesture animations are not decoration over gesture handler. They are the interface itself. When user drags card for deletion, visual feedback should lead the finger, not follow it with delay.
Swipe: Interactivity and Completion
iOS: UIPanGestureRecognizer + Spring Completion
Typical case—swipe card with dismiss animation on reaching threshold. Structure:
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
}
}
}
Velocity threshold 800 pt/s is important detail. Without speed check, user who quickly flicked small distance will get return instead of dismiss. This is annoying.
Rubber Band Effect at Boundary
When card goes past allowed limit, movement should slow per law x' = x * d / (1 + x * 0.0015), where d is elasticity coefficient (usually 0.55–0.75). Exactly how bounce works in UIScrollView. Implement ourselves on custom gestures:
func rubberBand(value: CGFloat, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
let bandedValue = abs(value) - limit
guard bandedValue > 0 else { return value }
let sign: CGFloat = value > 0 ? 1 : -1
return sign * (limit + bandedValue * coefficient / (1 + bandedValue * 0.004))
}
Pinch: Scaling Without Artifacts
UIPinchGestureRecognizer and Anchor Point
Main mistake on pinch implementation—not setting view anchorPoint at finger pinch point. Without it, object scales from its center, not from touch point.
@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 {
// Clamp + spring return if exceeded limits
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
}
}
Changing anchorPoint shifts center—so view.center = location is mandatory immediately after anchor change.
Combining Pinch and Panning
UIGestureRecognizer by default do not work simultaneously. Implement gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) in delegate:
func gestureRecognizer(_ a: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith b: UIGestureRecognizer) -> Bool {
return (a is UIPinchGestureRecognizer || a is UIPanGestureRecognizer)
&& (b is UIPinchGestureRecognizer || b is UIPanGestureRecognizer)
}
Long Press: Haptic + Visual Lock
Long press is gesture with waiting state. Visual animation should display "progress" until activation:
@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
}
}
Scale-down animation to 0.93 before context menu appearance is pattern from native iOS apps. It gives user visual confirmation that gesture is "captured."
Compose: Gesture Modifiers
In Jetpack Compose gestures implemented via Modifier.pointerInput:
Modifier.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
offsetX += pan.x
offsetY += pan.y
scale = (scale * zoom).coerceIn(0.5f, 3f)
}
}
detectTransformGestures combines pan + pinch in one handler. For spring return on exceeding bounds—Animatable with animateTo:
LaunchedEffect(isDragging) {
if (!isDragging) {
animatableOffset.animateTo(
targetValue = Offset.Zero,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
)
}
}
Timeline Guidelines
Implementation of one gesture animation type (swipe or pinch) with spring return—1–2 days. Full set—swipe + pinch + long press with haptics, on both platforms—3–5 days.







