
Всем привет! Я хочу поделится своим опытом реализации кастомных переходов между экранами в iOS. Несмотря на то, что тема эта достаточно популярная, и очень многие дизайнеры хотят привнести в процесс перехода какую-то свою изюминку (затемнение, параллакс и т. п.), реализация этих вещей не очень тривиальна. Я попробую разложить все по полочкам. Рассмотрим сначала стандартное решение, которое не особо гибкое, но зачастую достаточное для многих проектов. Затем реализуем полностью кастомное и контролируемое исключительно нами решение.
Итак, погнали!
Речь пойдет о всем знакомых переходах между контроллерами внутри UINavigationController. Гораздо удобнее и приятнее с точки зрения пользователя иметь возможность совершать переходы с помощью свайпов. Эту задачу мы и будем решать в статье.
InteractivePopGestureRecognizer
Задача реализации навигации с помощью свайпа может быть решена с помощью interactivePopGestureRecognizer. Реализовывается чрезвычайно просто:
navigationController.interactivePopGestureRecognizer?.delegate = self
navigationController.interactivePopGestureRecognizer?.isEnabled = trueЭто позволит с помощью свайпа слева направо возвращаться на предыдущий контроллер в стэке. Начинаться свайп должен будет с левой кромки экрана.
В большинстве случаев этой функциональности хватает. Но довольно часто необходимо иметь дополнительный контроль над анимацией, а также над триггером старта анимации - к примеру, если нужно свайпать с произвольной точки экрана, а не только с левого края.
UIViewControllerAnimatedTransitioning
Следующий способ основан на протоколе UIViewControllerAnimatedTransitioning, который мы можем реализовать и передать в соответствующем методе делегата нашему экземпляру UINavigationController. Объект, реализующий этот протокол, будет определять необходимую нам анимацию, которая будет использована для анимирования возврата на предыдущий экран (pop). В общем случае, можно использовать его и для перехода на новый экран (push), но лучше пока сосредоточиться только на одной задаче, чтобы не усложнять код.
Также будет необходимо передать и объект, реализующий протокол UIViewControllerInteractiveTransitioning. Данный объект позволит управлять анимацией с помощью жестов.
Давайте создадим наследника UINavigationController, назовем его BackSwipeNavigationController. Это действие не то, чтобы обязательное, но мне удобно делать так, чтобы в проекте иметь возможность использовать как обычные UINavigationController, так и с кастомной логикой.
Полный код файла:
Hidden text
import UIKit
class BackSwipeNavigationController: UINavigationController {
    lazy var navigationData: BackSwipeNavigationData = {
        let data = BackSwipeNavigationData()
        return data
    }()
    lazy var backSwipeManager: BackSwipeNavManager = {
        let manager = BackSwipeNavManager(data: self.navigationData, navController: self)
        return manager
    }()
    lazy var panGestureRecognizer: UIPanGestureRecognizer = {
        UIPanGestureRecognizer(target: backSwipeManager,
                               action: #selector(BackSwipeNavManager.handlePanGesture(_:)))
    }()
    
    init() {
        super.init(nibName: nil, bundle: nil)
        configure()
    }
    override init(nibName: String?, bundle: Bundle?) {
        super.init(nibName: nibName, bundle: bundle)
        configure()
    }
    override init(rootViewController: UIViewController) {
        super.init(rootViewController: rootViewController)
        configure()
    }
    func configure() {
        delegate = backSwipeManager
        panGestureRecognizer.isEnabled = true
        view.addGestureRecognizer(panGestureRecognizer)
    }
    
    override func pushViewController(_ contoller: UIViewController, animated: Bool) {
        navigationData.duringPushAnimation = true
        super.pushViewController(contoller, animated: animated)
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
Здесь мы переопределяем метод pushViewController, чтобы отслеживать момент начала push-анимации, которую мы пока не будет переопределять.
override func pushViewController(_ contoller: UIViewController, animated: Bool) {
    navigationData.duringPushAnimation = true
    super.pushViewController(contoller, animated: animated)
}Также в нашем контроллере появляется два новых объекта классов BackSwipeNavigationData и BackSwipeNavManager. Первый будет хранить необходимые нам данные, которые в зависимости от задачи можно будет расширять. Второй будет делегатом контроллера и содержать логику управления жестами.
lazy var navigationData: BackSwipeNavigationData = {
    let data = BackSwipeNavigationData()
    return data
}()
lazy var backSwipeManager: BackSwipeNavManager = {
    let manager = BackSwipeNavManager(data: self.navigationData, navController: self)
    return manager
}()В контроллере также определяем UIPanGestureRecognizer, который будет ловить жесты, обработку которых мы поручаем BackSwipeNavManager.
lazy var panGestureRecognizer: UIPanGestureRecognizer = {
  UIPanGestureRecognizer(target: backSwipeManager,
                         action: #selector(BackSwipeNavManager.handlePanGesture(_:)))
}()Приведем код BackSwipeNavigationData:
class BackSwipeNavigationData {
    var duringPushAnimation: Bool = false
    var duringPopAnimation: Bool = false
    var percentDrivenInteractiveTransition: UIPercentDrivenInteractiveTransition!
}Флаги duringPushAnimation и duringPopAnimation хранят информацию о типе анимации. Почему-то мне было проще использовать булевские переменные, хотя очень даже просится enum. Ну не суть :-) 
Переменная percentDrivenInteractiveTransition будет нашим рычагом управления анимацией. Именно с помощью нее мы будем осуществлять магию, задавая момент времени, на который мы перемещаем анимацию, а также осуществлять запуск анимации с текущего фрейма вперед или назад.
Прежде, чем мы перейдем к реализации BackSwipeNavManager, я хочу схематично описать всех участников процесса.

По этой схеме мы можем теперь описать дальнейший алгоритм, который реализуем в двух основных классах решения - BackSwipeNavManager и BackSwipeAnimatedTransitioning.
Итак, наш NavigationController будет делегировать всю основную логику в BackSwipeNavManager, включая логику обработки жестов от PanGestureRecognizer и обработку методов протокола UINavigationControllerDelegate, которые и запускают кастомизацию процесса перехода.
BackSwipeNavManager будет делать две основные вещи:
- Запускать кастомную анимацию перехода, реализацией которой займется класс - BackSwipeNavigationTransitioning.
- Контролировать кастомную анимацию с помощью экземпляра класса - UIPercentDrivenInteractiveTransition, подключив логику контроля к обработке жестов.
Теперь приведем полный код BackSwipeNavManager.
Hidden text
class BackSwipeNavManager: NSObject, UINavigationControllerDelegate {
    var navigationData: BackSwipeNavigationData!
    var navController: BackSwipeNavigationController!
    
    init(data: BackSwipeNavigationData,
               navController: BackSwipeNavigationController) {
        navigationData = data
        self.navController = navController
    }
    
    func navigationController(_ navigationController: UINavigationController,
                              animationControllerFor operation: UINavigationController.Operation,
                              from fromVC: UIViewController,
                              to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        if navigationData.duringPushAnimation {
            return nil
        }
        return BackSwipeAnimatedTransitioning()
    }
    
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        navigationData.duringPushAnimation = false
    }
    
    func navigationController(_ navigationController: UINavigationController,
                              interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        if navigationData.duringPushAnimation {
            return nil
        }
        if navController.panGestureRecognizer.state == .began {
            navigationData.percentDrivenInteractiveTransition = UIPercentDrivenInteractiveTransition()
            navigationData.percentDrivenInteractiveTransition.completionCurve = .easeOut
        } else {
            navigationData.percentDrivenInteractiveTransition = nil
        }
        return navigationData.percentDrivenInteractiveTransition
    }
    
    @objc func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
        let percent = max(panGesture.translation(in: navController.view).x, 0) / navController.view.frame.width
        switch panGesture.state {
        case .began:
            guard navController.viewControllers.count > 1 else {
                return
            }
            navController.delegate = self
            navigationData.duringPopAnimation = true
            navController.popViewController(animated: true)
        case .changed:
            if let percentDrivenInteractiveTransition = navigationData.percentDrivenInteractiveTransition {
                percentDrivenInteractiveTransition.update(percent)
            }
        case .ended:
            navigationData.duringPopAnimation = false
            guard let percentDrivenInteractiveTransition = navigationData.percentDrivenInteractiveTransition else { return }
            let velocity = panGesture.velocity(in: navController.view).x
            // Continue if drag more than 50% of screen width or velocity is higher than 1000
            if percent > 0.5 || velocity > 1000 {
                percentDrivenInteractiveTransition.finish()
            } else {
                percentDrivenInteractiveTransition.cancel()
                navigationData.percentDrivenInteractiveTransition = nil
            }
        case .cancelled,
             .failed:
            navigationData.duringPopAnimation = false
            guard let percentDrivenInteractiveTransition = navigationData.percentDrivenInteractiveTransition else { return }
            percentDrivenInteractiveTransition.cancel()
            navigationData.percentDrivenInteractiveTransition = nil
        default:
            break
        }
    }
}
Рассмотрим этот код подробнее. Начнем с метода:
func navigationController(_ navigationController: UINavigationController,
                          animationControllerFor operation: UINavigationController.Operation,
                          from fromVC: UIViewController,
                          to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    if navigationData.duringPushAnimation {
        return nil
    }
    return BackSwipeAnimatedTransitioning()
}Тут мы убеждаемся, что это именно pop-анимация. Затем, если это именно она, создаем и возвращаем экземпляр класса BackSwipeAnimatedTransitioning. В этом классе будет определена сама анимация перехода, и его мы рассмотрим позднее. 
Далее определим метод:
func navigationController(_ navigationController: UINavigationController,
                              interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    if navigationData.duringPushAnimation {
        return nil
    }
    if navController.panGestureRecognizer.state == .began {
        navigationData.percentDrivenInteractiveTransition = UIPercentDrivenInteractiveTransition()
        navigationData.percentDrivenInteractiveTransition.completionCurve = .easeOut
    } else {
        navigationData.percentDrivenInteractiveTransition = nil
    }
    return navigationData.percentDrivenInteractiveTransition
}В этом месте мы создаем и возвращаем объект класса UIPercentDrivenInteractiveTransition, с помощью которого будем контролировать анимацию. 
Затем идет объявление функции:
@objc func handlePanGesture(_ panGesture: UIPanGestureRecognizer)В ней мы осуществляем обработку жестов. Рассмотрим кейсы состояния рекогнайзера:
began
Убеждаемся, что нам есть, куда откатываться в navigation-stack, выставляем делегатом себя (мало ли, кто его изменил в процессе :-) ), устанавливаем флаг типа анимации, и осуществляем сам pop.
changed
Осуществляем установку момента анимации. Его высчитываем в самом начале, так как он будет использоваться в нескольких кейсах.
ended
Здесь мы определяем логику, когда пользователь оторвал палец от экрана. В этом случае нужно либо откатить анимацию назад, либо довести ее до конца. Для этого мы смотрим на процент завершения анимации, либо на скорость, с которой пользователь сделал свайп.
cancelled или failed
В этом кейсе всегда отменяем анимацию.
Остается теперь реализация самой анимации. Ее мы реализуем в классе BackSwipeAnimatedTransitioning.
Hidden text
class BackSwipeAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.3
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let containerView = transitionContext.containerView
        
        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
              let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
            return
        }
        
        let fromView = fromVC.view
        let toView = toVC.view
        
        let originToFrame = toView?.frame ?? CGRect.zero
        
        let width = containerView.frame.width
        
        var offsetLeft = fromView?.frame
        offsetLeft?.origin.x = width
        
        var offscreenRight = toView?.frame
        offscreenRight?.origin.x = -width / 3.33
        toView?.frame = offscreenRight!
        
        fromView?.layer.shadowRadius = 5.0
        fromView?.layer.shadowOpacity = 1.0
        toView?.layer.opacity = 0.9
        let toFrame = (fromView?.frame)!
        
        containerView.insertSubview(toView!, belowSubview: fromView!)
        
        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: .curveLinear, animations: {
            toView?.frame = toFrame
            fromView?.frame = offsetLeft!
            toView?.layer.opacity = 1.0
            fromView?.layer.shadowOpacity = 0.1
        }, completion: { _ in
            toView?.layer.opacity = 1.0
            toView?.layer.shadowOpacity = 0
            fromView?.layer.opacity = 1.0
            fromView?.layer.shadowOpacity = 0
            toView?.frame = originToFrame
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}Реализуя протокол UIViewControllerAnimatedTransitioning, мы должны реализовать два метода, определяющие длительность анимации и саму анимацию. 
Рассмотрим немного метод создания анимации:
func animateTransition(using transitionContext: UIViewControllerContextTransitioning)В него передается контекст перехода, из которого мы получаем доступ к контроллерам, между которыми осуществляется переход, а вместе с ними и к их view. Этого достаточно, чтобы реализовать абсолютно любую анимацию перехода между ними, которой затем мы сможем легко управлять.
Вот собственно и все. Отдельно хочу заметить, что подобным образом можно делать контролируемые кастомные анимации не только для переходов в UINavigationController, но и для переходов посредством present и dismiss контроллеров, а даже для переходов между экранами UITabBarController. 
Всем спасибо! Удачи в реализации ваших анимаций в приложениях! :-)
 
          