Хабрахабр

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

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

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

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

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

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

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

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

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

Показываем контроллер как обычно: Можно анимировать переход для модальных контроллеров и для UINavigationController (работает через UINavigationControllerDelegate).
Мы будет рассматривать модальные переходы.

class ParentViewController: UIViewController
}

Для простоты способ отображения будем задавать в дочернем контроллере:

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..

Показываем в пол-экрана

Вы с ним работали, если создавали всплывающие окна через UIPopoverController. Начнём код для PanelTransition с presentationController. Он решает, как показывать поповеры на айпаде: с каким фреймом, в какую сторону от кнопки показывать, добавляет размытие в фон окна и затемнение под него. 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?

Но контроллер, который будет участвовать в транзишине — первый из иерархии, у которого установлено definesPresentationContext = true. Source – это тот контроллер, на котором мы вызвали анимацию показа. Если контроллер сменится, то настоящий показывающий контроллер будет в параметре 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!) }

containerViewDidLayoutSubviews – лучшее место, потому что так мы сможем реагировать и на поворот экрана: Ещё нужно поставить фрейм для presentedView.

// 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
}()

Есть 4 метода: Будем менять alpha вьюшки согласованно с анимацией перехода.

  • 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.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть