Search code examples
iosswiftuinavigationbarcustom-transition

UINavigationBar color animation synchronized with push animation


I want to achieve smooth animation between views with a different UINavigationBar background colors. Embedded views have the same background color as UINavigationBar and I want to mimic push/pop transition animation like:

enter image description here

I've prepared custom transition:

class CustomTransition: NSObject, UIViewControllerAnimatedTransitioning {

    private let duration: TimeInterval
    private let isPresenting: Bool

    init(duration: TimeInterval = 1.0, isPresenting: Bool) {
        self.duration = duration
        self.isPresenting = isPresenting
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return duration
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let container = transitionContext.containerView
        guard
            let toVC = transitionContext.viewController(forKey: .to),
            let fromVC = transitionContext.viewController(forKey: .from),
            let toView = transitionContext.view(forKey: .to),
            let fromView = transitionContext.view(forKey: .from)
        else {
            return
        }

        let rightTranslation = CGAffineTransform(translationX: container.frame.width, y: 0)
        let leftTranslation = CGAffineTransform(translationX: -container.frame.width, y: 0)

        toView.transform = isPresenting ? rightTranslation : leftTranslation

        container.addSubview(toView)
        container.addSubview(fromView)

        fromVC.navigationController?.navigationBar.backgroundColor = .clear
        fromVC.navigationController?.navigationBar.setBackgroundImage(UIImage.fromColor(color: .clear), for: .default)

        UIView.animate(
            withDuration: self.duration,
            animations: {
                fromVC.view.transform = self.isPresenting ? leftTranslation :rightTranslation
                toVC.view.transform = .identity
            },
            completion: { _ in
                fromView.transform = .identity
                toVC.navigationController?.navigationBar.setBackgroundImage(
                    UIImage.fromColor(color: self.isPresenting ? .yellow : .lightGray),
                    for: .default
                )
                transitionContext.completeTransition(true)
            }
        )
    }
}

And returned it in the UINavigationControllerDelegate method implementation:

func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    return CustomTransition(isPresenting: operation == .push)
}

While push animation works pretty well pop doesn't.

enter image description here

Questions:

  1. Why after clearing NavBar color before pop animation it remains yellow?
  2. Is there any better way to achieve my goal? (navbar can't just be transparent all the time because it's only a part of the flow)

Here is the link to my test project on GitHub.

EDIT

Here is the gif presenting the full picture of discussed issue and the desired effect:

enter image description here


Solution

  • These components are always very difficult to customize. I think, Apple wants system components to look and behave equally in every app, because it allows to keep shared user experience around whole iOS environment.

    Sometimes, it easier to implement your own components from scratch instead of trying to customize system ones. Customization often could be tricky because you do not know for sure how components are designed inside. As a result, you have to handle lots of edge cases and deal with unnecessary side effects.

    Nevertheless, I believe I have a solution for your situation. I have forked your project and implemented behavior you had described. You can find my implementation on GitHub. See animation-implementation branch.


    UINavigationBar

    The root cause of pop animation does not work properly, is that UINavigationBar has it's own internal animation logic. When UINavigationController's stack changes, UINavigationController tells UINavigationBar to change UINavigationItems. So, at first, we need to disable system animation for UINavigationItems. It could be done by subclassing UINavigationBar:

    class CustomNavigationBar: UINavigationBar {
       override func pushItem(_ item: UINavigationItem, animated: Bool) {
         return super.pushItem(item, animated: false)
       }
    
       override func popItem(animated: Bool) -> UINavigationItem? {
         return super.popItem(animated: false)
       }
    }
    

    Then UINavigationController should be initialized with CustomNavigationBar:

    let nc = UINavigationController(navigationBarClass: CustomNavigationBar.self, toolbarClass: nil)
    

    UINavigationController


    Since there is requirement to keep animation smooth and synchronized between UINavigationBar and presented UIViewController, we need to create custom transition animation object for UINavigationController and use CoreAnimation with CATransaction.

    Custom transition

    Your implementation of transition animator almost perfect, but from my point of view few details were missed. In the article Customizing the Transition Animations you can find more info. Also, please pay attention to methods comments in UIViewControllerContextTransitioning protocol.

    So, my version of push animation looks as follows:

    func animatePush(_ transitionContext: UIViewControllerContextTransitioning) {
      let container = transitionContext.containerView
    
      guard let toVC = transitionContext.viewController(forKey: .to),
        let toView = transitionContext.view(forKey: .to) else {
          return
      }
    
      let toViewFinalFrame = transitionContext.finalFrame(for: toVC)
      toView.frame = toViewFinalFrame
      container.addSubview(toView)
    
      let viewTransition = CABasicAnimation(keyPath: "transform")
      viewTransition.duration = CFTimeInterval(self.duration)
      viewTransition.fromValue = CATransform3DTranslate(toView.layer.transform, container.layer.bounds.width, 0, 0)
      viewTransition.toValue = CATransform3DIdentity
    
      CATransaction.begin()
      CATransaction.setAnimationDuration(CFTimeInterval(self.duration))
      CATransaction.setCompletionBlock = {
          let cancelled = transitionContext.transitionWasCancelled
          if cancelled {
              toView.removeFromSuperview()
          }
          transitionContext.completeTransition(cancelled == false)
      }
      toView.layer.add(viewTransition, forKey: nil)
      CATransaction.commit()
    }
    

    Pop animation implementation is almost the same. The only difference in CABasicAnimation values of fromValue and toValue properties.

    UINavigationBar animation

    In order to animate UINavigationBar we have to add CATransition animation on UINavigationBar layer:

    let transition = CATransition()
    transition.duration = CFTimeInterval(self.duration)
    transition.type = kCATransitionPush
    transition.subtype = self.isPresenting ? kCATransitionFromRight : kCATransitionFromLeft
    toVC.navigationController?.navigationBar.layer.add(transition, forKey: nil)
    

    The code above will animate whole UINavigationBar. In order to animate only background of UINavigationBar we need to retrieve background view from UINavigationBar. And here is the trick: first subview of UINavigationBar is _UIBarBackground view (it could be explored using Xcode Debug View Hierarchy). Exact class is not important in our case, it is enough that it is successor of UIView. Finally we could add our animation transition on _UIBarBackground's view layer direcly:

    let backgroundView = toVC.navigationController?.navigationBar.subviews[0]
    backgroundView?.layer.add(transition, forKey: nil)
    

    I would like to note, that we are making prediction that first subview is a background view. View hierarchy could be changed in future, just keep this in mind.

    It is important to add both animations in one CATransaction, because in this case these animations will run simultaneously.

    You could setup UINavigationBar background color in viewWillAppear method of every view controller.

    Here is how final animation looks like:

    enter image description here

    I hope this helps.