Современные требования к дизайну мобильных приложений всё чаще подкидывают задачи по отображению контента со сложным поведением. Для его реализации необходимо понимание того, как работают кастомные презентация и транзишены. Без этих знаний не получилось бы решить задачу по созданию экрана оплаты в приложении «Кошелёк». Это первая статья из цикла про bottom sheet.  Bottom sheet: Custom transitioning Bottom sheet: Navigation Bottom sheet: Scrolling and interactions Команда мобильной разработки в приложении «Кошелёк» реализует множество интересных решений. Одним из них стала кастомная навигация между контроллерами, которые отображаются как bottom sheet. Эта статья подробно описывает работу кастомного transitioning delegate для отображения любого контента на экране в виде bottom sheet. Такое отображение подразумевает, что контроллер на экране занимает по высоте только область необходимую и достаточную, чтобы уместить весь содержащийся в нём контент. Cкачайте стартовый проект, в котором уже создано несколько базовых сущностей. В нём вы найдёте стартовый RootViewController, несколько расширений для базовых классов и типов и пару контроллеров (TextViewController и ListViewController), которые мы научимся показывать в виде bottom sheet. TextViewController содержит всего лишь один UILabel. UILabel, ограниченный в одном из измерений, будет стараться расшириться в другом, чтобы весь текст оказался в зоне видимости. Используем его как простейший пример autolayout за счёт intrinsicContentSize. Старт презентации происходит в RootViewController в методе presentVCAsBottomSheet(): Если запустить проект, мы увидим три кнопки. При нажатии на первую отобразится TextViewController на весь экран, или, начиная с iOS 13, в виде form sheet с небольшим отступом сверху. У каждого UIViewController’а есть свойство transitioningDelegate. UIKit обращается к нему, когда контроллер нужно презентовать либо скрыть. Этот делегат должен предоставлять animation controller и presentation controller. По умолчанию transitioning delegate отсутствует. Поэтому, чтобы заменить стандартное поведение, его нужно создать, реализовав протокол UIViewControllerTransitioningDelegate. Найдите файл BSTransitioningDelegate.swift и добавьте в него следующий код: UIViewControllerTransitioningDelegate предоставляет три метода, которые нужно реализовать. Здесь мы создадим presentation controller. Он является контейнером для презентуемого контроллера и отвечает за его положение и размеры. В этом методе мы создадим анимацию, с которой презентуемый контроллер будет появляться на экране. А в этом методе по аналогии с предыдущим для анимации, с которой будет исчезать. Презентация любого контроллера происходит внутри контейнера UIPresentationController. Стандартный для наших целей не подходит, поэтому мы реализуем своего наследника этого класса. Для этого в файл BSPresentationController.swift добавляем следующие строки: По умолчанию любая презентация показывается на весь экран. Переопределим это свойство, чтобы дать возможность контроллеру отобразиться только на часть. Этот метод UIKit вызовет перед стартом презентации. Это идеальное место, чтобы расположить презентуемый контроллер в containerView presentation controller’а. Отключим автогенерацию констрейнтов на основе autoresizingMask. Закрепляем view презентуемого контроллера (presentedView) к низу и краям контейнера и ограничиваем высоту по вертикали до нижней точки верхней safeArea. Привяжем view презентуемого контроллера (presentedView) к нижней, левой и правой точкам presentation controller. Ограничим высоту по вертикали до safeAreaInsets.top на случай, если контент выше чем экран. Не забываем удалить presentedView из контейнера, если транзишен был прерван. Теперь, когда presentation controller описан, давайте его создадим и вернём в transitioning delegate. Добавим в метод presentationController(forPresented presented:presenting:source:) следующий код: Тут presented — контроллер, который хотим отобразить, presenting — контроллер, поверх которого будет отображён презентуемый контроллер, а source — контроллер, который непосредственно вызвал метод present(_:animate:completion:). Аргумент presenting может быть nil и зависит это только от UIKit, потому, в случае его отсутствия передаём source. Чтобы посмотреть результат проделанной работы, осталось добавить немного кода в RootViewController. В стартовом проекте уже есть готовая кнопка и метод presentVCAsBottomSheet(), который она вызовет: Свойство контроллера transitioningDelegate weak, потому нужно дополнительно захватить объект по сильной ссылке. Присваиваем свойству transitioningDelegate презентуемого контроллера новое значение. Необходимо присвоить свойству modalPresentationStyle презентуемого контроллера значение UIModalPresentationStyle.custom. Так за транзишены станет отвечать transitioning Delegate. Теперь проект можно запустить и посмотреть на первые результаты. За анимацию контроллеров отвечают объекты, реализующие протокол UIViewControllerAnimatedTransitioning. Из них UIKit получает информацию для анимаций в объекте с типом UIViewControllerContextTransitioning. В CoverVerticalPresentAnimatedTransitioning.swift добавим следующий код, который описывает анимацию в стиле cover vertical: Перед стартом анимации UIKit запросит время анимации транзакции открытия экрана. Стандартное время транзишена в iOS — 0.35. Также перед стартом транзишена UIKit вызовет этот метод с контекстом, в котором хранится необходимая информации об участниках. Для реализованной в данном примере анимации достаточно будет только view презентуемого контроллера. Анимация построена на смещении view по y из-за нижней границы экрана, поэтому для начала принудительно обновим layout view контроллера. Так как размеры и положение у нас заданы в BSPresentationController с помощью констрейнтов, то layoutIfNeed спровоцирует UIKit на перерасчёт. Смещаем view вниз за экран на его же высоту до старта анимации с помощью трансформации. В блоке аниматора вернём view к исходному положению. После завершения анимации необходимо вызвать у контекста метод completeTransition(_ didComplete:)для индикации, что все анимации завершены со значением true, если анимация не была прервана. В итоге получим отличную – родной идентичную – анимацию открытия экрана. Анимация для открытия готова, теперь в CoverVerticalDismissAnimatedTransitioning.swift добавим практически идентичный код, только смещение происходит уже за границу экрана, а не из-за неё: Анимации написаны, теперь пора заменить nil в методах transition delegate на следующий код для появления: и для скрытия: И снова можно запустить результат трудов и убедиться, что всё работает верно. Таким образом, мы уже можем показать любой контроллер со статичным контентом в виде bottom sheet, но пока не можем его скрыть. Presentation controller, созданный ранее, может не только выступать контейнером для презентуемого контента: в него также можно добавить дополнительную логику. Положим под контент дополнительный слой чёрного цвета с прозрачностью и добавим в него обработку нажатия. Таким образом мы воспроизведём уже стандартное для iOS пользователей поведение закрытия контента, занимающего не всё пространство экрана. Добавим в BSPresentationController ещё кода: Создадим dimmView и покрасим его в черный с прозрачностью 0.75. Создадим tapRecognizer — обработчик жестов, который будет реагировать на нажатие пользователя и исполнять метод handleTap(_ sender:). Добавим метод, единственной задачей которого будет старт анимированного закрытия экрана. И добавим ещё немного логики в метод presentationTransitionWillBegin() в этом же классе: Добавляем затемнение под контент. Закрепляем констрейнтами к краям контейнера. В случае, если транзишен был прерван, убираем dimmView из контейнера. Теперь, когда фон затемняется, можно заметить, что делает он это без анимации. Значит, нужно её добавить. У нас есть возможность добавлять дополнительные анимации, которые будут синхронизированы с появлением или скрытием контроллера. Для этого у презентуемого контроллера есть transitionCoordinator. У presentedViewController есть свойство — transitionCoordinator. Оно удобно тем, что в него можно с помощью метода animate(alongsideTransition animation:) передать замыкание с изменением анимированных свойств. Изменения этих свойств будут анимироваться вместе с транзишеном открытия или закрытия контроллера. На транзишен открытия добавим переход альфы dimmView от 0 до 1. На закрытие — переход от текущего состояния до 0. Вот теперь мы наконец можем закрыть контроллер по нажатию вне контента. При этом параллельно с открытием и закрытием будет появляться и исчезать затемнение. Запустите проект и убедитесь в этом. Финальный проект. Мы разобрались в нюансах работы кастомного отображения контроллеров. Выяснили, как отобразить любой контент как bottom sheet. Добавили затемнение фона и возможность закрыть контроллер при нажатии. Написали анимации для транзишенов открытия и закрытия контроллеров. В этом руководстве я постарался балансировать на грани между подробным разбором материала и совсем тёмными дебрями. Надеюсь, что туториал поможет в решении стоящей перед вами задачи, а для кого-то станет отправной точкой для более глубокого изучения UIKit и его особенностей. А если что-то в статье было непонятно, не стесняйтесь задавать вопросы в комментариях. Тех, кому просто показать bottom sheet оказалось мало, приглашаю к прочтению следующей статьи из цикла. Там вас ждет увлекательное погружение в навигацию внутри bottom sheet’а между контроллерами с разной высотой. Эта задача оказалась не такой простой, как кажется на первый взгляд, и на самом деле вся статья задумывалась именно для того, чтобы подготовить вас к созданию навигации.  iOS разработчик в приложении «Кошелёк»