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

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



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

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

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


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


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


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



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


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


    class ParentViewController: UIViewController {
    
        private let transition = PanelTransition()      // 1
    
        @IBAction func openDidPress(_ sender: Any) {
            let child = ChildViewController()
            child.transitioningDelegate = transition   // 2
            child.modalPresentationStyle = .custom  // 3
    
            present(child, animated: true)
        }
    }

    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()
        }
    }

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


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


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


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


    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 можно не писать. Прерываемость рассмотрим в следующей статье, подписывайтесь.


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


    Всё то же самое, только в обратную сторону. В Panel Transition создадим класс DismissAnimation:


    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return DismissAnimation()
    }

    И реализуем его. Класс DismissAnimation целиком:


    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.

    • +29
    • 5,6k
    • 8
    Dodo Pizza Engineering
    349,58
    О том как IT доставляет пиццу
    Поделиться публикацией

    Комментарии 8

      0
      Ну вот вы то наверное мне и ответите на вопрос, который не дает покоя. Вопрос не прям про создание своих транзишенов, но про transitionCoordinator, который вы упомянули в вспомогательной функции. И вопрос в следующем:

      По каким причинам может отсутствовать transitionCoordinator?

      Ситуация: Я делаю push в навигационный стэк и сразу обращаюсь к transitionCoordinator чтобы получить возможность иметь completion block (примеров реализации очень много в интернете). Когда вызывается completion block я делаю модификацию стэка навигации. Достаточно редко и только на iOS 12 transitionCoordinator отсутствует. Соответствено completion block вызывается сразу. И в момент срабатывания completion block, контролер, который я пушил в стэк, не присутствует в массиве viewControllers и не является topViewController. Другими словами навигационный контролер не знает о контролере, который мы в него запушили к моменту окончания пуша. После этого модификация стека приводит к крэшу со словами (Pushing the same view controller instance more than once is not supported), т.к. я подменяю массив viewControllers на другой, который содержит контролер, который мы изначально запушили. Этот кусок кода в продакшене обложен логами с ног до головы. Я точь-в-точь могу повторить шаги пользователя. Но закрэшить приложение у меня так и не выходит.
        0
        Сложно ответить на вопрос с этой стороны.

        Расскажите подробней, зачем вы меняете стек навигации во время пуша? Может быть, есть решение без использования `transitionCoordinator`.
          0
          во время пуша
          Не во время, а после пуша. Для этого и нужен completion block.

          зачем вы меняете стек навигации
          Допустим у вас в стеке есть контролеры А, Б. Вам пришла пуш-нотификация и надо показать контролер В в этот же навигационный стек. Но есть бизнес логика, которая запрещает иметь живой контролер Б если в стеке есть контролер В. Соответственно, после пуша мы получаем массив А, Б, В. И я его без анимации подменяю на массив А, В после окончания пуша в стэк.
            0
            Может быть, можно без анимации скрыть контроллер Б и без анимации пушнуть контроллер В?
        0
        — Deleted
          0
          Писал с нуля такую всплывающую модалку целую неделю. В итоге плюнул и нашёл Под «SPStorkController» полностью повторяющий функционал модалки в стандартном приложении «Музыка», вплоть до анимации сгибания стрелки. Закончил 19 августа, спустя 3 дня выходит сия статья. Мб выйди она раньше не бросил и дописал свою реализацию до конца)
            0

            SPStorkController крутой под. Как вижу, там реализовали целый стек из контроллеров.


            Если правильно понимаю, то у Музыки немного другой подход, контроллер всегда на экране. Тогда лучше делать не через транзишены, а управлять UIViewPropertyAnimator как в этой статье.

              0
              Согласен, подход отличается. Сравнивал саму анимацию выплыва модалки.

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

          Самое читаемое