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’ы».

image-loader.svg

Если заинтересовал, то начнём! Создадим простой Bottom Sheet и шаг за шагом его прокачаем.

  1. Научимся подстраиваться под размер контента и закрывать Bottom Sheet.

  2. Добавим интерактивное закрытие, учитывая контент, который скроллится.

  3. Поддержим 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 раз.

Запустим приложение.

image-loader.svg

Теория. Как показывать 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()

Давайте запустим приложение.

image-loader.svg

Как будто стало только хуже. 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
}

Посмотрим, что получилось.

image-loader.svg

Изначальный размер учитывается, но нет реакции на его изменения.

Реагируем на изменение контента

Рассмотрим 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. Запустим приложение.

image-loader.svg

Размер изменяется неравномерно без анимации. Почему? Потому что мы никак не указываем эту анимацию. Добавим анимацию на изменения preferredContentSize в ResizeViewController.

UIView.animate(
    withDuration: 0.25,
    animations: { [self] in
        preferredContentSize = CGSize(
            width: UIScreen.main.bounds.width,
            height: newValue
        )
    }
)

Проверяем.

image-loader.svg

Работает! Но мы никак не можем уйти с 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)
    }
}

Посмотрим, что получилось.

image-loader.svg

Отлично, теперь у нас добавляется тень, и по нажатию 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
}

Убедимся, что анимация появилась.

image-loader.svg

Остаётся добавить закругленные края, и наш Bottom Sheet готов!

Закругляем края

Через cornerRadius у presentedViewController в presentation controller. Нам нужно сделать это перед началом транзишена в presentationTransitionWillBegin ().

private func applyStyle() {
    guard presentedViewController.isViewLoaded else { return }

    presentedViewController.view.clipsToBounds = true
    presentedViewController.view.layer.cornerRadius = cornerRadius
}

Следим за углами.

image-loader.svg

Закругленные! Теперь Bottom Sheet соответствует дизайну!

Что мы сделали в первой части?

  1. Переопределили системный transitioning delegate.

  2. Создали presentation controller.

  3. Добавили тень для скрытия Bottom Sheet через dismiss handler.

  4. Реализовали анимированный транзишен через transitioning delegate.

  5. Поддержали базовый дизайн.

Часть 2. Интерактивное закрытие Bottom Sheet

Стартовый проект

Как и в первой части начинаем со стартового проекта. К проекту добавился pull bar, который подскажет пользователю, что Bottom Sheet можно скрыть не только по нажатию в пустое пространство, но ещё и по swipe-жесту. Так же в ResizeViewController появился scrollView во весь экран. Он нам пригодится для списочных экранов. Остальное из первой части.

Запустим приложение.

image-loader.svg

Теория. Особенности интерактивного закрытия

Используем UISwipeGestureRecognizer для распознания swipe-жеста. По нему будем начинать закрытие Bottom Sheet.

Но что, если у presented controller уже есть такой жест? Тогда это может приводить к конфликту жестов, потому что непонятно, какой обрабатывать первым.

Но так ли часто у presented controller может быть такой жест? На самом деле постоянно. В современном приложении 99% экранов списочные. Это означает, что в каждом есть UIScrollView или его наследники: UITableView или UICollectionView, в которых есть тот самый жест. Как же быть?

Давайте разберём два случая, когда UIScrollView нет и он есть.

  1. Если нет, то всё просто — добавляем swipe-жест.

  2. Если есть, то контент может помещаться:

    1. Полностью. Тогда размер Bottom Sheet меньше экрана, и будем закрывать Bottom Sheet сразу по swipe-жесту.

    2. Частично. Тогда 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 должен вернуться в исходную позицию. Это наводит на мысли о расчёте некого импульса движения, который учитывает ускорение и направление.

Немного физики. Представим, что есть некое тело, которое двигается с постоянной скорость v_0. Дальше на него подействовало замедление a на расстоянии x_0. Вопрос — где остановится тело?

Вывод формулы расстояния для тела с замедлением

Примем Bottom Sheet за тело из задачи выше, когда жест закончился. Через pan gesture узнаем текущую скорость. Пройденное расстояние у нас есть. Замедление подбираем и принимаем за константу 800. Зная формулу для расстояния x_0 - 0.5 * v_0^2/ a, рассчитаем, где остановится 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
}

Запустим приложение и посмотрим, что получилось.

image-loader.svg

Вжух-вжух, и мы научили 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, а потом вернулся к скроллу контента. В этом случае у транзишена нулевой прогресс, и мы точно хотим его отменить.

Давайте посмотрим, что получилось!

image-loader.svg

Итак, мы научились работать со всеми размерами Bottom Sheet.

Что мы сделали во второй части?

  1. Поддержали pan gesture и разобрали состояния: began, changed, ended, cancelled.

  2. Реализовали множественную подписку на delegate через MulticastDelegate.

  3. Отслеживаем UIScrollViewDelegate и обновляем состояние транзишена.

Часть 3. Поддержим UINavigationController

Стартовый проект

Начинаем со стартового проекта. В ResizeViewController добавилось две кнопки, которые видны, если есть navigation controller. Первая пушит ResizeViewController с текущей высотой контента. Вторая сворачивает навигационный стек к rootViewController. Остальное из второй части.

Добавим возможность показывать UINavigationController c привычными операции push и pop. Также не забываем про системный интерактивный pop, который хочется поддержать.

Теория. А из коробки заработает?

Можно ли напрямую использовать системный UINavigationController? К сожалению, нет.

Navigation controller не учитывает preferredContentSize в полной мере. Изначальный размер контента и его увеличение работает ожидаемо. Однако, на уменьшение navigation controller никак не реагирует. При нажатии на -100 размер не изменяется.

image-loader.svg

Поэтому нам точно понадобится наследник 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)
    }
}

Проверяем результат.

image-loader.svg

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
}

Запустим и посмотрим, что получилось.

image-loader.svg

Стало лучше и размер контента учитывается, но с артефактами. Посмотрим на iOS 12.

image-loader.svg

Транзишен отличается в худшую сторону и после окончания размер контента остаётся с предыдущего 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.
    
            

© Habrahabr.ru