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



Изначально я хотел написать статью о том, что на 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()
    }
}

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


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


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


Теперь мы можем анимировать появление контроллера. В классе 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.

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


  1. iWheelBuy
    22.08.2019 22:32

    Ну вот вы то наверное мне и ответите на вопрос, который не дает покоя. Вопрос не прям про создание своих транзишенов, но про 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 на другой, который содержит контролер, который мы изначально запушили. Этот кусок кода в продакшене обложен логами с ног до головы. Я точь-в-точь могу повторить шаги пользователя. Но закрэшить приложение у меня так и не выходит.


    1. akaDuality Автор
      23.08.2019 12:39

      Сложно ответить на вопрос с этой стороны.

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


      1. iWheelBuy
        23.08.2019 12:44

        во время пуша
        Не во время, а после пуша. Для этого и нужен completion block.

        зачем вы меняете стек навигации
        Допустим у вас в стеке есть контролеры А, Б. Вам пришла пуш-нотификация и надо показать контролер В в этот же навигационный стек. Но есть бизнес логика, которая запрещает иметь живой контролер Б если в стеке есть контролер В. Соответственно, после пуша мы получаем массив А, Б, В. И я его без анимации подменяю на массив А, В после окончания пуша в стэк.


        1. akaDuality Автор
          26.08.2019 14:04

          Может быть, можно без анимации скрыть контроллер Б и без анимации пушнуть контроллер В?


  1. akaDuality Автор
    23.08.2019 12:39

    — Deleted


  1. Freeeon
    26.08.2019 14:21

    Писал с нуля такую всплывающую модалку целую неделю. В итоге плюнул и нашёл Под «SPStorkController» полностью повторяющий функционал модалки в стандартном приложении «Музыка», вплоть до анимации сгибания стрелки. Закончил 19 августа, спустя 3 дня выходит сия статья. Мб выйди она раньше не бросил и дописал свою реализацию до конца)


    1. akaDuality Автор
      26.08.2019 15:23

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


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