
Всем привет! Я хочу поделится своим опытом реализации кастомных переходов между экранами в 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.
Всем спасибо! Удачи в реализации ваших анимаций в приложениях! :-)
