Pull to refresh
Dodo Engineering
О том, как разработчики строят IT в Dodo

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

Reading time 8 min
Views 27K

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



Изначально я хотел написать статью о том, что на 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(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 можно не писать, но тогда надо вручную вызвать анимацию у PropertyAnimator.


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


Всё то же самое, только в обратную сторону. В 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)
    }
}

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



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


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


Серия статей, в которых можно почитать про транзишены в iOS и Android:

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

Tags:
Hubs:
+29
Comments 10
Comments Comments 10

Articles

Information

Website
dodo.dev
Registered
Founded
Employees
201–500 employees
Location
Россия