Реалізація анімації раскрытия Bottom Sheet у мобільних додатках
Bottom Sheet — один із компонентів, де різниця між «працює» та «відчувається правильно» заметна одразу. Пружинисте розкриття, тактильна відповідь на свайп, плавне затемнення фону — користувач це відчуває, навіть не формулюючи словами. Якщо анімація дерев'яна або переривається при швидкому жесті — це сигнал про низьку якість всього продукту.
Де зазвичай ломається
Найпоширеніша проблема на iOS — конфлікт UIPanGestureRecognizer зі скролом всередину самого Bottom Sheet. Користувач тягне контент вгору, очікує скролю, а sheet замість цього починає сворачиватися. Стандартний UISheetPresentationController (доступний з iOS 15) справляється з цим через prefersGrabberVisible та фіксовані detents, але стоїть додати кастомний UIScrollView всередину — виникають проблеми з gestureRecognizerShouldBegin.
На Android аналогічна історія з BottomSheetBehavior з Material Components: при peekHeight рівному висоті контенту та включеному hideable = true sheet іноді захлопується при будь-якому випадковому свайпі вниз — тому що NestedScrollView не передає подію коректно при scrollY == 0.
У Flutter showModalBottomSheet дає мінімальний контроль над анімацією. Реальна кастомізація починається з DraggableScrollableSheet + AnimationController з SpringSimulation — тільки так виникає фізично достовірна пружинна поведінка замість лінійної ease-out.
Як ми це робимо
На iOS використовуємо комбінацію UIViewPropertyAnimator з UISpringTimingParameters та кастомний UIPresentationController. Це дозволяє переривати анімацію на середині жеста та плавно змінювати швидкість без артефактів. Ключевий момент: dampingRatio та initialVelocity повинні узгоджуватись з поточною швидкістю panGesture.velocity(in:) — інакше sheet «підпригує» при різкому відпусканні.
let velocity = panGesture.velocity(in: view)
let springParams = UISpringTimingParameters(
dampingRatio: 0.8,
initialVelocity: CGVector(dx: 0, dy: velocity.y / remainingDistance)
)
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: springParams)
На Android працюємо з BottomSheetBehavior + кастомним CoordinatorLayout.Behavior коли потрібна нестандартна поведінка. Для складних випадків з кількома snap-точками — MotionLayout з ConstraintSet на кожне стан. Callback onSlide забезпечує паралельну анімацію scrim та контенту.
У Flutter — DraggableScrollableSheet з DraggableScrollableController для програмного управління + HapticFeedback.lightImpact() при досягненні snap-точок. Для особливо вимогливих випадків використовуємо пакет modal_bottom_sheet (woltapp), який реалізує нативну фізику через Simulation.
Нюанси, які розв'язуємо окремо
- Обробка safe area: на iPhone з Dynamic Island sheet не повинен перекривати Home indicator та виїжджати під notch
- Клавіатура: при появі
UIKeyboardsheet повинен зміщуватися вгору з анімацією, узгодженою за timing системної клавіатури (UIKeyboardAnimationDurationUserInfoKey) - Haptic feedback:
UIImpactFeedbackGeneratorна iOS,VibrationEffect.createOneShotна Android — невелика деталь, яку користувачи помічають
Процес роботи
Починаємо з аудиту існуючого компонента або ТЗ на новий. Якщо є дизайн у Figma з прототипом — витягуємо параметри пружини (stiffness, damping) з Figma Spring Animation та переносимо в код напряму. Якщо дизайну немає — узгоджуємо параметри на інтерактивному прототипі перед фінальною реалізацією.
Тестування обов'язково включає slow animations mode (Simulator → Debug → Slow Animations) для перевірки гладкості та XCTest для регресії жестів.
Терміни: 1–2 дні на одну платформу, включаючи інтеграцію в існуючий проект та тести.







