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.
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.
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.
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
If you found this article helpful, you can keep the ideas flowing by supporting me. Buy me a coffee or check out my apps to help me create more content like this!
Coffee Check out my apps