Pull to refresh

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

Reading time9 min
Views2.8K

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

Tags:
Hubs:
Total votes 2: ↑2 and ↓0+2
Comments0

Articles