Tomasz Pe
Tomasz Pe

Reputation: 716

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

Upvotes: 2

Views: 2646

Answers (3)

alexd
alexd

Reputation: 681

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.

Upvotes: 6

Giuseppe Lanza
Giuseppe Lanza

Reputation: 3699

The way I would do it is by making the navigation controller completely transparent. This way the animation of the contained view controller should give the effect you want.

Edit: You can get also "white content" by having a containerView constrained under the navigation bar. In the sample code I did that. The push picks randomly a color and gives to the container view randomly white or clear. You will see that all the scenarios in your gif are covered by this example.

Try this in playground:

import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {
  var containerView: UIView!

  override func loadView() {
    let view = UIView()
    view.backgroundColor = .white

    containerView = UIView()
    containerView.backgroundColor = .white
    containerView.translatesAutoresizingMaskIntoConstraints = false

    view.addSubview(containerView)
    containerView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor).isActive = true
    containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
    containerView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
    containerView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true

    let button = UIButton()
    button.frame = CGRect(x: 150, y: 200, width: 200, height: 20)
    button.setTitle("push", for: .normal)
    button.setTitleColor(.black, for: .normal)
    button.addTarget(self, action: #selector(push), for: .touchUpInside)
    containerView.addSubview(button)
    self.view = view
  }

  @objc func push() {
    let colors: [UIColor] = [.yellow, .red, .blue, .purple, .gray, .darkGray, .green]
    let controller = MyViewController()
    controller.title = "Second"

    let randColor = Int(arc4random()%UInt32(colors.count))
    controller.view.backgroundColor = colors[randColor]

    let clearColor: Bool = (arc4random()%2) == 1
    controller.containerView.backgroundColor = clearColor ? .clear: .white
    navigationController?.pushViewController(controller, animated: true)
  }
}
// Present the view controller in the Live View window

let controller = MyViewController()
controller.view.backgroundColor = .white
let navController = UINavigationController(rootViewController: controller)
navController.navigationBar.setBackgroundImage(UIImage(), for: .default)
controller.title = "First"


PlaygroundPage.current.liveView = navController

Upvotes: 2

Datt Patel
Datt Patel

Reputation: 1213

remove this code from your project:

toVC.navigationController?.navigationBar.setBackgroundImage(
        UIImage.fromColor(color: self.isPresenting ? .yellow : .lightGray),
        for: .default
)

Upvotes: 0

Related Questions