Современные требования к дизайну мобильных приложений всё чаще подкидывают задачи по отображению контента со сложным поведением. Для его реализации необходимо понимание того, как работают кастомные презентация и транзишены. Без этих знаний не получилось бы решить задачу по созданию экрана оплаты в приложении «Кошелёк».

Это первая статья из цикла про bottom sheet.

  1. Bottom sheet: Custom transitioning

  2. Bottom sheet: Navigation

  3. Bottom sheet: Scrolling and interactions

Команда мобильной разработки в приложении «Кошелёк» реализует множество интересных решений. Одним из них стала кастомная навигация между контроллерами, которые отображаются как bottom sheet. Эта статья подробно описывает работу кастомного transitioning delegate для отображения любого контента на экране в виде bottom sheet. Такое отображение подразумевает, что контроллер на экране занимает по высоте только область необходимую и достаточную, чтобы уместить весь содержащийся в нём контент.

Custom transitioning

Cкачайте стартовый проект, в котором уже создано несколько базовых сущностей.

В нём вы найдёте стартовый RootViewController, несколько расширений для базовых классов и типов и пару контроллеров (TextViewController и ListViewController), которые мы научимся показывать в виде bottom sheet.

TextViewController содержит всего лишь один UILabel. UILabel, ограниченный в одном из измерений, будет стараться расшириться в другом, чтобы весь текст оказался в зоне видимости. Используем его как простейший пример autolayout за счёт intrinsicContentSize.

Старт презентации происходит в RootViewController в методе presentVCAsBottomSheet():

@objc
func presentVCAsBottomSheet() {
    let vc = TextViewController()
    present(vc, animated: true)
}

Если запустить проект, мы увидим три кнопки. При нажатии на первую отобразится TextViewController на весь экран, или, начиная с iOS 13, в виде form sheet с небольшим отступом сверху.

Transitioning delegate

У каждого UIViewController’а есть свойство transitioningDelegate. UIKit обращается к нему, когда контроллер нужно презентовать либо скрыть. Этот делегат должен предоставлять animation controller и presentation controller.

По умолчанию transitioning delegate отсутствует. Поэтому, чтобы заменить стандартное поведение, его нужно создать, реализовав протокол UIViewControllerTransitioningDelegate.

Найдите файл BSTransitioningDelegate.swift и добавьте в него следующий код:

import UIKit
final class BSTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
	// 1
    func presentationController(
        forPresented presented: UIViewController,
        presenting: UIViewController?,
        source: UIViewController
    ) -> UIPresentationController? {
		nil
    }

	// 2
	func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
	    nil
    }

	// 3
	func animationController(
        forDismissed dismissed: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
	    nil
    }
}

UIViewControllerTransitioningDelegate предоставляет три метода, которые нужно реализовать.

  1. Здесь мы создадим presentation controller. Он является контейнером для презентуемого контроллера и отвечает за его положение и размеры.

  2. В этом методе мы создадим анимацию, с которой презентуемый контроллер будет появляться на экране.

  3. А в этом методе по аналогии с предыдущим для анимации, с которой будет исчезать.

Presentation controller

Презентация любого контроллера происходит внутри контейнера UIPresentationController. Стандартный для наших целей не подходит, поэтому мы реализуем своего наследника этого класса.

Для этого в файл BSPresentationController.swift добавляем следующие строки:

import UIKit
final class BSPresentationController: UIPresentationController {
	// 1
    override var shouldPresentInFullscreen: Bool {
        false
    }

	// 2
    override func presentationTransitionWillBegin() {
        super.presentationTransitionWillBegin()

        guard let containerView = containerView,
              let presentedView = presentedView
        else { return }

		// 3
        presentedView.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(presentedView)

		// 4
        NSLayoutConstraint.activate(
            [
                presentedView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
                presentedView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
                presentedView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
                presentedView.heightAnchor.constraint(
                    lessThanOrEqualTo: containerView.heightAnchor,
                    constant: -containerView.safeAreaInsets.top
                )
            ]
        )
    }

	// 5
	override func presentationTransitionDidEnd(_ completed: Bool) {
        if !completed {
            presentedView?.removeFromSuperview()
        }
    }
}
  1. По умолчанию любая презентация показывается на весь экран. Переопределим это свойство, чтобы дать возможность контроллеру отобразиться только на часть.

  2. Этот метод UIKit вызовет перед стартом презентации. Это идеальное место, чтобы расположить презентуемый контроллер в containerView presentation controller’а.

  3. Отключим автогенерацию констрейнтов на основе autoresizingMask.

  4. Закрепляем view презентуемого контроллера (presentedView) к низу и краям контейнера и ограничиваем высоту по вертикали до нижней точки верхней safeArea. Привяжем view презентуемого контроллера (presentedView) к нижней, левой и правой точкам presentation controller. Ограничим высоту по вертикали до safeAreaInsets.top на случай, если контент выше чем экран.

  5. Не забываем удалить presentedView из контейнера, если транзишен был прерван.

Теперь, когда presentation controller описан, давайте его создадим и вернём в transitioning delegate. Добавим в метод presentationController(forPresented presented:presenting:source:) следующий код:

func presentationController(
    forPresented presented: UIViewController,
    presenting: UIViewController?,
    source: UIViewController
    ) -> UIPresentationController? {
        BSPresentationController(
            presentedViewController: presented,
            presenting: presenting ?? source
    )
}

Тут presented — контроллер, который хотим отобразить, presenting — контроллер, поверх которого будет отображён презентуемый контроллер, а source — контроллер, который непосредственно вызвал метод present(_:animate:completion:). Аргумент presenting может быть nil и зависит это только от UIKit, потому, в случае его отсутствия передаём source.

Чтобы посмотреть результат проделанной работы, осталось добавить немного кода в RootViewController. В стартовом проекте уже есть готовая кнопка и метод presentVCAsBottomSheet(), который она вызовет:

// 1
private let customTransitioningDelegate = BSTransitioningDelegate()
@objc
func presentVCAsBottomSheet() {
    let vc = TextViewController()
    // 2
    vc.transitioningDelegate = customTransitioningDelegate
    // 3
    vc.modalPresentationStyle = .custom
    present(vc, animated: true)
}
  1. Свойство контроллера transitioningDelegate weak, потому нужно дополнительно захватить объект по сильной ссылке.

  2. Присваиваем свойству transitioningDelegate презентуемого контроллера новое значение.

  3. Необходимо присвоить свойству modalPresentationStyle презентуемого контроллера значение UIModalPresentationStyle.custom. Так за транзишены станет отвечать transitioning Delegate.

Теперь проект можно запустить и посмотреть на первые результаты.

Анимации

За анимацию контроллеров отвечают объекты, реализующие протокол UIViewControllerAnimatedTransitioning. Из них UIKit получает информацию для анимаций в объекте с типом UIViewControllerContextTransitioning.

В CoverVerticalPresentAnimatedTransitioning.swift добавим следующий код, который описывает анимацию в стиле cover vertical:

import UIKit

final class CoverVerticalPresentAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {
	private let duration: TimeInterval = 0.35
	
	// 1
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        duration
    }

	// 2
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let animator = makeAnimator(using: transitionContext)
        animator?.startAnimation()
    }

    private func makeAnimator(
        using transitionContext: UIViewControllerContextTransitioning
    ) -> UIViewImplicitlyAnimating? {
		// 3
        guard let toView = transitionContext.view(forKey: .to)
        else {
            return nil
        }

        // 4
        let containerView = transitionContext.containerView
        containerView.layoutIfNeeded()

    	// 5
        toView.transform = CGAffineTransform.identity.translatedBy(x: 0, y: toView.frame.height)

        let animator = UIViewPropertyAnimator(
            duration: duration,
            controlPoint1: CGPoint(x: 0.2, y: 1),
            controlPoint2: CGPoint(x: 0.42, y: 1)
        ) {
			// 6
            toView.transform = .identity
        }

        animator.addCompletion { _ in
			// 7
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }

        return animator
    }
}
  1. Перед стартом анимации UIKit запросит время анимации транзакции открытия экрана. Стандартное время транзишена в iOS — 0.35.

  2. Также перед стартом транзишена UIKit вызовет этот метод с контекстом, в котором хранится необходимая информации об участниках.

  3. Для реализованной в данном примере анимации достаточно будет только view презентуемого контроллера.

  4. Анимация построена на смещении view по y из-за нижней границы экрана, поэтому для начала принудительно обновим layout view контроллера. Так как размеры и положение у нас заданы в BSPresentationController с помощью констрейнтов, то layoutIfNeed спровоцирует UIKit на перерасчёт.

  5. Смещаем view вниз за экран на его же высоту до старта анимации с помощью трансформации.

  6. В блоке аниматора вернём view к исходному положению.

  7. После завершения анимации необходимо вызвать у контекста метод completeTransition(_ didComplete:)для индикации, что все анимации завершены со значением true, если анимация не была прервана.

В итоге получим отличную – родной идентичную – анимацию открытия экрана.

Анимация для открытия готова, теперь в CoverVerticalDismissAnimatedTransitioning.swift добавим практически идентичный код, только смещение происходит уже за границу экрана, а не из-за неё:

import UIKit

final class CoverVerticalDismissAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {

    private let duration: TimeInterval = 0.35
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        duration
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let animator = makeAnimator(using: transitionContext)
        animator?.startAnimation()
    }

    private func makeAnimator(
	    using transitionContext: UIViewControllerContextTransitioning
    ) -> UIViewImplicitlyAnimating? {
        guard let fromView = transitionContext.view(forKey: .from)
        else {
            return nil
        }

        let animator = UIViewPropertyAnimator(
            duration: duration,
            controlPoint1: CGPoint(x: 0.2, y: 1),
            controlPoint2: CGPoint(x: 0.42, y: 1)
        ) {
            fromView.transform = CGAffineTransform.identity.translatedBy(x: 0, y: fromView.frame.height)
        }

        animator.addCompletion { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }

        return animator
    }
}

Анимации написаны, теперь пора заменить nil в методах transition delegate на следующий код для появления:

func animationController(
    forPresented presented: UIViewController,
    presenting: UIViewController,
    source: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
    CoverVerticalPresentAnimatedTransitioning()
}

и для скрытия:

func animationController(
    forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
    CoverVerticalDismissAnimatedTransitioning()
}

И снова можно запустить результат трудов и убедиться, что всё работает верно.

Таким образом, мы уже можем показать любой контроллер со статичным контентом в виде bottom sheet, но пока не можем его скрыть.

Затемнение области без контента

Presentation controller, созданный ранее, может не только выступать контейнером для презентуемого контента: в него также можно добавить дополнительную логику.

Положим под контент дополнительный слой чёрного цвета с прозрачностью и добавим в него обработку нажатия. Таким образом мы воспроизведём уже стандартное для iOS пользователей поведение закрытия контента, занимающего не всё пространство экрана.

Добавим в BSPresentationController ещё кода:

// 1
private lazy var dimmView: UIView = {
    let view = UIView()
    view.backgroundColor = UIColor.black.withAlphaComponent(0.75)
    view.addGestureRecognizer(tapRecognizer)
    return view
}()
// 2
private lazy var tapRecognizer: UITapGestureRecognizer = {
    let recognizer = UITapGestureRecognizer(
        target: self,
        action: #selector(handleTap)
    )
    return recognizer
}()
// 3
@objc
private func handleTap(_ sender: UITapGestureRecognizer) {
    presentingViewController.dismiss(animated: true)
}
  1. Создадим dimmView и покрасим его в черный с прозрачностью 0.75.

  2. Создадим tapRecognizer — обработчик жестов, который будет реагировать на нажатие пользователя и исполнять метод handleTap(_ sender:).

  3. Добавим метод, единственной задачей которого будет старт анимированного закрытия экрана.

И добавим ещё немного логики в метод presentationTransitionWillBegin() в этом же классе:

override func presentationTransitionWillBegin() {
    super.presentationTransitionWillBegin()
    ...
    // 1
    dimmView.translatesAutoresizingMaskIntoConstraints = false
    containerView.insertSubview(dimmView, at: 0)
	// 2
    NSLayoutConstraint.activate(
        [
			...
            dimmView.topAnchor.constraint(equalTo: containerView.topAnchor),
            dimmView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
            dimmView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
            dimmView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
        ]
    )
}

// 3
override func presentationTransitionDidEnd(_ completed: Bool) {
    if !completed {
    dimmView.removeFromSuperview()
    ...
    }
}
  1. Добавляем затемнение под контент.

  2. Закрепляем констрейнтами к краям контейнера.

  3. В случае, если транзишен был прерван, убираем dimmView из контейнера.

Теперь, когда фон затемняется, можно заметить, что делает он это без анимации. Значит, нужно её добавить.

У нас есть возможность добавлять дополнительные анимации, которые будут синхронизированы с появлением или скрытием контроллера. Для этого у презентуемого контроллера есть transitionCoordinator.

// 1
override func presentationTransitionWillBegin() {
    ...
    dimmView.alpha = 0
    performAlongsideTransitionIfPossible {
        self.dimmView.alpha = 1
    }
    ...
}

// 2
override func dismissalTransitionWillBegin() {
    super.dismissalTransitionWillBegin()
    performAlongsideTransitionIfPossible {
        self.dimmView.alpha = 0
    }
}

private func performAlongsideTransitionIfPossible(_ animation: @escaping () -> Void ) {
    guard let coordinator = presentedViewController.transitionCoordinator else {
        animation()
        return
    }
  
	coordinator.animate { _ in
			animation()
	}
}

У presentedViewController есть свойство — transitionCoordinator. Оно удобно тем, что в него можно с помощью метода animate(alongsideTransition animation:) передать замыкание с изменением анимированных свойств. Изменения этих свойств будут анимироваться вместе с транзишеном открытия или закрытия контроллера.

  1. На транзишен открытия добавим переход альфы dimmView от 0 до 1.

  2. На закрытие — переход от текущего состояния до 0.

Вот теперь мы наконец можем закрыть контроллер по нажатию вне контента. При этом параллельно с открытием и закрытием будет появляться и исчезать затемнение. Запустите проект и убедитесь в этом.

Финальный проект.

Итого

Мы разобрались в нюансах работы кастомного отображения контроллеров. Выяснили, как отобразить любой контент как bottom sheet. Добавили затемнение фона и возможность закрыть контроллер при нажатии. Написали анимации для транзишенов открытия и закрытия контроллеров.

В этом руководстве я постарался балансировать на грани между подробным разбором материала и совсем тёмными дебрями. Надеюсь, что туториал поможет в решении стоящей перед вами задачи, а для кого-то станет отправной точкой для более глубокого изучения UIKit и его особенностей. А если что-то в статье было непонятно, не стесняйтесь задавать вопросы в комментариях.

Тех, кому просто показать bottom sheet оказалось мало, приглашаю к прочтению следующей статьи из цикла. Там вас ждет увлекательное погружение в навигацию внутри bottom sheet’а между контроллерами с разной высотой. Эта задача оказалась не такой простой, как кажется на первый взгляд, и на самом деле вся статья задумывалась именно для того, чтобы подготовить вас к созданию навигации.