Привет! Всем нравятся отзывчивые приложения. Ещё лучше, когда в них есть уместные анимации. В этой статье я расскажу и покажу со всем «мясом», как правильно показывать, скрывать, крутить, вертеть и делать всякое с всплывающими экранами.
Изначально я хотел написать статью о том, что на 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
}
…
}
- Создаём объект, описывающий переход.
transitioningDelegate
помечен какweak
, поэтому приходиться хранитьtransition
отдельно поstrong
ссылке. - Сетим наш переход в
transitioningDelegate
. - Для того, чтобы управлять способом отображения в
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)
}
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
}
}
Обойти довольно просто: нужно в коде 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.
Автор: akaDuality