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