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

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

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