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. Сперва убедимся, что мы понимаем под "Bottom Sheet" одно и то же. Bottom Sheet - это компонент, который располагается снизу и адаптируется под размер контента. Примеры использования есть в системных приложениях: Apple Maps (поиск), Stocks (новости), Voice Memos (запись голоса) и т.д. Вот ссылка на стартовый проект на Github. В проекте есть два таргета: BottomSheetDemo и BottomSheet — приложение и библиотека с Bottom Sheet. RootViewController — это первый экран в приложении. В нём есть всего одна кнопка Show Bottom Sheet. По нажатию покажется ResizeViewController. В инициализаторе ResizeViewController принимает высоту контента. Также есть четыре кнопки, которые изменяют высоту контента: на +100 и -100, в 2 и 0.5 раз. Запустим приложение. Нам нужна сущность, которая будет управлять показом. Она добавит Bottom Sheet в UI-иерархию, расположит его на экране, учтёт размер контента, будет реагировать на его изменения, позаботится об анимации, и можно будет сделать интерактивное закрытие. Это похоже на зону ответственности UIPresentationController. С момента появления view controller'а и до момента скрытия, UIKit использует presentation controller для управления процессом показа. Для его использования надо переопределить modalPresentationStyle и передать presentation controller через transitioningDelegate. Вооружившись этим знанием, начнём делать Bottom Sheet! Для показа Bottom Sheet переопределим modalPresentationStyle и transitioningDelegate. Не забываем, что transitioningDelegate — это weak ссылка, и нам понадобится strong ссылка, чтобы не потерять объект. Создадим BottomSheetTransitioningDelegate — реализацию transitioningDelegate. И presentation controller. Наконец вернёмся в RootViewController и закроем TODO. Давайте запустим приложение. Как будто стало только хуже. 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 и куда можно добавить, например, тень. Дополнительно укажем shouldPresentInFullscreen в false, потому что Bottom Sheet покрывает не весь экран. Посмотрим, что получилось. Изначальный размер учитывается, но нет реакции на его изменения. Рассмотрим UIPresentationController. Он реализует UIContentContainer, в котором нам интересен preferredContentSizeDidChange(forChildContentContainer:), который вызывается при изменениях preferredContentSize в дочерних view controller'ах. Проверяем текущий frame и тот, который мы считаем правильным. Если они разные, то обновляем presentedView.frame. Запустим приложение. Размер изменяется неравномерно без анимации. Почему? Потому что мы никак не указываем эту анимацию. Добавим анимацию на изменения preferredContentSize в ResizeViewController. Проверяем. Работает! Но мы никак не можем уйти с Bottom Sheet. Для закрытия добавим тень и по нажатию будем скрывать Bottom Sheet. Создадим обработчик закрытия, через который presentation controller будет сообщать, что готов, чтобы его закрыли. Передаём его в инициализатор presentation controller. Для удобства создадим фабрику presentation controller'а. Которая будет использоваться внутри BottomSheetTransitioningDelegate.  В RootViewController реализуем фабрику и обработчик закрытия. Скрываем presentedViewController, потому что это и есть Bottom Sheet. Теперь в presentation controller'е сконфигурируем тень с обработчиком скрытия. Добавляем тень перед началом транзишена и убираем после окончания. Для начала нам понадобится отслеживать состояние presentation controller. Введём поле state, которое будет отвечать за текущее состояние Bottom Sheet. Для отслеживания состояния переопределим методы жизненного цикла транзишена. Далее возникает вопрос, в какой момент добавлять и удалять тень. Добавлять тень будем перед показом Bottom Sheet, а удалять — сразу после скрытия Bottom Sheet. Остаётся реализовать addSubviews() и removeSubviews(). Посмотрим, что получилось. Отлично, теперь у нас добавляется тень, и по нажатию Bottom Sheet закрывается! Но тень появляется и исчезает без анимации. Как быть? Тень относится к транзишену и должна анимироваться вместе с ним. Поэтому нам нужно встроиться в transitioning delegate. Реализуем протокол UIViewControllerAnimatedTransitioning, в котором будет логика для анимированного поднимания и опускания шторки. Ровно такая же, как и у системы, но дополнительно добавим fade-анимацию для тени. Для простоты реализуем протокол внутри presentation controller, потому что у него уже есть доступ к нужным UI-элементам. И не забудем реализовать соответствующие методы в BottomSheetTransitioningDelegate. Убедимся, что анимация появилась. Остаётся добавить закругленные края, и наш Bottom Sheet готов! Через cornerRadius у presentedViewController в presentation controller. Нам нужно сделать это перед началом транзишена в presentationTransitionWillBegin(). Следим за углами. Закругленные! Теперь Bottom Sheet соответствует дизайну! Переопределили системный transitioning delegate. Создали presentation controller. Добавили тень для скрытия Bottom Sheet через dismiss handler. Реализовали анимированный транзишен через transitioning delegate. Поддержали базовый дизайн. Как и в первой части начинаем со стартового проекта. К проекту добавился 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, и по ним понимаем, в какой момент можно начинать интерактивное закрытие. По механике договорились, давайте её реализуем. То добавляем pan gesture к presentedView. В какой момент это делать? Жест инициирует интерактивное закрытие. А Bottom Sheet может быть закрыт, только если он полностью на экране. Поэтому разумно добавлять жест на окончание показа в presentationTransitionDidEnd(_:). И напишем функцию, которая добавляет pan gesture заданной view. Разберём каждое состояние жеста. began — пользователь только-только начал движение пальца и жест был определен, как pan gesture. Инициируем закрытие Bottom Sheet. changed — пользователь непрерывно ведёт пальцем по экрану. Скрываем Bottom Sheet пропорционально расстоянию, которое прошёл палец по экрану. ended — пользователь отпустил палец с экрана. Принимаем решение, закрывать Bottom Sheet или возвращать его в исходное положение. cancelled — жест был отменён. Возвращаем Bottom Sheet в исходное состояние. Дополнительно будем использовать UIPercentDrivenInteractiveTransition для передачи состояния транзишена transitioning delegate'у. Начнём с состояния began. Это подходящий момент для инициализации интерактивного закрытия, потому что это состояние происходит всего один раз. Так же вызываем dismiss у presentingViewController для уведомления UIKit о намерении закрыть Bottom Sheet. Далее — состояние changed. Изменяем позицию presentedView пропорционально движению пальца по экрану. Рассчитываем расстояние от начальной точки, где жест начался, до текущей. Далее вычисляем прогресс транзишена относительно высоты content view controller, т.е. presentedView. Когда пользователь отпустил палец, то жест переходит в состояние ended. Нужно определить намерение пользователя: скролл контента или желание закрыть Bottom Sheet. Если пользователь резко опустил палец вниз, пройдя минимальное расстояние, то скорее всего он хотел закрыть Bottom Sheet. Возможна и другая ситуация, если пользователь прошел большое расстояние по экрану и в последний момент с ускорением отпустил палец с экрана вверх. В такой ситуации Bottom Sheet должен вернуться в исходную позицию. Это наводит на мысли о расчёте некого импульса движения, который учитывает ускорение и направление. Немного физики. Представим, что есть некое тело, которое двигается с постоянной скоростью . Дальше на него подействовало замедление  на расстоянии . Вопрос — где остановится тело? Напишем формулу для скорости  С отрицательным ускорением скорость станет нулевой, обозначим этот момент времени . Подставим в формулу скорости: Далее подставим в формулу расстояния  Окончательно получим формулу для расстояния , где — начальное положение тела, — начальная скорость и — замедление. Примем Bottom Sheet за тело из задачи выше, когда жест закончился. Через pan gesture узнаем текущую скорость. Пройденное расстояние у нас есть. Замедление подбираем и принимаем за константу . Зная формулу для расстояния , рассчитаем, где остановится Bottom Sheet под замедлением. Если точка остановки ближе к начальному положению, то отменяем транзишен, иначе завершаем его. И если жест отменился cancelled, то возвращаемся в исходное положение. Также не забудем вернуть interactiveTransitioning в transitioning delegate. Запустим приложение и посмотрим, что получилось. Вжух-вжух, и мы научили Bottom Sheet закрываться по свайпу вниз! Однако, если контент больше экрана, то scrollView перехватывает жесты. Несмотря на то, что до этого ResizeViewController и так был списочным, это не помешало нам добавить pan gesture. Всё потому, что у scrollView нет скролла, когда contentSize равен его размеру. Поэтому рассмотрим случай, когда contentSize больше размера Bottom Sheet, и скролл есть. Подписываемся на contentOffset. Если contentOffset нулевой, и пользователь скроллит вниз, то инициируем закрытие. Когда пользователь отпускает палец с экрана, то решаем отменить или закончить транзишен, как и раньше. Если contentOffset изменяется и пользователь не касается экрана, то ничего не делаем. Это значит, что скролл происходит по инерции. Для начала нам понадобится признак, который подскажет, что у view controller есть scrollView. Введём для этого протокол. Для отслеживания изменений contentOffset подписываемся на UIScrollViewDelegate. Но что, если кто-то уже подписался на delegate UIScrollView? Тогда мы затрём предыдущий delegate. Поэтому будем использовать прокси на UIScrollViewDelegate. Идейно MulticastDelegate реализует UIScrollViewDelegate и проксирует методы delegate заинтересованным сторонам. При этом заботится, чтобы поле delegate не затиралось. В Swift нам приходится определять каждый метод delegate. В Objective-C можно добиться аналогичного результата без реализации всех методов delegate через runtime. Подписываемся на delegate после окончания транзишена в presentationTransitionDidEnd(_:), как и с pan gesture. Дальше реализуем UIScrollViewDelegate. И хелперы UIScrollView. Начнём с касания экрана scrollViewWillBeginDragging(:_). В этот момент пользователь только-только начал swipe-жест. Запоминаем это состояние через флаг isDragging. Далее рассмотрим вспомогательную функцию shouldDragOverlay(following:), в которой определяем, нужно ли обновить прогресс транзишена. Проверяем, что пользователь сейчас ведёт пальцем по экрану. Если так, то разбираемся, было ли инициировано закрытие. Если закрытие инициировано и мы где-то посередине транзишена, то продолжаем дальше. Если мы только-только инициировали закрытие, то проверяем, что скролл направлен вниз и контент находится сверху через isContentOriginInBounds. Если мы в начале транзишена, то также проверяем, что скролл направлен вниз и контент сверху. Дальше разберём, когда contentOffset изменяется и вызывается scrollViewDidScroll(:_). startInteractiveTransitionIfNeeded() — инициирует интерактивный транзишен, если мы это ещё не сделали. В scrollViewDidScroll(_:) проверяем, можем ли мы продолжить (начать) интерактивный транзишен. Если можем, то инициируем транзишен через startInteractiveTransitionIfNeeded(). Если не можем, то запоминаем последний contentOffset перед активацией транзишена. Он пригодится дальше. Помните, как на собеседованиях спрашивали, когда bounds.origin ненулевой? Вероятно, ответ был «когда contentOffset у scrollView ненулевой». Но было непонятно, как это можно использовать на практике, правда? Ниже хорошая возможность оправдать наличие этого знания! Далее убедимся, что контент прибит к верху, и приравняем contentInset к contentOffset. Измененяем contentOffset через bounds.origin, чтобы не вызывать scrollViewDidScroll(_:). В конце обновляем прогресс транзишена. Когда пользователь отрывает палец от экрана после скролла, то UIScrollViewDelegate вызывает scrollViewWillEndDragging(_:withVelocity:targetContentOffset:). В нём, как и в обработке состояния pan gesture ended, завершаем или отменяем транзишен. Через didStartDragging проверяем было ли активно интерактивное закрытие Bottom Sheet перед окончанием скролла. Если да, то, как и с pan gesture, используем скорость с замедлением, чтобы решить, отменить или закончить переход. Если нет, то отменяем транзишен. Возможна ситуация, что пользователь начал скрывать Bottom Sheet, а потом вернулся к скроллу контента. В этом случае у транзишена нулевой прогресс, и мы точно хотим его отменить. Давайте посмотрим, что получилось! Итак, мы научились работать со всеми размерами Bottom Sheet. Поддержали pan gesture и разобрали состояния: began, changed, ended, cancelled. Реализовали множественную подписку на delegate через MulticastDelegate. Отслеживаем UIScrollViewDelegate и обновляем состояние транзишена. Начинаем со стартового проекта. В 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. Аналогично presentation controller, реагируем на изменение content size через preferredContentSizeDidChange(forChildContentContainer:). Помним, что нужно самим позаботиться об анимации при изменении preferredContentSize. Поэтому добавим анимацию в BottomSheetNavigationController и уберём её из ResizeViewController. В presentation controller учтём, что presentedViewController может быть UINavigationController. Тогда нужно подписываться на текущий topViewController и на изменения навигационного стека. Обновим setupScrollTrackingIfNeeded(). Для отслеживания изменений навигационного стека подписываемся на delegate через известный нам паттерн MulticastDelegate. Присматриваем за scrollView при показе view controller. Проверяем результат. Navigation controller стал реагировать на изменения размера контента, но при переходе назад размер не участвует в анимации. Почему так происходит? Потому что системная реализация navigation controller не учитывает preferredContentSize при изменении навигационного стека. Поэтому нужно обновлять размер контента вместе с изменениями стека навигации. Для этого введём вспомогательную функцию, через которую будут идти обновления стека вместе с обновлением preferredContentSize. Если возможно, то изменение размера контента делаем анимированно через transitionCoordinator. Важно сначала обновить стек и только потом обновлять размер контента. Иначе topViewController будет неактуальным. Напоследок реализуем методы UINavigationController, которые изменяют навигационный стек, через updateNavigationStack(animated:applyChanges:). Запустим и посмотрим, что получилось. Стало лучше и размер контента учитывается, но с артефактами. Посмотрим на iOS 12. Транзишен отличается в худшую сторону и после окончания размер контента остаётся с предыдущего view controller. Получается, мы не можем рассчитывать на системный транзишен UINavigationController, и нам нужно реализовать его своими силами. Реализуем UINavigationControllerDelegate, в котором переопределим транзишен для push и pop операций. Далее реализуем UINavigationControllerDelegate и переопределим анимацию транзишена. Запустим приложение и последим за анимациями. Всё работает плавно, как ожидается!  Когда мы переопределили транзишен для push и pop операций, то мы потеряли системный интерактивный переход, который до этого работал из коробки. Придется реализовать его самим. Используем navigationController(_:interactionControllerFor:) из UINavigationControllerDelegate. Через него можно добавить интерактивный транзишен при переходе между view controller'ами. Но как повторить жест, который срабатывает при свайпе от края экрана? Воспользуемся UIScreenEdgePanGestureRecognizer, который делает ровно то, что нам нужно. Будем добавлять этот жест для view controller'а, который запушился в иерархию navigation controller'а. Привожу только критические участки кода. В обработчике жеста делаем то же самое, что и для pan gesture. Инициируем интерактивный транзишен, обновляем прогресс пропорционально движению пальца и используем тот же критерий для отмены и завершения перехода. Остаётся прокачать UINavigationControllerDelegate и учесть в нём интерактивный транзишен. На push добавляем к view controller'у жест для интерактивного pop'а. Проверяем. И ура, мы справились с нашим последним вызовом! Адаптировали UINavigationController под размер контента. Добились корректных push и pop транзишенов на iOS 12+. Поддержали уникальный интерактивный pop. Спасибо, что дочитали до конца! Теперь можно и резюме обновить.  Мы начали с нуля, а в конце получили многофункциональный Bottom Sheet. Ответили на вопросы, которые стояли в начале статьи, и даже вспомнили школьную физику. Надеюсь, что статья оказалась полезной, и в вашем приложении Bottom Sheet заиграет новыми красками!