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

в 14:20, , рубрики: Dodo IS, Dodo Pizza Engineering, interruptible transition, UINavigationControllerDelegate, UIPresentationController, UIViewAnimateTransitioning, UIViewPropertyAnimator, Блог компании Dodo Pizza Engineering, интерфейсы, разработка мобильных приложений, разработка под iOS

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

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

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

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

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

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

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

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

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

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

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

Можно анимировать переход для модальных контроллеров и для 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 управляет отображением контроллера: фреймом, иерархией и т.д. Он решает, как показывать поповеры на айпаде: с каким фреймом, в какую сторону от кнопки показывать, добавляет размытие в фон окна и затемнение под него.

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

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

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

Для начала, в методе 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)
    }
}

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

Всплывай! Транзишены в iOS - 6
Додо Пицца, Перекус и Сейви

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

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

Автор: akaDuality

Источник


* - обязательные к заполнению поля