Navigation bar и анимация перехода

Поведение UINavigationBar при переходе по стеку может показаться непредсказуемым и часто забагованным. Но, на самом деле, так и есть! Эта статья призвана освежить знания о принципах работы и показать возможности кастомизации поведения.

Немного общей теории

Если вы хорошо осведомлены, смело пролистывайте непосредственно к анимации.

  1. UINavigationBar — это view. Как правило, его положением управляет UINavigationController, но, так же как и другие view, его можно использовать самостоятельно.

  2. UINavigationItem — это класс, описывающий состояние (похожее на viewModel)  для конфигурации UINavigationBar. Просто класс со свойствами, которые будут переданы в UINavigationBar.

  3. UINavigationBar содержит массив [UINavigationItem]. С помощью методов pushItem, popItem и setItems можно анимировать переход одного состояния UINavigationBar в другое.

  4. Каждый UIViewController содержит UINavigationItem. UINavigationController сам управляет добавлением этого свойства в стек item-ов UINavigationBar-а.

Более подробно можно почитать вот здесь:

Также приведу ссылку на полезную схему полного жизненного цикла с UINavigationBar-ом. Это поможет лучше понимать его поведение:

e3b696802ee8d1f2c3c9b5ec5db23479

Проблема с изменением высоты

UINavigationBar может иметь разную высоту. Это зависит от следующих параметров:

  • Наличие или отсутствие prompt (текст над заголовком)

  • largeTitleDisplayMode управляет показом большого или стандартного заголовка

Так как UINavigationBar — это одна view, а контроллеров — пара, при переходе анимировано меняется в том числе и высота, что часто урезает контент.

Для наглядности покрасим навбар: розовый — это navigationBar.backgroundColor, а зелёный — navigationBar.barTintColor.

f8e0d013de8fc4975592dc6d99f02837.gif

Одно из решений — использовать прозрачный backgroundColor. Кстати, backgroundColor — это цвет именно view и он не учитывает смещение на статус-бар. 

a03db7939ddd949bbaf55a79d8d3caf7.gif

При переходе между контроллерами с имеющимся prompt-ом такой трюк уже не сработает.

3d1ea1cda7917508f06d94b3dc9a05fe.gif

К сожалению, контроля над transition анимацией в самом UINavigationBar мы не имеем. А при использовании UIViewControllerAnimatedTransitioning потеряем анимацию в UINavigationBar. 

План у нас следующий: добавить адаптацию контента, используя системную анимацию перехода

Создадим несколько контроллеров и используем safeArea для лейаута контента. В последующем смена additionalSafeAreaInsets у UIViewController-а позволит его анимировать:

contentView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true

Опишем наследника UINavigationController и добавим реализацию протокола UINavigationControllerDelegate. Это позволит отлавливать событие показа контроллеров, и именно тут будет модифицироваться анимация.

class NavigationController: UINavigationController, UINavigationControllerDelegate { }

Не забудем присвоить делегат самому UINavigationController-у.

navigationController.delegate = navigationController

Далее самое интересное. Ловим UIViewController в методе navigationController (_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) перед появлением и добавим в transitionCoordinator анимацию для safeArea.

guard let fromViewController = viewController.transitionCoordinator?.viewController(forKey: .from) 

else { return }

// Определяем тип анимации. Если контроллера нет в стеке, значит, это pop

let isPopped = !navigationController.viewControllers.contains(fromViewController)

 

// Добавляем анимацию, которая будет выполнятся одновременно с системной

viewController.transitionCoordinator?.animate { context in

    guard let from = context.viewController(forKey: .from),

          let to = context.viewController(forKey: .to)

    else { return }

    

    // Установка начального состояния

    // Перед анимацией задать параметры не выйдет, потому что safeArea изменится

    // и расчёты могут быть неверными

    UIView.setAnimationsEnabled(false)

    let diff = to.view.safeAreaInsets.top - from.view.safeAreaInsets.top

    // Выравниваем контент первого контроллера относительно второго

    to.additionalSafeAreaInsets.top = -diff

    to.view.layoutIfNeeded()

    UIView.setAnimationsEnabled(true)

    

    // Анимируем safeArea

    to.additionalSafeAreaInsets.top = 0

    to.view.layoutIfNeeded()

    

    guard isPopped else { return }

 

    // Изменение фрейма только для pop-анимации

    // так как в этом случае additionalSafeAreaInsets не анимируется

    from.view.frame.origin.y = diff

    from.view.frame.size.height += max(0, -diff)

    

} completion: { context in

    guard let from = context.viewController(forKey: .from),

          let to = context.viewController(forKey: .to)

    else { return }

    

    from.additionalSafeAreaInsets.top = 0

    to.additionalSafeAreaInsets.top = 0

}

Посмотрим, что вышло:

35b07b8653118bda01fdc8165d1481eb.gif

Заключение

Безусловно показанный пример — это не универсальный код. Скорее всего, вы ещё сотню раз столкнётесь с проблемами в UINavigationBar. Суть в том, чтобы показать, как понимание основ и принципов может облегчить разработку. 

Если у вас есть свои интересные решения, буду рад обсудить их в комментариях :)

Источники

Твиттер: Rtishchev Evgenii https://twitter.com/katleta3000/status/1259400743771156480

https://stackoverflow.com/questions/39515313/animate-navigation-bar-bartintcolor-change-in-ios10-not-working

Лучшие практики для navigation bar:  https://www.programmersought.com/article/1594185256/

© Habrahabr.ru