BottomSheet в iOS 15: возможности ​​UISheetPresentationController +3



До iOS 15 у разработчиков не было быстрого и удобного способа отображать контент в виде шторки, занимающей часть экрана. Приходилось выдумывать, как реализовать такое модальное представление. 

В актуальной версии UI Kit для iOS «родная» реализация BottomSheet (хоть это и неофициальный термин по версии Apple) наконец появилась. 

Дмитрий Демьянов, iOS-разработчик Surf, рассказывает:

  • Как новый ​​UISheetPresentationController взаимодействует с клавиатурой, жестами внутри шторки, фоновым контроллером.

  • Какие возможности для дополнительной кастомизации доступны разработчику сейчас.

Как BottomSheet был реализован до iOS 15

Раньше приходилось пользоваться сторонними библиотеками или писать собственную реализацию. Оба варианта не лишены недостатков.

Сторонние библиотеки. Проблемой часто становилась ограниченность кастомизации. Решение: форкать репозиторий с библиотекой и вносить нужные изменений. Это наиболее распространённый вариант на проектах, которые мне встречались.

Собственная реализация. Предоставляет полную свободу, но нюансы отнимают много времени. Решение должно быть переиспользуемым: логично разделить его на контроллер-контейнер, который отвечает за представление, и контроллер с контентом, который находится внутри контейнера. 

Схема возможной реализации:

  • UIViewControllerTransitioningDelegate — протокол с методами, которые возвращают объекты, описывающие кастомное модальное представление.

  • UIViewControllerAnimatedTransitioning отвечает за анимацию появления и исчезновения. 

  • UIViewControllerInteractiveTransitioning отвечает за жестовое управление появлением и исчезновением.

  • UIPresentationController отвечает за параметры представления: например, размер или затемнение фона.

Отдельного внимания заслуживает размер представления. Он может быть фиксированным, а может рассчитываться с помощью AutoLayout. Более того, он может меняться уже после появления на экране, что иногда тоже нужно учитывать. 

Также придётся продумать:

  • Взаимодействие с клавиатурой.

  • Взаимодействие жестов появления и скрытия с жестами внутри ContentViewController — например, со скроллом списка или коллекции.

  • Возможность взаимодействовать с частью экрана на заднем плане.

Приключение явно не на 20 минут — и это главный недостаток такого способа реализации. 

Что предлагает UI Kit в iOS 15. UISheetPresentationController

Начнём сразу с кода.

let sheetViewController = SheetViewController()
if let sheet = sheetViewController.sheetPresentationController {
    sheet.detents = [.medium()]
}
present(sheetViewController, animated: true)

Этого уже достаточно для отображения SheetViewController размером в половину экрана. Справа для сравнения — обычное модальное представление.

Высота шторки

Medium и Large

Разберём детали написанного кода — а именно, поле detents

detent — это значение высоты представления, в котором контроллер может быть зафиксирован. Значение по умолчанию — [.large()], что соответствует обычному модальному представлению с полной высотой экрана, как в примере справа. В коде мы указали значение medium — это высота в половину экрана.

detents принимает список из нескольких значений: можно указать сразу оба варианта.

sheet.detents = [.medium(), .large()]

Тогда размер представления можно будет изменять свайпом.

В документации Apple сказано, что значения detents нужно располагать по возрастанию. Если порядок нарушить, получим два эффекта:

  • Контроллер откроется сразу на весь экран.

  • Пропадёт визуальный эффект стопки контроллеров вверху экрана.

Баг это или фича? Первый эффект точно нежелательный: если нужно открыть шторку сразу с полной высотой, достаточно указать sheet.selectedDetentIdentifier = .large после задания списка detents. Отсутствие эффекта стопки — тоже сомнительный результат. Поэтому идти против документации всё же не стоит.

Переключение между выбранными высотами можно выполнить из кода:

extension SheetViewController {
    func changeHeight() {
        guard let sheet = sheetPresentationController else {
            return
        }
        let oldValue = sheet.selectedDetentIdentifier
        sheet.selectedDetentIdentifier = oldValue?.oppositeValue
    }
}

extension UISheetPresentationController.Detent.Identifier {
    var oppositeValue: UISheetPresentationController.Detent.Identifier {
        switch self {
        case .medium:
            return .large
        case .large:
            return .medium
        default:
            fatalError("Unsupported value")
        }
    }
}

При создании шторки мы указали sheet.detents = [.medium(), .large()], следовательно, шторка откроется в половину экрана, и при вызове changeHeight() увеличится до полного экрана. 

Однако... этого не происходит: код выше не работает. Проблема здесь в получении oldValue. Поле selectedDetentIdentifier по умолчанию установлено в nil

Варианта решения два: 

  • Указать начальное значение при создании шторки. 

  • Предоставить для oldValue значение по умолчанию. Им будет medium , так как шторка при открытии выбирает наименьшее значение высоты из возможных:

let oldValue = sheet.selectedDetentIdentifier ?? .medium

Теперь смена высоты из кода уже выполняется. При этом значение всегда будет актуальным: если пользователь изменит высоту свайпом шторки, selectedDetentIdentifier тоже обновится.

Но такое изменение без анимации вряд ли кому-то понравится. К счастью, исправить это совсем легко. Изменения, которые нужно анимировать, добавляются в блок animateChanges:

@IBAction func changeHeight() {
    guard let sheet = sheetPresentationController else {
        return
    }
    let oldValue = sheet.selectedDetentIdentifier ?? .medium
    sheet.animateChanges {
        sheet.selectedDetentIdentifier = oldValue.oppositeValue
    }
}

Теперь получилось то, что нужно.

Кастомные значения высоты шторки

Теперь посмотрим на другие возможные значения detents. Ой, подождите. Их же нет... Получается, все возможные варианты — это medium и large? В этот список явно напрашивается значение small. Тогда можно было бы выбрать размер: четверть, половина или полный экран. 

К сожалению, с этим ограничением придётся смириться и признать: если нужна высота шторки, отличная от половины экрана, про UISheetPresentationController можно пока забыть. 

Мне кажется, значение small стоит ждать в ближайших версиях iOS, ведь оно идеально вписывается к остальным вариантам. Да и указание значений списком намекает на то, что в будущем список намереваются расширить.

Если хочется получить высоту шторки, автоматически рассчитывающуюся по высоте контента, UISheetPresentationController точно не поможет. В таком случае придётся вернуться к выбору библиотеки или собственной реализации шторки.

Взаимодействие с клавиатурой

При появлении клавиатуры шторка автоматически увеличится до высоты large. Удобно!

А что будет, если при создании шторки не указать large в списке доступных высот? Тогда высота шторки увеличится, но не до полноэкранной, а только на значение высоты клавиатуры. 

Значит, detent всё-таки может принимать значения, отличные от medium и large. Но пока только в случае с поднятой клавиатурой.

Взаимодействие с жестами внутри шторки

Проверим, конфликтует ли жест изменения высоты шторки с жестами внутри шторки: например, со скроллом списка.

Если значение detents только одно, никаких проблем не возникает. Скролл списка плавно переходит в закрытие шторки.

Если доступны оба варианта высоты, жест изменения высоты имеет приоритет над скроллом списка. Изменить это можно так:

sheet.prefersScrollingExpandsWhenScrolledToEdge = false

Теперь при скролле таблицы поведение будет такое же, как в предыдущем примере с одним возможным detent. Высоту шторки при этом можно изменить свайпом по области шторки, которая не занята таблицей.

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

При открытии шторки фон затемняется, при его касании шторка закрывается. Такое поведение предсказуемо и логично, но UISheetPresentationController позволяет убрать dimmingView. В результате получим возможность взаимодействовать и со шторкой, и с фоном.

sheet.largestUndimmedDetentIdentifier = .medium

Здесь задаётся максимальная высота шторки, при которой возможно взаимодействие с фоном. Пока единственное значение, имеющее смысл, — medium. Это тоже даёт намёк на расширение списка detents в ближайших обновлениях iOS. 

Дополнительная кастомизация

У шторки можно настроить горизонтальную полоску-индикатор и радиус закругления.

sheet.prefersGrabberVisible = true
sheet.preferredCornerRadius = 32

Полоску-индикатор, впрочем, никто не мешает заменить на обычный UIView и настроить его внешний вид на своё усмотрение. Например, поменять цвет индикатора, толщину или увеличить отступ от него до верхнего края шторки.

Когда стоит применять UISheetPresentationController 

Пока во всех случаях, когда скудный выбор значения высоты шторки — не препятствие для решения задачи. В остальном реализация получилась гибкой и удобной для разработчиков. Она удачно вписалось в системные приложения: например, в список избранного в Safari.

Уверен, что в ближайшем обновлении iOS нехватку значений высоты исправят, и тогда это будет уже полноценным решением, экономящим кучу времени разработчикам.




Комментарии (4):

  1. MFilonen2
    /#23775015 / +1

    И вот имея кучу примеров реализации в виде сторонних библиотек и 100500 способов это сделать руками в UIKit, Apple должна была зашивать это в iOS 15 без обратной совместимости.
    Зачем?

    • Florelit
      /#23775489

      Apple всегда пытается привлечь больше новых разработчиков под свои платформы (оно и очевидно, Apple имеет свой процент + наполнение стора). Один из способов — снижение порога вхождения: переход на Swift, SwiftUI, расширение возможностей UIKit и тп.
      Думаю в данном случае именно такая ситуация — видя что элемент дизайна стал очень популярным в большом количестве приложений, кто-то решил добавить «решение из коробки».
      А обратная совместимость не самый главный приоритет в последнее время, к сожалению.

      • MFilonen2
        /#23776189

        Да я же не против этих новшеств, просто я так понимаю, что если они не могут сохранить обратную совместимость, то каждый раз, добавляя новый компонент, они хардкодят его в UIKit, меняя предыдущие кода (иначе бы компонент мог бинарником идти вместе с софтом).
        А хардкодить в своем же фреймворке для добавления таких вещей – не самый крутой архитектурный подход…

        • Florelit
          /#23782895

          В то же время, стоит рассмотреть обратную сторону вопроса. Если оставить только самые базовые компоненты в фреймворке, то бинарник приложений мог бы значительно вырасти. Так же это даёт более простой доступ к UI/UX решениям, которые используются в стандартных приложениях, плюс опять же скорость разработки.
          Думаю должен быть плюс минус баланс в таких вопросах.