An Overview of Navigation Title


Many developers, especially novices, appreciate the approachable nature of SwiftUI. While UIKit and Objective-C veterans might view it as restrictive, its simplicity is a boon for learning.

One prime example is the NavigationStack - you wrap your code, add elements, and the system handles back buttons, titles, and transitions seamlessly. No need for complex PreferenceKeys or manual management.

Drawback of Uniformity

However, this ease of use comes at a cost: standardisation.

Apple Settings App
Apple Settings App
PT Near Me app
PT Near Me app
Apple Settings App
Apple Settings App
PT Near Me app
PT Near Me app

No matter where you look, within Apple apps or from developers all of the navigation layouts are the same.

Many NavigationStack apps feel indistinguishable, with the same basic navigation layout. Some leverage Flutter or remain in UIKit for more control, but often, developers either stick to the built-in UI or resort to workarounds like wrapping .largeTitle in an HStack within a ScrollView.

A Brief History of Navigation Title Management

While SwiftUI offers a seamless navigation framework, understanding the evolution of .navigationTitleBar and .navigationTitle can shed light on design choices and provide better context for customisation strategies.

The Era of .navigationBarTitle (Pre-iOS 14)

This modifier reigned supreme, accepting only LocalizedStringKey for titles, limiting dynamic text options.

Workarounds like computed properties were necessary for conditional titles based on app state.

var navigationTitleText: LocalizedStringKey {
    if isSearching {
        return "Searching..."
    }
    if listObjects.isEmpty {
        return "No items"
    } else {
        return "List items"
    }
}

...
  .navigationBarTitle(navigationTitleText)

The Arrival of .navigationTitle (iOS 14 and Later)

This new modifier embraced the future, expanding to accept not just keys but also Text elements as titles.

The excitement was short-lived as Text support turned out to be strictly for localisation purposes, with customisation options still restricted.

var navigationTitleText: LocalizedStringKey {
    if isSearching {
        return "Searching..."
    }
    if listObjects.isEmpty {
        return "No items"
    } else {
        return "List items"
    }
}

...
  .navigationBarTitle(Text(navigationTitleText))

The Binding Breakthrough (iOS 16)

This update introduced Binding support for .navigationTitle, allowing dynamic adjustments based on app state without the need for computed properties.

While an improvement, customisation opportunities remained limited.

@State private var navigationTitleText: LocalizedStringKey = "No items"

func updateNavigationTitle() {
    if isSearching {
        navigationTitleText = "Searching..."
    }
    if listObjects.isEmpty {
        navigationTitleText = "No items"
    } else {
        navigationTitleText = "List items"
    }
}

...
  .navigationTitle(navigationTitleText)

Beyond the Built-in Options: Exploring Customisation

With the current state of .navigationTitle, developers often turn to other avenues for advanced customisation.

The main method is overriding UINavigationBarAppearance. This approach grants granular control but feels incongruent with SwiftUI's declarative style.

// Not recommended (see below section)
let appearance = UINavigationBar.appearance()
// ... customisation code ...

Beyond Basic Customisation

While overriding UINavigationBarAppearance (or the UINavigationBar.appearance()) directly can achieve custom navigation styles, to me it feels out of place in SwiftUI's declarative spirit.

Typically, the way you'd do this would be with code similar to this:

@main
struct MyApp: App {
    WindowGroup { ... }

    init() {
        let appearance = UINavigationBar.appearance()
        var titleFont = UIFont.preferredFont(forTextStyle: .largeTitle)
        var inlineFont = UIFont.preferredFont(forTextStyle: .headline)

        titleFont = UIFont(
            descriptor: (titleFont
                            .fontDescriptor
                            .withDesign(.monospaced)?
                            .withSymbolicTraits(.traitBold)
                        ).unsafelyUnwrapped,
            size: titleFont.pointSize
        )
        inlineFont = UIFont(
            descriptor: (inlineFont
                            .fontDescriptor
                            .withDesign(.monospaced)?
                            .withSymbolicTraits(.traitBold)
            ).unsafelyUnwrapped,
            size: inlineFont.pointSize
        )
        appearance.largeTitleTextAttributes = [.font: titleFont, .kern: -0.8]
        appearance.titleTextAttributes = [.font: inlineFont, .kern: -0.5]
    }
}

This is how I did it in my app Serial Box app when I wanted the entire UI to be monospaced.

Custom Modifiers to the Rescue

You can embrace the power of custom SwiftUI modifiers, encapsulating common tasks, improving code clarity and organisation.

For instance, the code from your init to make all titles monospaced can be transformed into a reusable .navigationAppearance modifier, streamlining your code and keeping it well-structured.

struct NavigationAppearance: ViewModifier {
    init {
        let appearance = UINavigationBar.appearance()
        var titleFont = UIFont.preferredFont(forTextStyle: .largeTitle)
        var inlineFont = UIFont.preferredFont(forTextStyle: .headline)

        titleFont = UIFont(
            descriptor: (titleFont
                            .fontDescriptor
                            .withDesign(.monospaced)?
                            .withSymbolicTraits(.traitBold)
                        ).unsafelyUnwrapped,
            size: titleFont.pointSize
        )
        inlineFont = UIFont(
            descriptor: (inlineFont
                            .fontDescriptor
                            .withDesign(.monospaced)?
                            .withSymbolicTraits(.traitBold)
            ).unsafelyUnwrapped,
            size: inlineFont.pointSize
        )
        appearance.largeTitleTextAttributes = [.font: titleFont, .kern: -0.8]
        appearance.titleTextAttributes = [.font: inlineFont, .kern: -0.5]
    }

    /// Nothing to change, it's all in the init
    func body(content: Content) -> some View { content }
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                Text("Item 1")
                Text("Item 2")
            }
            .navigationTitle("Home")
            .modifier(NavigationAppearance())
        }
    }
}

For an alternative, more customisable view modifier have a watch of Stewart Lynch's video Navigation Bar Styling in SwiftUI and the source code.

Theme-Based Customisation

Take it a step further by employing themes for different screens.

Again, Stewart Lynch has a great video Switching Themes in SwiftUI demonstrating how to implement this elegantly.

If you can't tell

I really like the teaching style of Stewart Lynch. I think the way he explains processes is clear and very thought out.

Accessibility Matters

The biggest aspect that a lot of developers overlook - particularly beginners - is accessibility.

One thing to remember is to never compromise on accessibility when customising navigation. Usually when we start looking beyond the standardised navigation.

Losing out on styling we do gain Apple taking care of all the hard work of making navigation accessible.

By forcing us use a single modifier - .navigationTitle - all apps instantly become accessible.

It automatically adapts to the colour scheme, is read by VoiceOver, and strips out any sneaky modifiers which people might try injecting.

There are positives for those who need accessibility in their apps, and don't want to fight a UI, particularly one so integral to UI and UX flow.

Accessibility

It is important, and should be something you allocate time to when developing. There's not much else to say other than think about those users who love your app but can't use it because it actively excludes them.

A Step-by-Step Guide

In the next post, I'll provide a practical, step-by-step guide that builds upon this foundation. We'll explore:

  • Two amazing modifiers for scroll background (.scrollContentBackground) and toolbar background (.toolbarBackground), effectively replacing the previous code.
  • Complete customisation of font and largeTitle while maintaining accessibility.

By the end, you'll have the knowledge and tools to create unique navigation UIs that seamlessly integrate with SwiftUI.

Conclusion

Customising navigation in SwiftUI, without sacrificing accessibility, requires careful consideration and leveraging well-designed tools.

Stay tuned for the next post, where we dive deeper into creating truly unique and accessible navigation experiences in your SwiftUI apps!

Resources


Help me write and make more!

You can help me continue to provide valuable content like this. If you found this article helpful, please consider supporting me.

Coffee Pizza Dinner