Всплывай! Транзишены в iOS

от автора

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

Изначально я хотел написать статью о том, что на iOS 10 появился удобный UIViewPropertyAnimator, который решает проблему прерываемых анимаций. Теперь их можно будет остановить, инвертировать, продолжить или отменить. Эпл называет такой интерфейс Fluid

Но потом я понял: сложно рассказывать о прерывании анимации контроллеров без описания того, как эти переходы правильно анимировать. Поэтому будет две статьи. В этой разберёмся, как правильно показывать и скрывать экран, а о прерывании — в следующей (но самые нетерпеливые уже могут посмотреть пример).

Как работают транзишены

У UIViewController есть проперти transitioningDelegate. Это протокол с разными функциями, каждая возвращает объект:

  • animationController за анимацию,
  • interactionController за прерывание анимаций,
  • presentationController за отображение: иерархию, frame и т.д.

На основе всего этого сделаем всплывающую панель:

Готовим контроллеры

Можно анимировать переход для модальных контроллеров и для UINavigationController (работает через UINavigationControllerDelegate).
Мы будет рассматривать модальные переходы. Показываем контроллер как обычно:

class ParentViewController: UIViewController {     @IBAction func openDidPress(_ sender: Any) {         let child = ChildViewController()         self.present(child, animated: true)     } }

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

class ChildViewController: UIViewController {     private let transition = PanelTransition() // 1     init() {         super.init(nibName: nil, bundle: nil)         transitioningDelegate = transition // 2         modalPresentationStyle = .custom // 3     } … }

  1. Создаём объект, описывающий переход. transitioningDelegate помечен как weak, поэтому приходиться хранить transition отдельно по strong ссылке.
  2. Сетим наш переход в transitioningDelegate.
  3. Для того, чтобы управлять способом отображения в presentationController нужно указывать .custom для modalPresentationStyle..

Показываем в пол-экрана

Начнём код для PanelTransition с presentationController. Вы с ним работали, если создавали всплывающие окна через UIPopoverController. PresentationController управляет отображением контроллера: фреймом, иерархией и т.д. Он решает, как показывать поповеры на айпаде: с каким фреймом, в какую сторону от кнопки показывать, добавляет размытие в фон окна и затемнение под него.

Наша структура похожа: будем затемнять фон, ставить фрейм не в полный экран:

Для начала, в методе presentationController(forPresented:, presenting:, source:) вернём класс PresentationController:

class PanelTransition: NSObject, UIViewControllerTransitioningDelegate {     func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {         return presentationController = PresentationController(presentedViewController: presented, presenting: presenting ?? source) }

Почему передаётся 3 контроллера и что такое source?

Source – это тот контроллер, на котором мы вызвали анимацию показа. Но контроллер, который будет участвовать в транзишине — первый из иерархии, у которого установлено definesPresentationContext = true. Если контроллер сменится, то настоящий показывающий контроллер будет в параметре presenting.

Теперь можно реализовать класс PresentationController. Для начала, зададим фрейм будущему контроллеру. Для этого есть метод frameOfPresentedViewInContainerView. Пусть контроллер займёт нижнюю половину экрана:

class PresentationController: UIPresentationController {     override var frameOfPresentedViewInContainerView: CGRect {         let bounds = containerView!.bounds         let halfHeight = bounds.height / 2         return CGRect(x: 0,                              y: halfHeight,                              width: bounds.width,                              height: halfHeight)     } }

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

// PresentationController.swift     override func presentationTransitionWillBegin() {         super.presentationTransitionWillBegin()         containerView?.addSubview(presentedView!)     }

Ещё нужно поставить фрейм для presentedView. containerViewDidLayoutSubviews – лучшее место, потому что так мы сможем реагировать и на поворот экрана:

// PresentationController.swift     override func containerViewDidLayoutSubviews() {         super.containerViewDidLayoutSubviews()         presentedView?.frame = frameOfPresentedViewInContainerView     }

Теперь можно запускать. Анимация будет стандартной для UIModalTransitionStyle.coverVertical, но фрейм будет в два раза меньше.

Затемняем фон

Следующая задача – затемнить фоновый контроллер, чтобы сфокусироваться на показанном.

Унаследуемся от PresentationController и заменим на новый класс в файле PanelTransition. В новом классе будет только код для затемнения.

class DimmPresentationController: PresentationController

Создадим вьюшку, которую будем накладывать поверх:

private lazy var dimmView: UIView = {     let view = UIView()     view.backgroundColor = UIColor(white: 0, alpha: 0.3)     view.alpha = 0     return view }()

Будем менять alpha вьюшки согласованно с анимацией перехода. Есть 4 метода:

  • presentationTransitionWillBegin
  • presentationTransitionDidEnd
  • dismissalTransitionWillBegin
  • dismissalTransitionDidEnd

Первый из них самый сложный. Надо добавить dimmView в иерархию, проставить фрейм и запустить анимацию:

override func presentationTransitionWillBegin() {     super.presentationTransitionWillBegin()     containerView?.insertSubview(dimmView, at: 0)     performAlongsideTransitionIfPossible { [unowned self] in         self.dimmView.alpha = 1     } }

Анимация запускается с помощью вспомогательной функции:

private func performAlongsideTransitionIfPossible(_ block: @escaping () -> Void) {     guard let coordinator = self.presentedViewController.transitionCoordinator else {         block()         return     }      coordinator.animate(alongsideTransition: { (_) in         block()     }, completion: nil) }

Фрейм для dimmView задаём в containerViewDidLayoutSubviews (как и в прошлый раз):

override func containerViewDidLayoutSubviews() {     super.containerViewDidLayoutSubviews()     dimmView.frame = containerView!.frame }

Анимация может быть прервана и отменена, и если отменили, то надо удалить dimmView из иерархии:

override func presentationTransitionDidEnd(_ completed: Bool) {     super.presentationTransitionDidEnd(completed)     if !completed {         self.dimmView.removeFromSuperview()     } }

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

override func dismissalTransitionWillBegin() {     super.dismissalTransitionWillBegin()     performAlongsideTransitionIfPossible { [unowned self] in         self.dimmView.alpha = 0     } }  override func dismissalTransitionDidEnd(_ completed: Bool) {     super.dismissalTransitionDidEnd(completed)     if completed {         self.dimmView.removeFromSuperview()     } }

Теперь фон затемняется.

Управляем анимацией

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

Теперь мы можем анимировать появление контроллера. В классе PresentationController вернём класс, который будет управлять анимацией появления:

func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {     return PresentAnimation() }

Реализовать протокол просто:

extension PresentAnimation: UIViewControllerAnimatedTransitioning {     func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {         return duration     }      func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {         let animator = self.animator(using: transitionContext)         animator.startAnimation()     }      func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {         return self.animator(using: transitionContext)     } }

Ключевой код чуть сложнее:

class PresentAnimation: NSObject {     let duration: TimeInterval = 0.3      private func animator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {         // transitionContext.view содержит всю нужную информацию, извлекаем её         let to = transitionContext.view(forKey: .to)!         let finalFrame = transitionContext.finalFrame(for: transitionContext.viewController(forKey: .to)!) // Тот самый фрейм, который мы задали в PresentationController         // Смещаем контроллер за границу экрана         to.frame = finalFrame.offsetBy(dx: 0, dy: finalFrame.height)         let animator = UIViewPropertyAnimator(duration: duration, curve: .easeOut) {             to.frame = finalFrame // Возвращаем на место, так он выезжает снизу         }          animator.addCompletion { (position) in         // Завершаем переход, если он не был отменён             transitionContext.completeTransition(!transitionContext.transitionWasCancelled)         }          return animator     } }

UIViewPropertyAnimator не работает в iOS 9

Обойти довольно просто: нужно в коде animateTransition использовать не аниматор, а старое апи UIView.animate… Например, вот так:

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {     let to = transitionContext.view(forKey: .to)!     let finalFrame = transitionContext.finalFrame(for: transitionContext.viewController(forKey: .to)!)      to.frame = finalFrame.offsetBy(dx: 0, dy: finalFrame.height)      UIView.animate(withDuration: duration, delay: 0,                             usingSpringWithDamping: 1, initialSpringVelocity: 0,                             options: [.curveEaseOut], animations: {                                 to.frame = finalFrame                             }) { (_) in                                 transitionContext.completeTransition(!transitionContext.transitionWasCancelled)                             } }  Этот метод не вызывается, если реализован `interruptibleAnimator(using transitionContext:)`

Если вы не делаете прерываемый транзишен, то метод interruptibleAnimator можно не писать. Прерываемость рассмотрим в следующей статье, подписывайтесь.

Скрываем контроллер вниз

Всё то же самое, только в обратную сторону. Класс целиком:

class DismissAnimation: NSObject {     let duration: TimeInterval = 0.3      private func animator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {         let from = transitionContext.view(forKey: .from)!         let initialFrame = transitionContext.initialFrame(for: transitionContext.viewController(forKey: .from)!)          let animator = UIViewPropertyAnimator(duration: duration, curve: .easeOut) {             from.frame = initialFrame.offsetBy(dx: 0, dy: initialFrame.height)         }          animator.addCompletion { (position) in             transitionContext.completeTransition(!transitionContext.transitionWasCancelled)         }          return animator     } }  extension DismissAnimation: UIViewControllerAnimatedTransitioning {     func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {         return duration     }      func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {         let animator = self.animator(using: transitionContext)         animator.startAnimation()     }      func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {         return self.animator(using: transitionContext)     } }

На этом месте можно поэкспериментировать со сторонами:
– снизу может появиться альтернативный сценарий;
– справа – быстрый переход по меню;
– сверху – информационное сообщение:


Додо Пицца, Перекус и Сейви

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

Подписывайтесь на канал Dodo Pizza Mobile.


ссылка на оригинал статьи https://habr.com/ru/company/dodopizzaio/blog/463527/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *