Создание анимаций для навигации в iOS

от автора

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

  1. Запускать кастомную анимацию перехода, реализацией которой займется класс BackSwipeNavigationTransitioning.

  2. Контролировать кастомную анимацию с помощью экземпляра класса 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

Всем спасибо! Удачи в реализации ваших анимаций в приложениях! 🙂 

P.S. Код демо-проекта из статьи доступен по ссылке


ссылка на оригинал статьи https://habr.com/ru/post/692180/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *