How to Customize the Appearance of the Navigation Bar in Your iOS App

We once worked on an app that enabled users to choose profile colors and in which the color of the User Details navigation bar depended on the color of the user’s avatar. 

During development, we experienced a lot of issues such as the need to make the status bar readable on the navigation bar’s background. What’s more, we had to make a toolbar in the app to be visible only for your friends who are also the app users, which was also challenging.

How can you implement color changing features?

A native approach was to perform the navigation controller appearance configuration in the UIViewController.viewWillAppear. But with this implementation, we soon found our view controllers full of unnecessary and even odd information, such as names of the navigation bar’s background images and the toolbar’s tint color alpha. Moreover, this logic was duplicated in several independent classes.
 
Given this nuisance, we decided to refactor UIViewController.viewWillAppear and turn it into a small and handy tool that could free view controllers from repeated imperative appearance configurations. We wanted our implementation to have a UIKit-like declarative style in line with UIViewController.preferredStatusBarStyle. Using this logic, view controllers would be asked a number of questions or would have to meet specific requirements that tell them how to behave.

How AppearanceNavigationController works

To achieve declarative behavior, we need to use a UINavigationControllerDelegate to handle push and pop between the view controllers. In navigationController(_:willShowViewController:animated:), we need to ask displayed view controller the following questions:

  • Do we need to display a navigation bar?
  • If yes, what color should the navigation bar be?
  • Do we need to display a toolbar?
  • If yes, what color should the toolbar be?
  • What style should the status bar be?

 

 Let’s turn this declarative behavior into a Swift struct:

public struct Appearance: Equatable {
    
    public struct Bar: Equatable {
        
        var style: UIBarStyle = .default
        var backgroundColor = UIColor(red: 234 / 255, green: 46 / 255, blue: 73 / 255, alpha: 1)
        var tintColor = UIColor.white
        var barTintColor: UIColor?
    }
    
    var statusBarStyle: UIStatusBarStyle = .default
    var navigationBar = Bar()
    var toolbar = Bar()
}

You may have noticed that the flags for navigation bar and toolbar visibility are missing, and here’s why: most of the time, we don’t care about how the bars appear. All we need to do is hide them. Therefore, we keep them separate.

We ask our view controller about the preferred appearance as follows:

public protocol NavigationControllerAppearanceContext: class {
    
    func prefersBarHidden(for navigationController: UINavigationController) -> Bool
    func prefersToolbarHidden(for navigationController: UINavigationController) -> Bool
    func preferredAppearance(for navigationController: UINavigationController) -> Appearance?    
}

Since not every UIViewController will configure the appearance, we’re not going to extend UIViewController with AppearanceNavigationControllerContext. Instead, let’s provide a default implementation using a protocol extension so any entity that conforms to the NavigationControllerAppearanceContext can implement only the methods they’re interested in:

extension NavigationControllerAppearanceContext {
    
    func prefersBarHidden(for navigationController: UINavigationController) -> Bool {
        return false
    }
    
    func prefersToolbarHidden(for navigationController: UINavigationController) -> Bool {
        return true
    }
    
    func preferredAppearance(for navigationController: UINavigationController) -> Appearance? {
        return nil
    }
}

As you may have noticed, preferredNavigationControllerAppearance allows us to return nil, which is useful to interpret as “Okay, this controller doesn’t want to affect the current appearance.”

Now let’s implement the basics of our navigation controller:

public class AppearanceNavigationController: UINavigationController, UINavigationControllerDelegate {
    
    public required init?(coder decoder: NSCoder) {
        super.init(coder: decoder)
        
        delegate = self
    }
    
    override public init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        
        delegate = self
    }
    
    override public init(rootViewController: UIViewController) {
        super.init(rootViewController: rootViewController)
        
        delegate = self
    }
    
    // MARK: - UINavigationControllerDelegate
    
    public func navigationController(
        _ navigationController: UINavigationController,
        willShow viewController: UIViewController, animated: Bool
    ) {
        guard let appearanceContext = viewController as? NavigationControllerAppearanceContext else {
            return
        }
        setNavigationBarHidden(appearanceContext.prefersBarHidden(for: self), animated: animated)
        setNavigationBarHidden(appearanceContext.prefersBarHidden(for: self), animated: animated)
        setToolbarHidden(appearanceContext.prefersToolbarHidden(for: self), animated: animated)
        applyAppearance(appearance: appearanceContext.preferredAppearance(for: self), animated: animated)
    }
 
    
    // mark: - Appearance Applying
    
    private var appliedAppearance: Appearance?
    
    private func applyAppearance(appearance: Appearance?, animated: Bool) {
        // apply
    }
}

Read also: The Fastest Way to Import Data from JSON to Core Data

Configuring the appearance

Now it’s time to implement the appearance by applying the above-mentioned color details: 

private func applyAppearance(appearance: Appearance?, animated: Bool) {
    if let appearance = appearance {
        let navigationBar = navigationController.navigationBar
        let toolbar = navigationController.toolbar
        
        if !navigationController.isNavigationBarHidden {
            let background = ImageRenderer.renderImageOfColor(color: 
                             appearance.navigationBar.backgroundColor)
            navigationBar.setBackgroundImage(background, for: .default)
            navigationBar.tintColor = appearance.navigationBar.tintColor
            navigationBar.barTintColor = appearance.navigationBar.barTintColor
            navigationBar.titleTextAttributes = [
                NSAttributedString.Key.foregroundColor: appearance.navigationBar.tintColor
            ]
        }
            
        if !navigationController.isToolbarHidden {
            toolbar?.setBackgroundImage(
                ImageRenderer.renderImageOfColor(color: appearance.toolbar.backgroundColor),
                forToolbarPosition: .any,
                barMetrics: .default
            )
            toolbar?.tintColor = appearance.toolbar.tintColor
            toolbar?.barTintColor = appearance.toolbar.barTintColor
        }
    }
}

If the View Controller’s appearance isn’t nil, pay no attention. Code that applies the appearance is fairly simple, except for ImageRenderer.renderImageOfColor (color), which returns a colored image with a 1×1 pixel size.

Configuring the status bar

Note that the status bar style is tied to the Appearance, and not via UIViewController.preferredStatusBarStyle. This is because status bar visibility depends on the navigation bar’s brightness. So we decided to keep this knowledge about colors in a single place instead of putting it in two separate places.

private var appliedAppearance: Appearance?
    
private func applyAppearance(appearance: Appearance?, animated: Bool) {
    if appearance != nil && appliedAppearance != appearance {
        appliedAppearance = appearance
            
// rest of the code
 
        setNeedsStatusBarAppearanceUpdate()
    }
}

// mark: - Status Bar

public override var preferredStatusBarStyle: UIStatusBarStyle {
    appliedAppearance?.statusBarStyle ?? super.preferredStatusBarStyle
}
    
public override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
        appliedAppearance != nil ? .fade : super.preferredStatusBarUpdateAnimation
}

Since we’re going to use UIKit’s preferred way of changing the appearance of the Status Bar, the applied Appearance needs to be preserved. Also, if no appearance is applied, we switch to the default super’s implementation.

Read also: 8 Handy Tips to Succeed in Core Data Migration

Updating the appearance

Obviously, the view controller appearance may change during the controller’s life cycle. In order to update the view controller, let’s add a UIKit-like method called NavigationControllerAppearanceContext:

public protocol NavigationControllerAppearanceContext: class {
    
     // rest of the interface
    
    func setNeedsUpdateNavigationControllerAppearance()
}

extension NavigationControllerAppearanceContext {
    
    // rest of the default implementation
    
    func setNeedsUpdateNavigationControllerAppearance() {
        if let viewController = self as? UIViewController,
            let navigationController = viewController.navigationController as? AppearanceNavigationController {
            navigationController.updateAppearanceForViewController(viewController: viewController)
        }
    }
}

Here’s a corresponding implementation in the AppearanceNavigationController:

func updateAppearance(for viewController: UIViewController) {
    if let context = viewController as? NavigationControllerAppearanceContext,
        viewController == topViewController && transitionCoordinator == nil {
        setNavigationBarHidden(context.prefersBarHidden(for: self), animated: true)
        setToolbarHidden(context.prefersToolbarHidden(for: self), animated: true)
        applyAppearance(appearance: context.preferredAppearance(for: self), animated: true)
    }
}
 

public func updateAppearance() {
    if let top = topViewController {
        updateAppearance(for: top)
    }
}

From this point, any AppearanceNavigationControllerContext can ask its container to rerun the appearance configuration in case something is changed. Through various checks like viewController == topViewController and transitionCoordinator() == nil, we disallow appearance changes invoked by an invisible view controller or during the interactive pop gesture.

Usage

We’re done with the implementation. Now any view controller can define an appearance context, change the appearance of UIViewController in the middle of the life cycle, and so on:

class ContentViewController: UIViewController, NavigationControllerAppearanceContext {
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        
        navigationItem.rightBarButtonItem = editButtonItem
    }
    
    var appearance: Appearance? {
        didSet {
            setNeedsUpdateNavigationControllerAppearance()
        }
    }
    
    // mark: - Actions
    
    override func setEditing(_ editing: Bool, animated: Bool) {
        super.setEditing(editing, animated: animated)
        
        setNeedsUpdateNavigationControllerAppearance()
    }
    
    // mark: - AppearanceNavigationControllerContent
    
    func prefersToolbarHidden(for navigationController: UINavigationController) -> Bool {
        // hide toolbar during editing
        return isEditing
    }
    
    func preferredAppearance(for navigationController: UINavigationController) -> Appearance? {
        // inverse navigation bar color and status bar during editing
        return isEditing ? appearance?.inverse() : appearance
    }
}

Read also: Swift vs Objective-C: Comparing Swift 3 and Swift 5 vs Objective-C

Gathering appearance data

Now we can gather all the appearance configurations as a category with common configurations, thus eliminating code duplication as in the native solution:

extension Appearance {       
    static let lightAppearance: Appearance = {
        var value = Appearance()
        
        value.navigationBar.backgroundColor = .lightGray
        value.navigationBar.tintColor = .white
        value.statusBarStyle = .lightContent
        
        return value
    }()
}

Customizing the appearance 

To make the animation more customizable, let’s wrap it into AppearanceApplyingStrategy:

public class AppearanceApplyingStrategy {
    
    public func apply(appearance: Appearance?, toNavigationController navigationController: UINavigationController, animated: Bool) {
    
}

Now let’s connect the strategy to the AppearanceNavigationController:

private func applyAppearance(appearance: Appearance?, animated: Bool) {
        if appearance != nil && appliedAppearance != appearance {
            appliedAppearance = appearance
            
            appearanceApplyingStrategy.apply(appearance: appearance, toNavigationController: self, animated: animated)
            setNeedsStatusBarAppearanceUpdate()
        }
    }

    public var appearanceApplyingStrategy = AppearanceApplyingStrategy() {
        didSet {
            applyAppearance(appearance: appliedAppearance, animated: false)
        }
    }

In summary, we don't pretend this solution is a silver bullet. However, with this short and simple implementation, we have: 

  • simplified the navigation controller’s appearance configuration
  • reduced code duplication by defining extensions to the Appearance
  • made our code more UIKit-like
  • reduced the number of small and annoying bugs (Example: We eliminated an accidental status bar style change by MailComposer.)

 

You're welcome to find our project here.

3.9/ 5.0
Article rating
46
Reviews
Remember those Facebook reactions? Well, we aren't Facebook but we love reactions too. They can give us valuable insights on how to improve what we're doing. Would you tell us how you feel about this article?
Want to see what else we can do?

Check out our tech stack

Jump to the link

We use cookies to personalize our service and to improve your experience on the website and its subdomains. We also use this information for analytics.