Bottom sheet: Custom transitioning
Современные требования к дизайну мобильных приложений всё чаще подкидывают задачи по отображению контента со сложным поведением. Для его реализации необходимо понимание того, как работают кастомные презентация и транзишены. Без этих знаний не получилось бы решить задачу по созданию экрана оплаты в приложении «Кошелёк».
Это первая статья из цикла про bottom sheet.
Команда мобильной разработки в приложении «Кошелёк» реализует множество интересных решений. Одним из них стала кастомная навигация между контроллерами, которые отображаются как 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
предоставляет три метода, которые нужно реализовать.
Здесь мы создадим presentation controller. Он является контейнером для презентуемого контроллера и отвечает за его положение и размеры.
В этом методе мы создадим анимацию, с которой презентуемый контроллер будет появляться на экране.
А в этом методе по аналогии с предыдущим для анимации, с которой будет исчезать.
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()
}
}
}
По умолчанию любая презентация показывается на весь экран. Переопределим это свойство, чтобы дать возможность контроллеру отобразиться только на часть.
Этот метод UIKit вызовет перед стартом презентации. Это идеальное место, чтобы расположить презентуемый контроллер в
containerView
presentation controller’а.Отключим автогенерацию констрейнтов на основе autoresizingMask.
Закрепляем view презентуемого контроллера (
presentedView
) к низу и краям контейнера и ограничиваем высоту по вертикали до нижней точки верхней safeArea. Привяжем view презентуемого контроллера (presentedView
) к нижней, левой и правой точкам presentation controller. Ограничим высоту по вертикали доsafeAreaInsets.top
на случай, если контент выше чем экран.Не забываем удалить
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)
}
Свойство контроллера
transitioningDelegate
weak, потому нужно дополнительно захватить объект по сильной ссылке.Присваиваем свойству
transitioningDelegate
презентуемого контроллера новое значение.Необходимо присвоить свойству
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
}
}
Перед стартом анимации UIKit запросит время анимации транзакции открытия экрана. Стандартное время транзишена в iOS — 0.35.
Также перед стартом транзишена UIKit вызовет этот метод с контекстом, в котором хранится необходимая информации об участниках.
Для реализованной в данном примере анимации достаточно будет только view презентуемого контроллера.
Анимация построена на смещении view по y из-за нижней границы экрана, поэтому для начала принудительно обновим layout view контроллера. Так как размеры и положение у нас заданы в BSPresentationController с помощью констрейнтов, то layoutIfNeed спровоцирует UIKit на перерасчёт.
Смещаем view вниз за экран на его же высоту до старта анимации с помощью трансформации.
В блоке аниматора вернём view к исходному положению.
После завершения анимации необходимо вызвать у контекста метод
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)
}
Создадим
dimmView
и покрасим его в черный с прозрачностью 0.75.Создадим
tapRecognizer
— обработчик жестов, который будет реагировать на нажатие пользователя и исполнять методhandleTap(_ sender:)
.Добавим метод, единственной задачей которого будет старт анимированного закрытия экрана.
И добавим ещё немного логики в метод 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()
...
}
}
Добавляем затемнение под контент.
Закрепляем констрейнтами к краям контейнера.
В случае, если транзишен был прерван, убираем
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:)
передать замыкание с изменением анимированных свойств. Изменения этих свойств будут анимироваться вместе с транзишеном открытия или закрытия контроллера.
На транзишен открытия добавим переход альфы dimmView от 0 до 1.
На закрытие — переход от текущего состояния до 0.
Вот теперь мы наконец можем закрыть контроллер по нажатию вне контента. При этом параллельно с открытием и закрытием будет появляться и исчезать затемнение. Запустите проект и убедитесь в этом.
Итого
Мы разобрались в нюансах работы кастомного отображения контроллеров. Выяснили, как отобразить любой контент как bottom sheet. Добавили затемнение фона и возможность закрыть контроллер при нажатии. Написали анимации для транзишенов открытия и закрытия контроллеров.
В этом руководстве я постарался балансировать на грани между подробным разбором материала и совсем тёмными дебрями. Надеюсь, что туториал поможет в решении стоящей перед вами задачи, а для кого-то станет отправной точкой для более глубокого изучения UIKit и его особенностей. А если что-то в статье было непонятно, не стесняйтесь задавать вопросы в комментариях.
Тех, кому просто показать bottom sheet оказалось мало, приглашаю к прочтению следующей статьи из цикла. Там вас ждет увлекательное погружение в навигацию внутри bottom sheet’а между контроллерами с разной высотой. Эта задача оказалась не такой простой, как кажется на первый взгляд, и на самом деле вся статья задумывалась именно для того, чтобы подготовить вас к созданию навигации.