2

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

Tomasz Pe
  • 696
  • 7
  • 19

3 Answers3

6

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.

Alex D.
  • 671
  • 3
  • 12
  • Your answer is really informative, big thanks! I've tested your solution and it's almost working... There is a glitch in pop animation, it's visible when you test on the device (iPhone 7, iOS 11.2). Will go into the details and try to find the reason. – Tomasz Pe Jan 26 '18 at 17:54
  • @Fayer I am glad that it is helpful! I have launched the project on the device (iPhone 6s, iOS 11.2.5) and the glitch really exists. It is present on iOS simulator too, but it is barely visible. The root cause is that I missed to set final transform value to the view. I fixed that bug and pushed update to the remote. Please, check if it works. – Alex D. Jan 26 '18 at 20:24
1

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
Giuseppe Lanza
  • 3,519
  • 1
  • 18
  • 40
  • As I mentioned before, navbar can't be completely transparent all the time. Check my edit, I hope that it will help to clarify the problem. – Tomasz Pe Jan 22 '18 at 22:14
  • You can have the navigationBar always transparent and get the same result as the gif. You just need a container view with top constraint = the bottom of the navigation bar having white color as background while the main view controller background has the color you want to see under the navigation bar. – Giuseppe Lanza Jan 23 '18 at 11:08
  • I edited the code in my answer. I obtained the effect you want with the approach I described in my previous comment. Try it in playground and let me know what do you think. :) – Giuseppe Lanza Jan 23 '18 at 11:45
  • I've tried your playground and have to admit that it works, thanks! TBH I'm not fully convinced due to potential overhead in the project but I'll try to put it into practice. – Tomasz Pe Jan 23 '18 at 23:34
  • What concerns you? Just one view is not going to affect performances. It's actually an approach widely used by apple itself. To convince yourself just use the view inspector with a navigation controller or a UITabViewController. You'll see a lot of containers. – Giuseppe Lanza Jan 23 '18 at 23:55
  • It's not about performance, I meant project consistency and maintenance. – Tomasz Pe Jan 25 '18 at 22:51
  • I see. Let me know if you need help with something related. I hope I helped you anyway. – Giuseppe Lanza Jan 25 '18 at 22:52
  • Will keep in mind :) Once again thank you for taking the time and fast responses. – Tomasz Pe Jan 25 '18 at 23:03
  • Cool. Don't forget to mark the answer as answered for who comes next with the same issue, if you feel satisfied with one of the answers we have – Giuseppe Lanza Jan 26 '18 at 08:15
0

remove this code from your project:

toVC.navigationController?.navigationBar.setBackgroundImage(
        UIImage.fromColor(color: self.isPresenting ? .yellow : .lightGray),
        for: .default
)
Datt Patel
  • 1,203
  • 1
  • 11
  • 10
  • After removing that part of code navbar will just remain transparent while it has to be accordingly `yellow` and `lightGray` because it's a part of bigger flow where all previous and consecutive views are presented on the same `UINavigationController`. – Tomasz Pe Jan 17 '18 at 14:46