Не так давно в Кошельке появился платежный фрагмент для оплаты в магазинах. При разработке его дизайна мы, конечно, ориентировались на Apple Pay. Он отображается в Bottom Sheet и имеет навигацию внутри себя. Если нажать на выбор карты, Bottom Sheet отобразит viewcontroller со списком карт, у которого еще и другая высота контента. Нам потребовалось повторить такое поведение и оказалось, что сделать это не так-то просто. Контент внутри UINavigationController не привязывается автолейаутом к верхней и нижней границам, а значит, высота Bottom Sheet’а не может быть рассчитана автоматически. Было два варианта: рассчитывать высоту вручную или написать свой NavigationController. Мы прикинули, что использовать Autolayout для нас важнее, чем использовать нативный UINavigationController, и выбрали второй вариант.

Это вторая статья из цикла про bottom sheet. Весь материал можно найти вот здесь:

  1. Bottom sheet: Custom transitioning

  2. Bottom sheet: Navigation

  3. Bottom sheet: Scrolling and interactions

Если вы ещё не пробовали отобразить Bottom Sheet в iOS до 15 версии, рекомендую сначала прочитать первую статью из цикла, там подробно описан именно этот вопрос.

Скачайте стартовый проект. Можно запустить и ознакомиться с его функциями.

Первая кнопка уже функциональная и умеет отображать различный контент как bottom sheet. Его высота вычисляется на основе размера контента при помощи autolayout.

Bottom Sheet Navigation Controller

Наш навигационный контроллер(BSNavigationController), как и нативный, займётся хранением и отображением списка child контроллеров. Для удобства использования другими разработчиками, полностью повторим интерфейс UINavigationController. Реализуем анимации для транзишенов, схожие с нативными. Они будут учитывать отображения контента только на части экрана, а также его увеличение или уменьшение.

Первым этапом опишем хранение child контроллеров в нашем navigation controller. Найдите в проекте класс BSNavigationController и добавьте в него код:

// 1
private let customTransitioningDelegate = BSTransitioningDelegate()
private(set) var viewControllers: [UIViewController] = []

public var topViewController: UIViewController? {
    viewControllers.last
}

convenience init(rootViewController: UIViewController) {
    self.init()
    setRootViewController(rootViewController)
}

init() {
    super.init(nibName: nil, bundle: nil)
    transitioningDelegate = transitionDelegate
    modalPresentationStyle = .custom
}

@available(*, unavailable)
required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

private func setRootViewController(_ viewController: UIViewController) {
	// 2
    viewControllers = [viewController]
}

public func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
    guard let to = viewControllers.last else {
        return
    }

	// 3
    if let from = topViewController {
    	// 4
    } else {
        setRootViewController(to)
    }

    self.viewControllers = viewControllers
}

public func pushViewController(_ viewController: UIViewController, animated: Bool) {
    guard let from = topViewController else {
        setRootViewController(viewController)
        return
    }

    self.viewControllers.append(viewController)
}

@discardableResult
public func popViewController(animated: Bool) -> UIViewController? {
    guard let from = topViewController, from != viewControllers.first else { return nil }
    
	viewControllers.removeLast()
  
	return from
}
  1. Кастомный transitioning delegate мы реализовали ранее, чтобы отображать navigation controller в виде bottom sheet. Как это работает, описано в первой статье.

  2. Метод выглядит на этом этапе бесполезным, но чуть дальше мы будем в нём настраивать стартовое состояние контроллера. Пока просто сохраним rootViewController в массиве.

  3. На случай, если navigation controller был создан без rootController, то, независимо от animated, мы вызовем метод setRootViewController(:)

  4. Место для старта анимации транзишена между верхними контроллерами текущего стека и нового.

Добавим rootViewController для отображения как child и закрепим к краям контейнера:

private func setRootViewController(_ viewController: UIViewController) {
	...

    addChild(viewController)
    view.addSubview(viewController.view)
    viewController.didMove(toParent: self)

    viewController.view.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
        viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
        viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        viewController.view.widthAnchor.constraint(equalTo: view.widthAnchor)
    ])
}

С добавлением и хранением разобрались, перейдём к анимациям транзишенов между контроллерами.

Анимации навигационных транзишенов

Как выглядит анимация перехода на новый контроллер? Новый контроллер появляется на верхнем слое из-за правого края экрана и перекрывает контент нижнего. В это время нижний контроллер смещается на треть своей ширины за правый край экрана.

Анимация будет построена на изменениях констрейнтов и их попеременном включении и отключении.

Реализуем эту анимацию, добавив следующие строки в BSNavigationController .

Перед самой анимацией добавим расчёт максимальной высоты контента:

private var contentMaxHeight: CGFloat {
    let keyWindow = UIApplication.shared.windows.first(where: \.isKeyWindow)
    let topInset = keyWindow?.safeAreaInsets.top ?? 0
    return UIScreen.main.bounds.height - topInset
}

Максимальной будем считать высоту экрана минус отступ safeArea сверху.

private func pushTransition(from: UIViewController, to: UIViewController, animated: Bool) {
    // 1
	guard let containerView = presentationController?.containerView else {
        return
    }

	// 2
    addChild(to)
    view.addSubview(to.view)
    from.willMove(toParent: nil)
    to.willMove(toParent: self)

	// 3
    view.removeConstraints(view.constraints.filter { $0.firstItem === from.view || $0.secondItem === from.view })
    
    from.view.translatesAutoresizingMaskIntoConstraints = false
    to.view.translatesAutoresizingMaskIntoConstraints = false

	// 4
	let fromTop = from.view.topAnchor.constraint(equalTo: view.topAnchor)
    let fromLeading = from.view.leadingAnchor.constraint(equalTo: view.leadingAnchor)

    let toTop = to.view.topAnchor.constraint(equalTo: view.topAnchor)
    let toLeading = to.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: view.bounds.width)
    
	NSLayoutConstraint.activate([
        fromTop,
        fromLeading,
        from.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        from.view.widthAnchor.constraint(equalTo: view.widthAnchor),
        from.view.heightAnchor.constraint(lessThanOrEqualToConstant: contentMaxHeight),
        
		toLeading,
        to.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        to.view.widthAnchor.constraint(equalTo: view.widthAnchor),
        to.view.heightAnchor.constraint(lessThanOrEqualToConstant: contentMaxHeight)
    ])

	// 5
    view.layoutIfNeeded()

	// 6
    fromTop.isActive = false
    fromLeading.constant = -view.bounds.width * 0.3
    toTop.isActive = true
    toLeading.constant = 0

	// 7
    CATransaction.begin()
    CATransaction.setAnimationTimingFunction(Constants.timingFunction)
    CATransaction.setDisableActions(!animated)

    UIView.animate(
        withDuration: Constants.duration,
        animations: {
			// 8
            containerView.layoutIfNeeded()
        }, completion: { _ in
			// 9
            to.didMove(toParent: self)
            from.removeFromParent()
            from.view.removeFromSuperview()
            from.didMove(toParent: nil)
        }
    )

    CATransaction.commit()
}

private enum Constants {
	static let duration: TimeInterval = 0.35
    static let timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 1, 0.42, 1)
}
  1. Проверим, что контейнер, в котором располагается BSNavigationController’s view, существует. Старт обновления constraints производится на его уровне, чтобы обновить всю иерархию зависимостей. Если делать это выше, то обновление высоты верхнего контейнера произойдёт без анимации.

  2. Добавляем новый контроллер как child, а для старого стартуем процесс открепления.

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

  4. Подготовим constraints для стартового состояния контроллеров. Активируем все констрейнты контроллеров кроме top для to контроллера. Здесь нам понадобится contentMaxHeight. Таким образом, если контент имеет высоту больше, чем может поместить на экране, то мы это учтём ещё до того, как в силу вступят ограничения контейнера. Так анимация перехода будет происходить между финальными состояниями контента внутри контроллеров. Сохраняем в переменные те констрейнты, которые будем менять в дальнейшем.

  5. Вызовем обновление layout, чтобы до старта анимации контроллеры обновились до заданного констрейнтами состояния.

  6. Изменение высоты контейнера будем анимировать от верхней точки старого контроллера к верхней точке нового. Отключаем top контстрейнт для from и включаем top для to. Обновляем константы leading констрейнтов обоих контроллеров для анимации смещения по горизонтали.

  7. Добавим возможность отключить анимации смены контроллеров. Для этого обернём вызов анимации в блок CATransaction. С помощью метода setDisableActions(:) можно пропускать анимацию обновления layout.

  8. Стартуем обновления layout.

  9. Заканчиваем добавление нового контроллера как child и открепление старого.

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

Реализация этого поведения находится в следующих строках:

private func popTransition(from: UIViewController, to: UIViewController, animated: Bool) {
    guard let containerView = presentationController?.containerView else {
        return
    }

    addChild(to)
    view.insertSubview(to.view, at: 0)
    from.willMove(toParent: nil)
    to.willMove(toParent: self)

    from.view.translatesAutoresizingMaskIntoConstraints = false
    to.view.translatesAutoresizingMaskIntoConstraints = false

    view.removeConstraints(view.constraints.filter { $0.firstItem === from.view || $0.secondItem === from.view })

    let fromTop = from.view.topAnchor.constraint(equalTo: view.topAnchor)
    let fromLeading = from.view.leadingAnchor.constraint(equalTo: view.leadingAnchor)

    let toTop = to.view.topAnchor.constraint(equalTo: view.topAnchor)
    let toLeading = to.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: -view.bounds.width * 0.3)

    NSLayoutConstraint.activate([
        fromTop,
        fromLeading,
        from.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        from.view.widthAnchor.constraint(equalTo: view.widthAnchor),
        from.view.heightAnchor.constraint(lessThanOrEqualToConstant: contentMaxHeight),

        toLeading,
        to.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        to.view.widthAnchor.constraint(equalTo: view.widthAnchor),
        to.view.heightAnchor.constraint(lessThanOrEqualToConstant: contentMaxHeight)
    ])

    view.layoutIfNeeded()

    fromTop.isActive = false
    fromLeading.constant = view.bounds.width
    toTop.isActive = true
    toLeading.constant = 0

    CATransaction.begin()
    CATransaction.setAnimationTimingFunction(Constants.timingFunction)
    CATransaction.setDisableActions(!animated)
    UIView.animate(
        withDuration: Constants.duration,
        animations: {
            containerView.layoutIfNeeded()
        }, completion: { _ in
            to.didMove(toParent: self)
            from.removeFromParent()
            from.view.removeFromSuperview()
            from.didMove(toParent: nil)
        }
    )

    CATransaction.commit()
}

Принцип анимации pop аналогичен push. Разница заключается в направлении смещения по горизонтали и в том, что верхний экран теперь from, а нижний to.

Добавим вызовы анимаций в три метода:

public func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
    guard let to = viewControllers.last else {
        return
    }

    if let from = topViewController {
		// 1
        pushTransition(from: from, to: to, animated: animated)
    } else {
        setRootViewController(to)
    }

    self.viewControllers = viewControllers
}

public func pushViewController(_ viewController: UIViewController, animated: Bool) {
    guard let from = topViewController else {
        setRootViewController(viewController)
        return
    }

	// 2
    pushTransition(from: from, to: viewController, animated: animated)
    self.viewControllers.append(viewController)
}

@discardableResult
public func popViewController(animated: Bool) -> UIViewController? {
    guard let from = topViewController, from != viewControllers.first else { return nil }

    viewControllers.removeLast()

	// 3
    if let to = topViewController {
        popTransition(from: from, to: to, animated: animated)
    }

    return from
}

Панель навигации

Ещё одной неотъемлемой составляющей navigation controller является верхняя навигационная панель (navigation bar). Повторим её для нашего кастомного решения и будем использовать тот же класс, что и для нативного — UINavigationBar.

Navigation bar хранит в себе navigation items подобно тому, как это делает navigation controller с children controllers. У каждого view controller есть свой navigation item, который navigation controller по аналогии с методом setViewControllers(_:animated:) добавляет в navigation bar. Так как мы используем UINavigationBar, а не пишем своё решение, то все анимации смены состояний мы получим из коробки.

Добавим navigation bar в navigation controller. Перейдём в BSNavigationView.swift и добавим в него код:

public let navigationBar = UINavigationBar()

private func createView() {
    addSubview(navigationBar)
    ...
}

private func configure() {
	...
    // 1
    navigationBar.barStyle = .default
    navigationBar.isTranslucent = false
    navigationBar.setBackgroundImage(UIImage(color: .secondaryBackground), for: .default)
    navigationBar.titleTextAttributes = [
        .foregroundColor: UIColor.primaryText
    ]
}

// 2
private var navigationBarConstraints: [NSLayoutConstraint] {
    [
        navigationBar.topAnchor.constraint(equalTo: topAnchor),
        navigationBar.leadingAnchor.constraint(equalTo: leadingAnchor),
        navigationBar.trailingAnchor.constraint(equalTo: trailingAnchor),
    ]
}

private func setupConstraints() {
    navigationBar.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate(
        navigationBarConstraints
    )
}

// 3
override public func addSubview(_ view: UIView) {
    super.addSubview(view)
    bringSubviewToFront(navigationBar)
}

override public func insertSubview(_ view: UIView, at index: Int) {
    super.insertSubview(view, at: index)
    bringSubviewToFront(navigationBar)
}

// 4
public func setNavigationBarHidden(_ hidden: Bool) {
    navigationBar.isHidden = hidden
}
  1. Кастомизируем немного отображение navigation bar, чтобы сохранить общий стиль приложения =)

  2. Настроим констрейнты так, что navigation bar располагается сверху и на всю ширину контейнера. Высоту navigation bar рассчитывает самостоятельно.

  3. Navigation bar всегда должен быть расположен поверх остального контента. Для этого переопределяем методы addSubview(:) и insertSubview(:at:) и поднимаем  bringSubviewToFront(navigationBar)

  4. Дадим возможность скрывать navigation bar при необходимости.

Теперь настроим жизненный цикл navigation bar. Вернёмся в BSNavigationController и добавим логики:

// 1
private(set) var isNavigationBarHidden: Bool = false {
    didSet {
        updateAdditionalSafeAreaInsets()
    }
}

// 2
public var navigationBar: UINavigationBar {
    contentView.navigationBar
}

// 3
override public func loadView() {
	...
    updateAdditionalSafeAreaInsets()
}

public override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

	// 4
    updateAdditionalSafeAreaInsets()
}

// 5
public func setNavigationBarHidden(_ hidden: Bool) {
    isNavigationBarHidden = hidden
    contentView.setNavigationBarHidden(hidden)
}

// 6
private func updateAdditionalSafeAreaInsets() {
    let top = isNavigationBarHidden
    ? 0
    : max(contentView.navigationBar.bounds.height, Constants.navBarHeight)
    additionalSafeAreaInsets = UIEdgeInsets(
        top: top,
        left: 0,
        bottom: 0,
        right: 0
    )
}

private enum Constants {
	...
	static let navBarHeight: CGFloat = 44
}
  1. Дадим возможность узнать, включено ли отображение navigation bar.

  2. Реализуем доступ к самому navigation bar на чтение. Может пригодиться при кастомизации из child контроллеров.

  3. Переопределим view для bottom sheet и обновим верхний отступ. В зависимости от того, видим или нет navigation bar, будет меняться дополнительный отступ сверху. Привязав контент к safeArea, мы всегда будем иметь актуальный отступ.

  4. Актуализируем высоту отступа при изменении контента.

  5. Меняем состояние видимости navigation bar.

  6. Высота navigation bar рассчитывается UIKit автоматически в зависимости от типа презентации. Если это fullscreen, то 44, а если модально, то 56 (начиная с iOS 13). Таким образом можем положиться на высоту navigation bar почти всегда, кроме начала появления контроллера, когда она ещё 0. Для корректной отрисовки контента во время транзишена минимальную высоту сделаем 44.

Добавим настройку navigation items для navigation bar в методы навигации:

private func setRootViewController(_ viewController: UIViewController) {
	viewControllers = [viewController]
	// 1
    navigationBar.setItems(viewControllers.map { $0.navigationItem }, animated: false)
	...
}

public func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
	...
    self.viewControllers = viewControllers
	// 2
    navigationBar.setItems(viewControllers.map { $0.navigationItem }, animated: animated)
}

public func pushViewController(_ viewController: UIViewController, animated: Bool) {
	...
    self.viewControllers.append(viewController)
	// 3
    navigationBar.setItems(viewControllers.map { $0.navigationItem }, animated: animated)
}


@discardableResult
public func popViewController(animated: Bool) -> UIViewController? {
	...
    viewControllers.removeLast()
	// 4
    navigationBar.setItems(viewControllers.map { $0.navigationItem }, animated: animated)
	...
}

Для тестирования добавим следующий код в метод presentVCInBottomSheet() RootViewController:

Для тестирования результата создадим BSNavigationController, в который поместим FirstViewController. У последнего есть переменная nextTapHandler, ожидающая замыкание, которое вызовется по нажатию на «Далее» в navigation bar. Откроем ViewController и добавим следующие строки в метод presentVCInBottomSheet():

@objc
func presentVCInBottomSheet() {
    let firstVC = FirstViewController()
    let bottomSheet = BSNavigationController(rootViewController: firstVC)
    firstVC.nextTapHandler = {
        let secondVC = SecondViewController()
        secondVC.backTapHandler = {
            bottomSheet.popViewController(animated: true)
        }
        bottomSheet.pushViewController(secondVC, animated: true)
    }
    present(bottomSheet, animated: true)
}

Как видно из примера, по итогу работа с BSNavigationController ничем не отличается от UINavigationController.

Запускаем, проверяем, радуемся!

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

Итого

Мы воссоздали полноценную навигацию в рамках bottom sheet отображения, основанного на autolayout, а не на неудобном ручном расчёте высоты. Повторили анимации навигационных переходов. Добавили navigation bar как нативный способ управления навигацией.

У этого решения есть как плюсы, так и минусы. Основным минусом является то, что решение не нативное, а значит есть шанс, что на его поддержку в каждой версии SDK необходимо будет выделять дополнительные ресурсы разработки. С другой стороны, если не использовать autolayout, это тоже может привести к тому, что в новой версии iOS что-то отвалится. Мне неоднократно приходилось чинить кастомные хэдэры после обновления SDK, высота которых рассчитывалась вручную. Я же постарался сделать свой NavigationController, по максимуму используя нативные инструменты: Autolayout и UINavigationBar. Так я попробовал минимизировать риски того, что в модуль придется лезть, чтобы что-то починить, а не добавить.

В конечном итоге плюсы такой реализации перевесили минусы, и мы построили свой платежный фрагмент в проде на таком модуле.

Комментарии (0)