Разработка анимаций жестов (свайп, пинч, долгое нажатие)
Жестовые анимации — это не декорация поверх обработчика жеста. Это сам интерфейс. Когда пользователь тянет карточку для удаления, визуальная обратная связь должна опережать палец, а не следовать за ним с задержкой.
Свайп: интерактивность и завершение
iOS: UIPanGestureRecognizer + пружинное завершение
Типичный кейс — свайп карточки с dismiss-анимацией при достижении порога. Структура:
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 — важная деталь. Без проверки скорости пользователь, резко смахнувший на малое расстояние, получит возврат вместо dismiss. Это раздражает.
Rubber band эффект при достижении границы
Когда карточка выходит за допустимый предел, движение должно замедляться по закону x' = x * d / (1 + x * 0.0015), где d — коэффициент упругости (обычно 0.55–0.75). Именно так работает bounce в UIScrollView. Реализуем сами при кастомных жестах:
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))
}
Пинч: масштабирование без артефактов
UIPinchGestureRecognizer и anchor point
Главная ошибка при реализации пинча — не устанавливать 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 {
// Clamp + spring return если вышли за пределы
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 сдвигает center — поэтому view.center = location обязателен сразу после изменения anchor.
Комбинирование пинча и панорамирования
UIGestureRecognizer по умолчанию не работают одновременно. Реализуем gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) в делегате:
func gestureRecognizer(_ a: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith b: UIGestureRecognizer) -> Bool {
return (a is UIPinchGestureRecognizer || a is UIPanGestureRecognizer)
&& (b is UIPinchGestureRecognizer || b is UIPanGestureRecognizer)
}
Долгое нажатие: haptic + visual lock
Long press — жест с состоянием ожидания. Визуальная анимация должна отображать «прогресс» до активации:
@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 перед появлением контекстного меню — это паттерн из нативных iOS приложений. Она даёт пользователю визуальное подтверждение, что жест «захвачен».
Compose: жестовые модификаторы
В Jetpack Compose жесты реализуются через Modifier.pointerInput:
Modifier.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
offsetX += pan.x
offsetY += pan.y
scale = (scale * zoom).coerceIn(0.5f, 3f)
}
}
detectTransformGestures объединяет pan + pinch одним обработчиком. Для spring-возврата при выходе за границы — Animatable с animateTo:
LaunchedEffect(isDragging) {
if (!isDragging) {
animatableOffset.animateTo(
targetValue = Offset.Zero,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
)
}
}
Ориентиры по срокам
Реализация жестовой анимации одного типа (свайп или пинч) с spring-возвратом — 1–2 дня. Полный набор — свайп + пинч + long press с хаптиками, на обеих платформах — 3–5 дней.







