...

четверг, 22 августа 2019 г.

Всплывай! Транзишены в 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.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий