
Порой дизайнеры рисуют необычные переходы между экранами, и UIKit не поддерживает их из коробки. Но их реализация не такая сложная, как может показаться на первый взгляд.
Давайте посмотрим на макеты:


Как вы могли заметить, есть два типа анимаций: переход между историями и закрытие/открытие историй как в Instagram (анимация Zoom In/Zoom Out). Давайте обсудим, как можно реализовать эти анимации.
Анимация Zoom In/Zoom Out
Первый тип анимации, который нам необходим, это открытие/закрытие экрана с историями. Идея в том, чтобы из какого-либо фрейма представлять вью-контроллер, в который он позже и закроется. Реализуем протокол для view, из которой будет представлен экран:
public protocol PreviewStoryViewProtocol: AnyObject {
var endFrame: CGRect { get }
var startFrame: CGRect { get }
}
public class PreviewStoryView: UIView, PreviewStoryViewProtocol {
public var startFrame: CGRect {
return convert(bounds, to: nil)
}
public var endFrame: CGRect {
return convert(bounds, to: nil)
}
}startFrame и endFrame отвечают за позицию этой view на экране.
Далее реализуем сам экран, отвечающий за истории. Он представляет из себя массив из нескольких контроллеров. Та�� как UIPageViewController не поддерживает пользовательские анимации при переходах, то реализуем эту логику на базе UINavigationController.
class StoriesNavigationController: UINavigationController {
// MARK: - Private properties
private var previewFrame: PreviewStoryViewProtocol?
// MARK: - Setup
func setup(viewControllers: [UIViewController], previewFrame: PreviewStoryViewProtocol?) {
self.previewFrame = previewFrame
self.viewControllers = viewControllers
}
// MARK: - Lifecycle
convenience init() {
self.init(nibName: nil, bundle: nil)
setupUI()
}
}
extension StoriesNavigationController {
private func setupUI() {
setNavigationBarHidden(true, animated: false)
modalPresentationStyle = .custom
}
}Функция setup отвечает за конфигурацию нашего NavigationController’а. В нее мы передаем массив вью-контроллеров и делегат previewFrame, через который позже получим необходимые фреймы для начала и окончания анимаций.
Далее перейдем к самому интересному. Каждый UIViewController имеет свой transitioningDelegate, который можно реализовать через UIViewControllerTransitioningDelegate. Каждый раз, когда мы совершаем показ или закрытие, UIKit спрашивает у делегата, какую анимацию ему отобразить. Чтобы заменить стандартную анимацию на свою, мы и реализуем UIViewControllerTransitioningDelegate.
extension StoriesNavigationController: UIViewControllerTransitioningDelegate {
public func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning?
{
guard let startFrame = previewFrame?.startFrame else { return nil }
return StoriesNavigationPresentAnimator(startFrame: startFrame)
}
public func animationController(
forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning?
{
guard let endFrame = previewFrame?.endFrame else { return nil }
return StoriesNavigationDismissAnimator(endFrame: endFrame)
}
}И не забудьте в функции setupUI указать transitioningDelegate = self.
Эти два метода отвечают за показ и закрытие view-котроллера. Для них мы и должны реализовать два аниматора на базе UIViewControllerAnimatedTransitioning. На эти методы возлагается вся логика анимации.
Рассмотрим первый аниматор StoriesNavigationPresentAnimator, отвечающий за показ.
class StoriesNavigationAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private enum Spec {
static let animationDuration: TimeInterval = 0.3
}
private let startFrame: CGRect
init(startFrame: CGRect) {
self.startFrame = startFrame
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return Spec.animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// 1
guard let toViewController = transitionContext.viewController(forKey: .to),
let snapshot = toViewController.view.snapshotView(afterScreenUpdates: true)
else {
return
}
// 2
let containerView = transitionContext.containerView
// 3
containerView.addSubview(toViewController.view)
toViewController.view.isHidden = true
// 4
snapshot.frame = startFrame
snapshot.alpha = 0.0
containerView.addSubview(snapshot)
UIView.animate(withDuration: Spec.animationDuration, animations: {
// 5
snapshot.frame = (transitionContext.finalFrame(for: toViewController))
snapshot.alpha = 1.0
}, completion: { _ in
// 6
toViewController.view.isHidden = false
snapshot.removeFromSuperview()
// 7
if transitionContext.transitionWasCancelled {
toViewController.view.removeFromSuperview()
}
// 8
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}Первое, что необходимо сделать, это указать время длительности анимации в методе transitionDuration(using:).
Затем мы реализуем саму анимацию внутри метода animateTransition.
Получаем презентуемый вью-контроллер и снэпшотим его.
Получаем
containerView. В этом контексте будет происходить анимация во время перехода между вью-контроллерами.Добавляем view конечного вью-контроллера в контекст и скрываем его.
Готовим снэпшот к анимации. Задаем ему frame view, из которого будем показывать.
Анимированно меняем размер снэпшота до финального размера.
После окончания анимации удаляем снэпшот, отображаем реальную view конечного view-котроллера.
Если переход не будет выполнен (например, прерван пользователем), то необходимо удалить конечное view (
toViewController.view), так как оно не будет отображено.И наконец-то сообщаем UIKit’у через
transitionContextо состоянии перехода.
Теперь ваш аниматор готов к использованию!
Аналогично реализуем аниматор для закрытия, который делает всё то же самое, но наоборот.
class StoriesNavigationDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private enum Spec {
static let animationDuration: TimeInterval = 0.3
}
private let endFrame: CGRect
init(endFrame: CGRect) {
self.endFrame = endFrame
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return Spec.animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromViewController = transitionContext.viewController(forKey: .from),
let snapshot = fromViewController.view.snapshotView(afterScreenUpdates: true)
else {
return
}
let containerView = transitionContext.containerView
containerView.addSubview(snapshot)
fromViewController.view.isHidden = true
UIView.animate(withDuration: Spec.animationDuration, delay: 0, options: .curveEaseOut, animations: {
snapshot.frame = self.endFrame
snapshot.alpha = 0
}, completion: { _ in
fromViewController.view.isHidden = false
snapshot.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}Чтобы посмотреть результат, реализуем простой StoryBaseViewController, который отвечает за экран с одной историей.
class StoryBaseViewController: UIViewController {
// MARK: - Constants
private enum Spec {
enum CloseImage {
static let size: CGSize = CGSize(width: 40, height: 40)
static var original: CGPoint = CGPoint(x: 24, y: 50)
}
}
// MARK: - UI components
private lazy var closeButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(#imageLiteral(resourceName: "closeImage"), for: .normal)
button.addTarget(self, action: #selector(closeButtonAction(sender:)), for: .touchUpInside)
button.frame = CGRect(origin: Spec.CloseImage.original, size: Spec.CloseImage.size)
return button
}()
// MARK: - Lifecycle
public override func loadView() {
super.loadView()
view.addSubview(closeButton)
}
@objc
private func closeButtonAction(sender: UIButton!) {
dismiss(animated: true, completion: nil)
}
}Завершающий этап — реализация view на стартовом ViewController'е, из которой происходит показ историй. Для этого необходимо создать массив историй (StoryBaseViewController) и отобразить в StoriesNavigationController.
class ViewController: UIViewController {
// MARK: - UI components
private lazy var previewView: PreviewStoryView = {
let preview = PreviewStoryView()
preview.frame.size = CGSize(width: 200, height: 200)
preview.backgroundColor = .black
preview.layer.cornerRadius = 10
preview.center = view.center
return preview
}()
private lazy var showButton: UIButton = {
let button = UIButton()
button.setTitle("Show", for: .normal)
button.addTarget(self, action: #selector(handleButtonAction), for: .touchUpInside)
button.frame = CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: 200, height: 200))
return button
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
}
extension ViewController {
private func setupUI() {
view.backgroundColor = .darkGray
view.addSubview(previewView)
previewView.addSubview(showButton)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(gesture:)))
previewView.addGestureRecognizer(panGesture)
}
}
extension ViewController {
@objc
func handleButtonAction(sender: UIButton!) {
var storyViewControllers: [UIViewController] {
let vc1 = StoryBaseViewController()
vc1.view.backgroundColor = .red
let vc2 = StoryBaseViewController()
vc2.view.backgroundColor = .green
let vc3 = StoryBaseViewController()
vc3.view.backgroundColor = .blue
return [vc1, vc2, vc3]
}
let storiesVC = StoriesNavigationController()
storiesVC.setup(viewControllers: storyViewControllers, previewFrame: previewView)
present(storiesVC, animated: true, completion: nil)
}
@objc
func handlePanGesture(gesture: UIPanGestureRecognizer) {
let stateIsValidate = gesture.state == .began || gesture.state == .changed
if let gestureView = gesture.view, stateIsValidate {
let translation = gesture.translation(in: self.view)
let newXPosition = gestureView.center.x + translation.x
let newYPosition = gestureView.center.y + translation.y
gestureView.center = CGPoint(x: newXPosition, y: newYPosition)
gesture.setTranslation(.zero, in: self.view)
}
}
}Обратите внимание, что previewView выступает делегатом для StoriesNavigationController и передает startFrame и endFrame. Можно интерактивно перемещать view, и показ экрана с историями будет происходить из нового местоположения на экране.

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