Bottom Sheet, перейдём на «ты»?
Bottom Sheet представлялся мне сложным и недосягаемым. Это был вызов! Я не понимал, с чего начать. Возникало много вопросов: использовать view или view controller? Auto или manual layout? Как анимировать? Как скрывать Bottom Sheet интерактивно?
Но всё изменилось после работы над Bottom Sheet для приложения Joom, где он используется повсеместно. В том числе и в таких критических сценариях, как оплата. Так что могу точно сказать, что в этом компоненте мы уверены. Настолько уверены, что я даже рассказывал о нём на Podlodka iOS crew #7. В рамках воркшопа я показал, как сделать Bottom Sheet, который умеет подстраиваться под размер контента, интерактивно закрывается и поддерживает UINavigationController.
Стоп, но Apple же предоставила системный Bottom Sheet. Зачем писать свой? Действительно, это так, но компонент поддерживается только с iOS 15. А это значит, что полноценно его можно будет использовать только через 2–3 года. К тому же часто требования дизайнеров выходят за рамки стандартных iOS-элементов.
В рамках статьи хочу развеять туман над Bottom Sheet, ответить на вопросы, которыми задавался я сам и предложить один из вариантов реализации. Чтобы в конце вы могли добавить в резюме строчку «Профессионально делаю Bottom Sheet’ы».
Если заинтересовал, то начнём! Создадим простой Bottom Sheet и шаг за шагом его прокачаем.
Научимся подстраиваться под размер контента и закрывать Bottom Sheet.
Добавим интерактивное закрытие, учитывая контент, который скроллится.
Поддержим UINavigationController с навигацией внутри Bottom Sheet.
Часть 1. Адаптируемся под размер контента. Закрываем Bottom Sheet. Базовый дизайн
Стартовый проект
Вот ссылка на стартовый проект на Github. В проекте есть два таргета: BottomSheetDemo и BottomSheet — приложение и библиотека с Bottom Sheet.
Структура проекта
BottomSheetDemo
Sources/User Interface
Screens
Resize
ResizeViewController.swift
Root
RootViewController.swift
BottomSheet
Core
BottomSheetModalDismissalHandler.swift
BottomSheetPresentationController.swift
BottomSheetPresentationController+PullBar.swift
BottomSheetTransitioningDelegate.swift
Helpers
...
RootViewController — это первый экран в приложении. В нём есть всего одна кнопка Show Bottom Sheet. По нажатию покажется ResizeViewController.
@objc
private func handleShowBottomSheet() {
let viewController = ResizeViewController(initialHeight: 300)
present(viewController, animated: true, completion: nil)
}
В инициализаторе ResizeViewController принимает высоту контента. Также есть четыре кнопки, которые изменяют высоту контента: на +100 и -100, в 2 и 0.5 раз.
Запустим приложение.
Теория. Как показывать Bottom Sheet?
Нам нужна сущность, которая будет управлять показом. Она добавит Bottom Sheet в UI-иерархию, расположит его на экране, учтёт размер контента, будет реагировать на его изменения, позаботится об анимации, и можно будет сделать интерактивное закрытие.
Это похоже на зону ответственности UIPresentationController. С момента появления view controller’а и до момента скрытия, UIKit использует presentation controller для управления процессом показа.
Для его использования надо переопределить modalPresentationStyle и передать presentation controller через transitioningDelegate.
Вооружившись этим знанием, начнём делать Bottom Sheet!
Создадим presentation controller
Для показа Bottom Sheet переопределим modalPresentationStyle и transitioningDelegate. Не забываем, что transitioningDelegate — это weak ссылка, и нам понадобится strong ссылка, чтобы не потерять объект.
private var bottomSheetTransitioningDelegate: UIViewControllerTransitioningDelegate?
@objc
private func handleShowBottomSheet() {
let viewController = ResizeViewController(initialHeight: 300)
// TODO: bottomSheetTransitioningDelegate = ...
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = bottomSheetTransitioningDelegate
present(viewController, animated: true, completion: nil)
}
Создадим BottomSheetTransitioningDelegate — реализацию transitioningDelegate.
public final class BottomSheetTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
private func _presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> BottomSheetPresentationController {
BottomSheetPresentationController(
presentedViewController: presented,
presenting: presenting
)
}
}
И presentation controller.
public final class BottomSheetPresentationController: UIPresentationController {}
Наконец вернёмся в RootViewController и закроем TODO.
// TODO: bottomSheetTransitioningDelegate = ...
bottomSheetTransitioningDelegate = BottomSheetTransitioningDelegate()
Давайте запустим приложение.
Как будто стало только хуже. View controller открывается во весь экран и скрывается за status bar. Мы переопределили системный presentation controller, который показывал view controller красиво, позиционировал его с учетом safeArea. В нашем presentation controller ничего подобного нет, мы никак не указываем положение view controller, давайте исправимся.
Учитываем размер контента
Вернёмся к ResizeViewController. Поле currentHeight отвечает за текущую высоту. Чтобы не создавать лишних протоколов, используем preferredContentSize. Он будет показывать текущий желаемый размер для Bottom Sheet.
В presentation controller переопределим frameOfPresentedViewInContainerView, который отвечает за положение presentedView. В нашем случае presentedView — это view ResizeViewController. containerView — это view, которая содержит presentedView и куда можно добавить, например, тень.
public override var frameOfPresentedViewInContainerView: CGRect {
targetFrameForPresentedView()
}
private func targetFrameForPresentedView() -> CGRect {
guard let containerView = containerView else {
return .zero
}
let windowInsets = presentedView?.window?.safeAreaInsets ?? .zero
let preferredHeight = presentedViewController.preferredContentSize.height + windowInsets.bottom
let maxHeight = containerView.bounds.height - windowInsets.top
let height = min(preferredHeight, maxHeight)
return .init(
x: 0,
y: (containerView.bounds.height - height).pixelCeiled,
width: containerView.bounds.width,
height: height.pixelCeiled
)
}
Дополнительно укажем shouldPresentInFullscreen в false, потому что Bottom Sheet покрывает не весь экран.
public override var shouldPresentInFullscreen: Bool {
false
}
Посмотрим, что получилось.
Изначальный размер учитывается, но нет реакции на его изменения.
Реагируем на изменение контента
Рассмотрим UIPresentationController. Он реализует UIContentContainer, в котором нам интересен preferredContentSizeDidChange (forChildContentContainer:), который вызывается при изменениях preferredContentSize в дочерних view controller’ах.
public override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) {
updatePresentedViewSize()
}
private func updatePresentedViewSize() {
guard let presentedView = presentedView else {
return
}
let oldFrame = presentedView.frame
let targetFrame = targetFrameForPresentedView()
if !oldFrame.isAlmostEqual(to: targetFrame) {
presentedView.frame = targetFrame
}
}
Проверяем текущий frame и тот, который мы считаем правильным. Если они разные, то обновляем presentedView.frame. Запустим приложение.
Размер изменяется неравномерно без анимации. Почему? Потому что мы никак не указываем эту анимацию. Добавим анимацию на изменения preferredContentSize в ResizeViewController.
UIView.animate(
withDuration: 0.25,
animations: { [self] in
preferredContentSize = CGSize(
width: UIScreen.main.bounds.width,
height: newValue
)
}
)
Проверяем.
Работает! Но мы никак не можем уйти с Bottom Sheet.
Закрываем Bottom Sheet
Для закрытия добавим тень и по нажатию будем скрывать Bottom Sheet. Создадим обработчик закрытия, через который presentation controller будет сообщать, что готов, чтобы его закрыли.
public protocol BottomSheetModalDismissalHandler {
func performDismissal(animated: Bool)
}
Передаём его в инициализатор presentation controller.
private let dismissalHandler: BottomSheetModalDismissalHandler
public init(
presentedViewController: UIViewController,
presentingViewController: UIViewController?,
dismissalHandler: BottomSheetModalDismissalHandler
) {
self.dismissalHandler = dismissalHandler
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
}
Для удобства создадим фабрику presentation controller’а.
public protocol BottomSheetPresentationControllerFactory {
func makeBottomSheetPresentationController(
presentedViewController: UIViewController,
presentingViewController: UIViewController?
) -> BottomSheetPresentationController
}
Которая будет использоваться внутри BottomSheetTransitioningDelegate.
private let factory: BottomSheetPresentationControllerFactory
public init(factory: BottomSheetPresentationControllerFactory) {
self.factory = factory
}
public func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
factory.makeBottomSheetPresentationController(
presentedViewController: presented,
presentingViewController: presenting
)
}
В RootViewController реализуем фабрику и обработчик закрытия. Скрываем presentedViewController, потому что это и есть Bottom Sheet.
extension RootViewController: BottomSheetPresentationControllerFactory {
func makeBottomSheetPresentationController(
presentedViewController: UIViewController,
presentingViewController: UIViewController?
) -> BottomSheetPresentationController {
.init(
presentedViewController: presentedViewController,
presentingViewController: presentingViewController,
dismissalHandler: self
)
}
}
extension RootViewController: BottomSheetModalDismissalHandler {
func performDismissal(animated: Bool) {
presentedViewController?.dismiss(animated: animated, completion: nil)
}
}
Теперь в presentation controller’е сконфигурируем тень с обработчиком скрытия. Добавляем тень перед началом транзишена и убираем после окончания.
Для начала нам понадобится отслеживать состояние presentation controller. Введём поле state, которое будет отвечать за текущее состояние Bottom Sheet. Для отслеживания состояния переопределим методы жизненного цикла транзишена.
Жизненный цикл presentation controller
// MARK: - Nested types
private enum State {
case dismissed
case presenting
case presented
case dismissing
}
// MARK: - Private properties
private var state: State = .dismissed
// MARK: - UIPresentationController
public override func presentationTransitionWillBegin() {
state = .presenting
}
public override func presentationTransitionDidEnd(_ completed: Bool) {
if completed {
state = .presented
} else {
state = .dismissed
}
}
public override func dismissalTransitionWillBegin() {
state = .dismissing
}
public override func dismissalTransitionDidEnd(_ completed: Bool) {
if completed {
state = .dismissed
} else {
state = .presented
}
}
Далее возникает вопрос, в какой момент добавлять и удалять тень. Добавлять тень будем перед показом Bottom Sheet, а удалять — сразу после скрытия Bottom Sheet.
public override func presentationTransitionWillBegin() {
state = .presenting
addSubviews()
}
public override func dismissalTransitionDidEnd(_ completed: Bool) {
if completed {
removeSubviews()
state = .dismissed
} else {
state = .presented
}
}
Остаётся реализовать addSubviews () и removeSubviews ().
Добавляем и удаляем тень — addSubviews () и removeSubviews ()
private func addSubviews() {
guard let containerView = containerView else {
assertionFailure()
return
}
setupShadingView(containerView: containerView)
}
private func setupShadingView(containerView: UIView) {
let shadingView = UIView()
containerView.addSubview(shadingView)
shadingView.backgroundColor = UIColor.black.withAlphaComponent(0.6)
shadingView.frame = containerView.bounds
let tapGesture = UITapGestureRecognizer()
shadingView.addGestureRecognizer(tapGesture)
tapGesture.addTarget(self, action: #selector(handleShadingViewTapGesture))
self.shadingView = shadingView
}
@objc
private func handleShadingViewTapGesture() {
dismissIfPossible()
}
private func removeSubviews() {
shadingView?.removeFromSuperview()
shadingView = nil
}
private func dismissIfPossible() {
let canBeDismissed = state == .presented
if canBeDismissed {
dismissalHandler.performDismissal(animated: true)
}
}
Посмотрим, что получилось.
Отлично, теперь у нас добавляется тень, и по нажатию Bottom Sheet закрывается! Но тень появляется и исчезает без анимации.
Анимированный транзишен
Как быть? Тень относится к транзишену и должна анимироваться вместе с ним. Поэтому нам нужно встроиться в transitioning delegate.
Реализуем протокол UIViewControllerAnimatedTransitioning, в котором будет логика для анимированного поднимания и опускания шторки. Ровно такая же, как и у системы, но дополнительно добавим fade-анимацию для тени.
Поднимаем и опускаем шторку через animated transitioning
Для простоты реализуем протокол внутри presentation controller, потому что у него уже есть доступ к нужным UI-элементам.
extension BottomSheetPresentationController: UIViewControllerAnimatedTransitioning {
public func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
0.3
}
public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard
let sourceViewController = transitionContext.viewController(forKey: .from),
let destinationViewController = transitionContext.viewController(forKey: .to),
let sourceView = sourceViewController.view,
let destinationView = destinationViewController.view
else {
return
}
let isPresenting = destinationViewController.isBeingPresented
let presentedView = isPresenting ? destinationView : sourceView
let containerView = transitionContext.containerView
if isPresenting {
containerView.addSubview(destinationView)
destinationView.frame = containerView.bounds
}
sourceView.layoutIfNeeded()
destinationView.layoutIfNeeded()
let frameInContainer = frameOfPresentedViewInContainerView
let offscreenFrame = CGRect(
origin: CGPoint(
x: 0,
y: containerView.bounds.height
),
size: sourceView.frame.size
)
presentedView.frame = isPresenting ? offscreenFrame : frameInContainer
pullBar?.frame.origin.y = presentedView.frame.minY - Style.pullBarHeight + pixelSize
shadingView?.alpha = isPresenting ? 0 : 1
let animations = {
presentedView.frame = isPresenting ? frameInContainer : offscreenFrame
self.pullBar?.frame.origin.y = presentedView.frame.minY - Style.pullBarHeight + pixelSize
self.shadingView?.alpha = isPresenting ? 1 : 0
}
let completion = { (completed: Bool) in
transitionContext.completeTransition(completed && !transitionContext.transitionWasCancelled)
}
let options: UIView.AnimationOptions = transitionContext.isInteractive ? .curveLinear : .curveEaseInOut
let transitionDurationValue = transitionDuration(using: transitionContext)
UIView.animate(withDuration: transitionDurationValue, delay: 0, options: options, animations: animations, completion: completion)
}
}
И не забудем реализовать соответствующие методы в BottomSheetTransitioningDelegate.
// MARK: - UIViewControllerTransitioningDelegate
public func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
presentationController
}
public func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
presentationController
}
Убедимся, что анимация появилась.
Остаётся добавить закругленные края, и наш Bottom Sheet готов!
Закругляем края
Через cornerRadius у presentedViewController в presentation controller. Нам нужно сделать это перед началом транзишена в presentationTransitionWillBegin ().
private func applyStyle() {
guard presentedViewController.isViewLoaded else { return }
presentedViewController.view.clipsToBounds = true
presentedViewController.view.layer.cornerRadius = cornerRadius
}
Следим за углами.
Закругленные! Теперь Bottom Sheet соответствует дизайну!
Что мы сделали в первой части?
Переопределили системный transitioning delegate.
Создали presentation controller.
Добавили тень для скрытия Bottom Sheet через dismiss handler.
Реализовали анимированный транзишен через transitioning delegate.
Поддержали базовый дизайн.
Часть 2. Интерактивное закрытие Bottom Sheet
Стартовый проект
Как и в первой части начинаем со стартового проекта. К проекту добавился pull bar, который подскажет пользователю, что Bottom Sheet можно скрыть не только по нажатию в пустое пространство, но ещё и по swipe-жесту. Так же в ResizeViewController появился scrollView во весь экран. Он нам пригодится для списочных экранов. Остальное из первой части.
Запустим приложение.
Теория. Особенности интерактивного закрытия
Используем UISwipeGestureRecognizer для распознания swipe-жеста. По нему будем начинать закрытие Bottom Sheet.
Но что, если у presented controller уже есть такой жест? Тогда это может приводить к конфликту жестов, потому что непонятно, какой обрабатывать первым.
Но так ли часто у presented controller может быть такой жест? На самом деле постоянно. В современном приложении 99% экранов списочные. Это означает, что в каждом есть UIScrollView или его наследники: UITableView или UICollectionView, в которых есть тот самый жест. Как же быть?
Давайте разберём два случая, когда UIScrollView нет и он есть.
Если нет, то всё просто — добавляем swipe-жест.
Если есть, то контент может помещаться:
Полностью. Тогда размер Bottom Sheet меньше экрана, и будем закрывать Bottom Sheet сразу по swipe-жесту.
Частично. Тогда swipe может означать так же и скроллинг. Будем считать, что пользователь хочет закрыть Bottom Sheet по swipe’у вниз и, когда контент закончился сверху (нулевой contentOffset).
В случае наличия UIScrollView подписываем на изменения contentOffset, и по ним понимаем, в какой момент можно начинать интерактивное закрытие.
По механике договорились, давайте её реализуем.
Если UIScrollView нет
То добавляем pan gesture к presentedView. В какой момент это делать? Жест инициирует интерактивное закрытие. А Bottom Sheet может быть закрыт, только если он полностью на экране. Поэтому разумно добавлять жест на окончания показа в presentationTransitionDidEnd (_:).
public override func presentationTransitionDidEnd(_ completed: Bool) {
if completed {
setupGesturesForPresentedView()
state = .presented
} else {
state = .dismissed
}
}
private func setupGesturesForPresentedView() {
setupPanGesture(for: presentedView)
}
И напишем функцию, которая добавляем pan gesture заданной view.
private func setupPanGesture(for view: UIView?) {
guard let view = view else { return }
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
view.addGestureRecognizer(panRecognizer)
}
@objc
private func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
switch panGesture.state {
case .began:
processPanGestureBegan(panGesture)
case .changed:
processPanGestureChanged(panGesture)
case .ended:
processPanGestureEnded(panGesture)
case .cancelled:
processPanGestureCancelled(panGesture)
default:
break
}
}
Разберём каждое состояние жеста.
began — пользователь только-только начал движение пальца и жест был определен, как pan gesture. Инициируем закрытие Bottom Sheet.
changed — пользователь непрерывно ведёт пальцем по экрану. Скрываем Bottom Sheet пропорционально расстоянию, которое прошёл палец по экрану.
ended — пользователь отпустил палец с экрана. Принимаем решение, закрывать Bottom Sheet или возвращать его в исходное положение.
cancelled — жест был отменён. Возвращаем Bottom Sheet в исходное состояние.
Дополнительно будем использовать UIPercentDrivenInteractiveTransition для передачи состояния транзишена transitioning delegate’у.
// BottomSheetPresentationController.swift
private var interactionController: UIPercentDrivenInteractiveTransition?
Начнём с состояния began. Это подходящий момент для инициализации интерактивного закрытия, потому что это состояние происходит всего один раз. Так же вызываем dismiss у presentingViewController для уведомления UIKit о намерении закрыть Bottom Sheet.
private func processPanGestureBegan(_ panGesture: UIPanGestureRecognizer) {
startInteractiveTransition()
}
private func startInteractiveTransition() {
interactionController = UIPercentDrivenInteractiveTransition()
presentingViewController.dismiss(animated: true) { [weak self] in
guard let self = self else { return }
if self.presentingViewController.presentedViewController !== self.presentedViewController {
self.dismissalHandler.performDismissal(animated: true)
}
}
}
Далее — состояние changed. Изменяем позицию presentedView пропорционально движению пальца по экрану. Рассчитываем расстояние от начальной точки, где жест начался, до текущей. Далее вычисляем прогресс транзишена относительно высоты content view controller, т.е. presentedView.
private func processPanGestureChanged(_ panGesture: UIPanGestureRecognizer) {
let translation = panGesture.translation(in: nil)
updateInteractionControllerProgress(verticalTranslation: translation.y)
}
private func updateInteractionControllerProgress(verticalTranslation: CGFloat) {
guard let presentedView = presentedView else { return }
let progress = verticalTranslation / presentedView.bounds.height
interactionController?.update(progress)
}
Когда пользователь отпустил палец, то жест переходит в состояние ended. Нужно определить намерение пользователя: скролл контента или желание закрыть Bottom Sheet. Если пользователь резко опустил палец вниз, пройдя минимальное расстояние, то скорее всего он хотел закрыть Bottom Sheet. Возможна и другая ситуация, если пользователь прошел большое расстояние по экрану и в последний момент с ускорением отпустил палец с экрана вверх. В такой ситуации Bottom Sheet должен вернуться в исходную позицию. Это наводит на мысли о расчёте некого импульса движения, который учитывает ускорение и направление.
Немного физики. Представим, что есть некое тело, которое двигается с постоянной скорость . Дальше на него подействовало замедление на расстоянии . Вопрос — где остановится тело?
Вывод формулы расстояния для тела с замедлением
Примем Bottom Sheet за тело из задачи выше, когда жест закончился. Через pan gesture узнаем текущую скорость. Пройденное расстояние у нас есть. Замедление подбираем и принимаем за константу . Зная формулу для расстояния , рассчитаем, где остановится Bottom Sheet под замедлением. Если точка остановки ближе к начальному положению, то отменяем транзишен, иначе завершаем его.
private func processPanGestureEnded(_ panGesture: UIPanGestureRecognizer) {
let velocity = panGesture.velocity(in: presentedView)
let translation = panGesture.translation(in: presentedView)
endInteractiveTransition(verticalVelocity: velocity.y, verticalTranslation: translation.y)
}
private func endInteractiveTransition(verticalVelocity: CGFloat, verticalTranslation: CGFloat) {
guard let presentedView = presentedView else { return }
let deceleration = 800.0 * (verticalVelocity > 0 ? -1.0 : 1.0)
let finalProgress = (verticalTranslation - 0.5 * verticalVelocity * verticalVelocity / CGFloat(deceleration))
/ presentedView.bounds.height
let isThresholdPassed = finalProgress < 0.5
endInteractiveTransition(isCancelled: isThresholdPassed)
}
private func endInteractiveTransition(isCancelled: Bool) {
if isCancelled {
interactionController?.cancel()
} else {
interactionController?.finish()
}
interactionController = nil
}
И если жест отменился cancelled, то возвращаемся в исходное положение.
private func processPanGestureCancelled(_ panGesture: UIPanGestureRecognizer) {
endInteractiveTransition(isCancelled: true)
}
Также не забудем вернуть interactiveTransitioning в transitioning delegate.
Возвращаем interactiveTransitioning в transitioning delegate
// BottomSheetPresentationController.swift
var interactiveTransitioning: UIViewControllerInteractiveTransitioning? {
interactionController
}
// BottomSheetTransitioningDelegate.swift
public func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
presentationController?.interactiveTransitioning
}
public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
presentationController?.interactiveTransitioning
}
Запустим приложение и посмотрим, что получилось.
Вжух-вжух, и мы научили Bottom Sheet закрываться по свайпу вниз! Однако, если контент больше экрана, то scrollView перехватывает жесты.
Разбираем списочные экраны
Несмотря на то, что до этого ResizeViewController и так был списочным, это не помешало нам добавить pan gesture. Всё потому, что у scrollView нет скролла, когда contentSize равен его размеру.
Поэтому рассмотрим случай, когда contentSize больше размера Bottom Sheet, и скролл есть. Подписываемся на contentOffset. Если contentOffset нулевой, и пользователь скроллит вниз, то инициируем закрытие. Когда пользователь отпускает палец с экрана, то решаем отменить или закончить транзишен, как и раньше. Если contentOffset изменяется и пользователь не касается экрана, то ничего не делаем. Это значит, что скролл происходит по инерции.
Для начала нам понадобится признак, который подскажет, что у view controller есть scrollView. Введём для этого протокол.
public protocol ScrollableBottomSheetPresentedController: AnyObject {
var scrollView: UIScrollView? { get }
}
Для отслеживания изменений contentOffset подписываемся на UIScrollViewDelegate. Но что, если кто-то уже подписался на delegate UIScrollView? Тогда мы затрём предыдущий delegate.
Поэтому будем использовать прокси на UIScrollViewDelegate. Идейно MulticastDelegate реализует UIScrollViewDelegate и проксирует методы delegate заинтересованным сторонам. При этом заботится, чтобы поле delegate не затиралось. В Swift нам приходится определить каждый метод delegate. В Objective-C можно добиться аналогичного результата без реализации всех методов delegate через runtime.
Подписываемся на delegate после окончания транзишена в presentationTransitionDidEnd (_:), как и с pan gesture.
private var trackedScrollView: UIScrollView?
private func setupScrollTrackingIfNeeded() {
trackScrollView(inside: presentedViewController)
}
private func trackScrollView(inside viewController: UIViewController) {
guard
let scrollableViewController = viewController as? ScrollableBottomSheetPresentedController,
let scrollView = scrollableViewController.scrollView
else {
return
}
trackedScrollView?.multicastingDelegate.removeDelegate(self)
scrollView.multicastingDelegate.addDelegate(self)
self.trackedScrollView = scrollView
}
private func removeScrollTrackingIfNeeded() {
trackedScrollView?.multicastingDelegate.removeDelegate(self)
trackedScrollView = nil
}
Дальше реализуем UIScrollViewDelegate.
Вспомогательные переменные и хелперы UIScrollView
private var isInteractiveTransitionCanBeHandled: Bool {
isDragging
}
private var isDragging = false
private var overlayTranslation: CGFloat = 0
private var scrollViewTranslation: CGFloat = 0
private var lastContentOffsetBeforeDragging: CGPoint = .zero
private var didStartDragging = false
И хелперы UIScrollView.
private extension UIScrollView {
var scrollsUp: Bool {
panGestureRecognizer.velocity(in: nil).y < 0
}
var scrollsDown: Bool {
!scrollsUp
}
var isContentOriginInBounds: Bool {
contentOffset.y <= -adjustedContentInset.top
}
}
Начнём с касания экрана scrollViewWillBeginDragging (:_). В этот момент пользователь только-только начал swipe-жест. Запоминаем это состояние через флаг isDragging.
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isDragging = true
}
Далее рассмотрим вспомогательную функцию shouldDragOverlay (following:), в которой определяем, нужно ли обновить прогресс транзишена.
private func shouldDragOverlay(following scrollView: UIScrollView) -> Bool {
guard scrollView.isTracking, isInteractiveTransitionCanBeHandled else {
return false
}
if let percentComplete = interactionController?.percentComplete {
if percentComplete.isAlmostEqual(to: 0) {
return scrollView.isContentOriginInBounds && scrollView.scrollsDown
}
return true
} else {
return scrollView.isContentOriginInBounds && scrollView.scrollsDown
}
}
Проверяем, что пользователь сейчас ведёт пальцем по экрану. Если так, то разбираемся, было ли инициировано закрытие.
Если закрытие инициировано и мы где-то посередине транзишена, то продолжаем дальше. Если мы только-только инициировали закрытие, то проверяем, что скролл направлен вниз и контент находится сверху через isContentOriginInBounds. Если мы в начале транзишена, то также проверяем, что скролл направлен вниз и контент сверху.
Дальше разберём, когда contentOffset изменяется и вызывается scrollViewDidScroll (:_).
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
let previousTranslation = scrollViewTranslation
scrollViewTranslation = scrollView.panGestureRecognizer.translation(in: scrollView).y
didStartDragging = shouldDragOverlay(following: scrollView)
if didStartDragging {
startInteractiveTransitionIfNeeded()
overlayTranslation += scrollViewTranslation - previousTranslation
// Update scrollView contentInset without invoking scrollViewDidScroll(_:)
scrollView.bounds.origin.y = -scrollView.adjustedContentInset.top
updateInteractionControllerProgress(verticalTranslation: overlayTranslation)
} else {
lastContentOffsetBeforeDragging = scrollView.panGestureRecognizer.translation(in: scrollView)
}
}
private func startInteractiveTransitionIfNeeded() {
guard interactionController == nil else { return }
startInteractiveTransition()
}
startInteractiveTransitionIfNeeded () — инициирует интерактивный транзишен, если мы это ещё не сделали.
В scrollViewDidScroll (_:) проверяем, можем ли мы продолжить (начать) интерактивный транзишен. Если можем, то инициируем транзишен через startInteractiveTransitionIfNeeded (). Если не можем, то запоминаем последний contentOffset перед активацией транзишена. Он пригодится дальше.
Помните, как на собеседованиях спрашивали, когда bounds.origin ненулевой? Вероятно, ответ был «когда contentOffset у scrollView ненулевой». Но было не понятно, как это можно использовать на практике, правда? Ниже хорошая возможность оправдать наличие этого знания!
Далее убедимся, что контент прибит кверху, и приравняем contentInset к contentOffset. Измененяем contentOffset через bounds.origin, чтобы не вызывать scrollViewDidScroll (_:). В конце обновляем прогресс транзишена.
Когда пользователь отрывает палец от экрана после скролла, то UIScrollViewDelegate вызывает scrollViewWillEndDragging (_: withVelocity: targetContentOffset:). В нём, как и в обработке состояния pan gesture ended, завершаем или отменяем транзишен.
public func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer
) {
if didStartDragging {
let velocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
let translation = scrollView.panGestureRecognizer.translation(in: scrollView)
endInteractiveTransition(
verticalVelocity: velocity.y,
verticalTranslation: translation.y - lastContentOffsetBeforeDragging.y
)
} else {
endInteractiveTransition(isCancelled: true)
}
overlayTranslation = 0
scrollViewTranslation = 0
lastContentOffsetBeforeDragging = .zero
didStartDragging = false
isDragging = false
}
Через didStartDragging проверяем было ли активно интерактивное закрытие Bottom Sheet перед окончанием скролла.
Если да, то, как и с pan gesture, используем скорость с замедлением, чтобы решить отменить или закончить переход.
Если нет, то отменяем транзишен. Возможна ситуация, что пользователь начал скрывать Bottom Sheet, а потом вернулся к скроллу контента. В этом случае у транзишена нулевой прогресс, и мы точно хотим его отменить.
Давайте посмотрим, что получилось!
Итак, мы научились работать со всеми размерами Bottom Sheet.
Что мы сделали во второй части?
Поддержали pan gesture и разобрали состояния: began, changed, ended, cancelled.
Реализовали множественную подписку на delegate через MulticastDelegate.
Отслеживаем UIScrollViewDelegate и обновляем состояние транзишена.
Часть 3. Поддержим UINavigationController
Стартовый проект
Начинаем со стартового проекта. В ResizeViewController добавилось две кнопки, которые видны, если есть navigation controller. Первая пушит ResizeViewController с текущей высотой контента. Вторая сворачивает навигационный стек к rootViewController. Остальное из второй части.
Добавим возможность показывать UINavigationController c привычными операции push и pop. Также не забываем про системный интерактивный pop, который хочется поддержать.
Теория. А из коробки заработает?
Можно ли напрямую использовать системный UINavigationController? К сожалению, нет.
Navigation controller не учитывает preferredContentSize в полной мере. Изначальный размер контента и его увеличение работает ожидаемо. Однако, на уменьшение navigation controller никак не реагирует. При нажатии на -100 размер не изменяется.
Поэтому нам точно понадобится наследник UINavigationController, который сможет отслеживать изменение навигационного стека и обновлять свой собственный preferredContentSize ориентируясь на topViewController.
В presentation controller при отслеживании scrollView нужно учесть, что presentedViewController может быть UINavigationController. И при изменении навигационного стека необходимо извлечь scrollView из текущего topViewController, если он есть.
Последнее. Navigation controller поставляется вместе с версии iOS SDK. Получается, что каждый раз мы работаем с новым компонентом со своими особенностями. И, как мы убедимся дальше, это особенности себя проявят и нам помешают. Как и что будем с этим делать — обсудим ближе к делу.
Адаптируемся под размер контента
Мы уже делали адаптацию под размер контента в первой части, но с поддержкой navigation controller фича сломалась. Системный navigation controller не учитывает изменения preferredContentSize в полной мере. Поэтому создадим наследника UINavigationController и воспользуемся свойством UIContentContainer.
В updatePreferredContentSize учитываем topViewController и additionalSafeAreaInsets.
public final class BottomSheetNavigationController: UINavigationController {
private func updatePreferredContentSize() {
preferredContentSize = CGSize(
width: view.bounds.width,
height: topViewController?.preferredContentSize.height ?? 0 + additionalSafeAreaInsets.verticalInsets
)
}
}
Аналогично presentation controller, реагируем на изменение content size через preferredContentSizeDidChange (forChildContentContainer:). Помним, что нужно самим позаботиться об анимации при изменении preferredContentSize. Поэтому добавим анимацию в BottomSheetNavigationController и уберём её из ResizeViewController.
// BottomSheetNavigationController.swift
private var isUpdatingNavigationStack = false
private var canAnimatePreferredContentSizeUpdates = false
// MARK: - Private methods
public override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) {
guard
let viewController = container as? UIViewController,
viewController === topViewController
else { return }
let updates = { [self] in
updatePreferredContentSize()
view.layoutIfNeeded()
}
if canAnimatePreferredContentSizeUpdates {
UIView.animate(withDuration: 0.25, animations: updates)
} else {
updates()
}
canAnimatePreferredContentSizeUpdates = true
}
В presentation controller учтём, что presentedViewController может быть UINavigationController. Тогда нужно подписываться на текущий topViewController и на изменения навигационного стека. Обновим setupScrollTrackingIfNeeded ().
private func setupScrollTrackingIfNeeded() {
if let navigationController = presentedViewController as? UINavigationController {
navigationController.multicastingDelegate.addDelegate(self)
if let topViewController = navigationController.topViewController {
trackScrollView(inside: topViewController)
}
} else {
trackScrollView(inside: presentedViewController)
}
}
Для отслеживания изменений навигационного стека подписываемся на delegate через известный нам паттерн MulticastDelegate. Присматриваем за scrollView при показе view controller.
extension BottomSheetPresentationController: UINavigationControllerDelegate {
public func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
trackScrollView(inside: viewController)
}
}
Проверяем результат.
Navigation controller стал реагировать на изменения размера контента, но при переходе назад размер не участвует в анимации.
Анимируем транзишен push и pop
Почему так происходит? Потому что системная реализация navigation controller не учитывает preferredContentSize при изменении навигационного стека. Поэтому нужно обновлять размер контента вместе с изменениями стека навигации.
Для этого введём вспомогательную функцию, через которую будут идти обновления стека вместе с обновлением preferredContentSize. Если возможно, то изменение размера контента делаем анимированно через transitionCoordinator. Важно сначала обновить стек и только потом обновлять размер контента. Иначе topViewController будет неактуальным.
// BottomSheetNavigationController.swift
private var isUpdatingNavigationStack = false
// MARK: - Private methods
private func updateNavigationStack(animated: Bool, applyChanges: () -> Void) {
isUpdatingNavigationStack = true
applyChanges()
if let transitionCoordinator = transitionCoordinator, animated, transitionCoordinator.isAnimated {
transitionCoordinator.animate(
alongsideTransition: { _ in
self.updatePreferredContentSize()
},
completion: { context in
self.isUpdatingNavigationStack = false
self.updatePreferredContentSize()
}
)
} else {
isUpdatingNavigationStack = false
updatePreferredContentSize()
}
}
Напоследок реализуем методы UINavigationController, которые изменяют навигационный стек, через updateNavigationStack (animated: applyChanges:).
Методы, манипулирующие стеком UINavigationController
// MARK: - UINavigationController
public override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
updateNavigationStack(animated: animated) {
super.setViewControllers(viewControllers, animated: animated)
}
}
public override func pushViewController(_ viewController: UIViewController, animated: Bool) {
updateNavigationStack(animated: animated) {
super.pushViewController(viewController, animated: animated)
}
}
public override func popViewController(animated: Bool) -> UIViewController? {
var viewController: UIViewController?
updateNavigationStack(animated: animated) {
viewController = super.popViewController(animated: animated)
}
return viewController
}
public override func popToRootViewController(animated: Bool) -> [UIViewController]? {
var viewControllers: [UIViewController]?
updateNavigationStack(animated: animated) {
viewControllers = super.popToRootViewController(animated: animated)
}
return viewControllers
}
Запустим и посмотрим, что получилось.
Стало лучше и размер контента учитывается, но с артефактами. Посмотрим на iOS 12.
Транзишен отличается в худшую сторону и после окончания размер контента остаётся с предыдущего view controller.
Получается, мы не можем рассчитывать на системный транзишен UINavigationController, и нам нужно реализовать его своими силами. Реализуем UINavigationControllerDelegate, в котором переопределим транзишен для push и pop операций.
Транзишен для push и pop операций
public final class BottomSheetNavigationAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {
// MARK: - Private
private let operation: UINavigationController.Operation
// MARK: - Init
public init(operation: UINavigationController.Operation) {
self.operation = operation
}
// MARK: - UIViewControllerAnimatedTransitioning
public func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
0.25
}
public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
guard
let sourceViewController = transitionContext.viewController(forKey: .from),
let destinationViewController = transitionContext.viewController(forKey: .to),
let destinationView = destinationViewController.view,
let sourceView = sourceViewController.view,
let containerViewWindow = containerView.window
else {
return
}
let isPushing = operation == .push
let containerBounds = containerView.bounds
let topView = isPushing ? destinationView : sourceView
let bottomView = isPushing ? sourceView : destinationView
let topViewFrame = { bounds, isTopViewVisible -> CGRect in
isTopViewVisible
? bounds
: bounds.offsetBy(dx: bounds.