Search code examples
swiftuitableviewanimationuinavigationcontrolleruinavigationbar

Animate change to title text in Navigation Controller


I am attempting to animate the change to the title of a detail UITableViewController that is embedded in a UINavigationController.

These two Q&A on stack overflow seem most relevant...

I've attempted a number of code variants, mostly following Ashley Mills answer and others based on his answer, but essentially I cannot seem to make the animation work!

Note writing in Swift 5 using Xcode 10.2.

I'm using a Master-Detail UITableViewController setup to manage a list and the details of the items in that list.

I'm using Large Titles...

navigationController?.navigationBar.prefersLargeTitles = true

...and a search controller...

navigationItem.searchController = <<mySearchController>>
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true

...all set in the viewDidLoad() method for the Master View Controller.

Here's what I've done.

In my project:

  1. import QuartzCore framework, per the comment by Jack Bellis "you'll need to add the QuartzCore framework via [project]>[target]>Build Phases>Link Binaries>QuartzCore.framework.";

    In the Master View Controller:

  2. import QuartzCore;

    In the Detail View Controller:

  3. In the viewDidAppear(_ animated: Bool) method, write the CATransition code similar to the answers to the OP noted above (which changes the Large Title, but without animation).

    override func viewDidAppear(_ animated: Bool) {
    
        super.viewDidAppear(animated)
    
        let animationTransition = CATransition()
    
        animationTransition.duration = 2.0
    
        animationTransition.type = CATransitionType.push
        animationTransition.subtype = CATransitionSubtype.fromTop
    
        navigationController!.navigationBar.layer.add(animationTransition, forKey: "pushText")
    
        navigationItem.title = <<NEW TITLE TEXT>>
    }
    
  4. In the viewDidAppear(_ animated: Bool) method, write alternatives to the CATransition code suggested in the answers to the OP noted above (which only adds a separate title into the centre of the navigation bar and does not change the Large Title).

    override func viewDidAppear(_ animated: Bool) {
    
        super.viewDidAppear(animated)
    
        let animationTransition = CATransition()
    
        animationTransition.duration = 2.0
    
        animationTransition.type = CATransitionType.fade
    
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 200, height: 44))
        label.text = <<NEW TITLE TEXT>>
        navigationItem.titleView = label
    
        navigationItem.titleView!.layer.add(animationTransition, forKey: "fadeText")
    }
    
  5. I've also tried including CAMediaTimingFunction...

        animationTransition.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
    
  6. I've also tried calling setNeedsLayout and setNeedsDisplay...

        navigationItem.titleView?.setNeedsLayout()
        navigationController?.navigationBar.setNeedsDisplay()
    

For clarity:

  • the title text does change, but without animation;
  • the code is written in the viewDidAppear(_ animated:) method, because I want the user to see the transition;
  • the duration is long (2 seconds) for testing - I may reduce this if/when the animation is working;
  • Using the Inspectors in the storyboard, I've read through all the UITableView/Controller and UINavigationController settings to see whether I may have missed something.

Any suggestions?


Solution

  • So I've worked it out.

    I had to find the specific layer within the UINavigationBar's subviews that contained the title text, then animate that layer. The results are exactly what I wanted.

    Here's my answer (Swift 5 iOS 12)...

    override func viewDidAppear(_ animated: Bool) {
    
        super.viewDidAppear(animated)
    
        if (newTitle ?? "").isEmpty == false { // only proceed with a valid value for newTitle.
    
            // CATransition code
            let titleAnimation = CATransition()
            titleAnimation.duration = 0.5
            titleAnimation.type = CATransitionType.push
            titleAnimation.subtype = CATransitionSubtype.fromRight
            titleAnimation.timingFunction = CAMediaTimingFunction.init(name: CAMediaTimingFunctionName.easeInEaseOut)
    
            // this is a detail view controller, so we must grab the reference
            // to the parent view controller's navigation controller
            // then cycle through until we find the title labels.
            if let subviews = parent?.navigationController?.navigationBar.subviews {
    
                for navigationItem in subviews {
    
                    for itemSubView in navigationItem.subviews {
    
                        if let largeLabel = itemSubView as? UILabel {
    
                            largeLabel.layer.add(titleAnimation, forKey: "changeTitle")
                        }
                    }
                }
            }
            // finally set the title
            navigationItem.title = newTitle
       }
    

    Note: there is no need to import QuartzCore.

    GIF of iOS Simulator illustrating CATransition push fromRight

    ...and here's the process I went through to identify what I had to change...

    This SO Q&A How to set multi line Large title in navigation bar? ( New feature of iOS 11) helped me identify the process detailed below, so thanks in particular to the original post and the answer by @Krunal .

    Using the same code to cycle through the UINavigationBar's subviews (as above), I used a print to terminal to identify the various UINavigationItems and their subviews.

            counter = 0
    
            if let subviews = parent?.navigationController?.navigationBar.subviews {
    
                for navigationItem in subviews {
    
                    print("____\(navigationItem)")
    
                    for itemSubView in navigationItem.subviews {
    
                        counter += 1
    
                        print("_______\(itemSubView)")
                    }
                }
            }
            print("COUNTER: \(counter)")
    

    this code yielded the following prints in terminal (for iPhone 8 Plus running iOS 12.2 in simulator)...

     ____<_UIBarBackground: 0x7f922740c000; frame = (0 -20; 414 116); userInteractionEnabled = NO; layer = <CALayer: 0x6000026ce1c0>>
     _______<UIImageView: 0x7f922740c9c0; frame = (0 116; 414 0.333333); userInteractionEnabled = NO; layer = <CALayer: 0x6000026ce7c0>>
     _______<UIVisualEffectView: 0x7f922740cbf0; frame = (0 0; 414 116); layer = <CALayer: 0x6000026ce880>>
     ____<_UINavigationBarLargeTitleView: 0x7f922740f390; frame = (0 44; 414 52); clipsToBounds = YES; layer = <CALayer: 0x6000026cd840>>
     _______<UILabel: 0x7f9227499fe0; frame = (20.1667 3.66667; 206.333 40.6667); text = 'Event Details'; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x6000005bf250>>
     ____<_UINavigationBarContentView: 0x7f922740d660; frame = (0 0; 414 44); clipsToBounds = YES; layer = <CALayer: 0x6000026cea00>>
     _______<_UIButtonBarStackView: 0x7f92274984a0; frame = (302 0; 100 44); layer = <CALayer: 0x60000261cc60>>
     _______<_UIButtonBarButton: 0x7f922749a5c0; frame = (0 0; 82.3333 44); layer = <CALayer: 0x60000261ee60>>
     _______<UILabel: 0x7f922749a2d0; frame = (155 11.6667; 104.333 20.3333); text = 'Event Details'; alpha = 0; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x6000005bfc50>>
     ____<_UINavigationBarModernPromptView: 0x7f9227602db0; frame = (0 0; 0 50); alpha = 0; hidden = YES; layer = <CALayer: 0x6000026e4600>>
     COUNTER: 2
    

    I've actually applied the animation twice - to the UILabel layer within _UINavigationBarLargeTitleView and the UILabel layer within _UINavigationBarContentView. This does not seem to matter however because, when the large title first appears, the label within content view (which I assume is for the "old style" title in the navigation bar when the large title is scrolled off screen) is hidden on viewDidAppear.

    Incidentally, if you drop in the following two lines, you'll also have multi-line large titles:

     largeLabel.numberOfLines = 0
     largeLabel.lineBreakMode = .byWordWrapping 
    

    BUT, I've not yet figured out how to animate the increase in size of the large title frame, so a change to two or more lines is immediate and IMHO ruins the animation of the title change.

    Not yet tested on device, but does seem to work OK for both iPhone and iPad sims.

    If you find any bugs, let me know and I'll update my answer.