Реализация анимации раскрытия 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 на каждое состояние. onSlide callback обеспечивает параллельную анимацию скрима и контента.
В Flutter — DraggableScrollableSheet с DraggableScrollableController для программного управления + HapticFeedback.lightImpact() при достижении snap-точек. Для особо требовательных случаев используем пакет modal_bottom_sheet (woltapp), который реализует нативную физику через Simulation.
Нюансы, которые решаем отдельно
- Обработка safe area: на iPhone с Dynamic Island sheet не должен перекрывать индикатор Home и выезжать под нотч
- Клавиатура: при появлении
UIKeyboardsheet должен смещаться вверх с анимацией, согласованной по timing с системной клавиатурой (UIKeyboardAnimationDurationUserInfoKey) - Haptic feedback:
UIImpactFeedbackGeneratorпри snap на iOS,VibrationEffect.createOneShotна Android — небольшая деталь, которую пользователи замечают
Процесс работы
Начинаем с аудита существующего компонента или ТЗ на новый. Если есть дизайн в Figma с прототипом — извлекаем параметры пружины (stiffness, damping) из Figma Spring Animation и переносим в код напрямую. Если дизайна нет — согласовываем параметры на интерактивном прототипе перед финальной реализацией.
Тестирование обязательно включает slow animations mode (Simulator → Debug → Slow Animations) для проверки плавности и XCTest для регрессии жестов.
Срок реализации — 1–2 дня для одной платформы, включая интеграцию в существующий проект и тесты.







