Порой дизайнеры рисуют необычные переходы между экранами, и 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.

  1. Получаем презентуемый вью-контроллер и снэпшотим его.

  2. Получаем containerView. В этом контексте будет происходить анимация во время перехода между вью-контроллерами.

  3. Добавляем view конечного вью-контроллера в контекст и скрываем его.

  4. Готовим снэпшот к анимации. Задаем ему frame view, из которого будем показывать.

  5. Анимированно меняем размер снэпшота до финального размера.

  6. После окончания анимации удаляем снэпшот, отображаем реальную view конечного view-котроллера.

  7. Если переход не будет выполнен (например, прерван пользователем), то необходимо удалить конечное view (toViewController.view), так как оно не будет отображено.

  8. И наконец-то сообщаем 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, и показ экрана с историями будет происходить из нового местоположения на экране.

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

Весь исходный код этой статьи можете скачать тут.