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

Изначально я хотел написать статью о том, что на iOS 10 появился удобный
UIViewPropertyAnimator, который решает проблему прерываемых анимаций. Теперь их можно будет остановить, инвертировать, продолжить или отменить. Эпл называет такой интерфейс Fluid [1].Но потом я понял: сложно рассказывать о прерывании анимации контроллеров без описания того, как эти переходы правильно анимировать. Поэтому будет две статьи. В этой разберёмся, как правильно показывать и скрывать экран, а о прерывании — в следующей (но самые нетерпеливые уже могут посмотреть пример [2]).
У 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 метода:
presentationTransitionWillBeginpresentationTransitionDidEnddismissalTransitionWillBegindismissalTransitionDidEndПервый из них самый сложный. Надо добавить 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)
}
}
На этом месте можно поэкспериментировать со сторонами:
– снизу может появиться альтернативный сценарий;
– справа – быстрый переход по меню;
– сверху – информационное сообщение:

Додо Пицца [3], Перекус [4] и Сейви [5]
В следующий раз добавим интерактивное закрытие жестом, а потом сделаем его анимацию прерываемой. Если не терпится, то полный проект уже на гитхабе. [2]
Подписывайтесь на канал Dodo Pizza Mobile. [6]
Автор: akaDuality
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/interfejsy/327886
Ссылки в тексте:
[1] Fluid: https://devstreaming-cdn.apple.com/videos/wwdc/2018/803lpnlacvg2jsndx/803/hls_vod_mvp.m3u8
[2] пример: https://github.com/akaDuality/InterruptibleTransition
[3] Додо Пицца: https://apps.apple.com/ru/app/%D0%B4%D0%BE%D0%B4%D0%BE-%D0%BF%D0%B8%D1%86%D1%86%D0%B0-%D0%B4%D0%BE%D1%81%D1%82%D0%B0%D0%B2%D0%BA%D0%B0-%D0%BF%D0%B8%D1%86%D1%86%D1%8B/id894649641
[4] Перекус: https://www.artlebedev.ru/perekus/
[5] Сейви: https://ilyabirman.ru/meanwhile/all/sayve/
[6] на канал Dodo Pizza Mobile.: https://telegram.im/dodomobile
[7] Источник: https://habr.com/ru/post/463527/?utm_campaign=463527&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.