Creating our own Navigation Titles
In the previous post, I briefly explored the origins of navigation titles and their limitations, particularly in terms of customisation.
As promised, this post will cover a method for building a customised NavigationStack
and .navigationTitle
, along with a few additional UI enhancements.
What We'll Create
By the end of this post, we will have crafted a custom .navigationTitle
element, a coloured background, and a matching navigation bar.
Attempting the old way
Before diving into our solution, let's discuss the traditional approach using UINavigationBarAppearance()
and why it falls short.
Consider the following code as the old method:
This code is not recommended due to the limitations discussed below.
struct ContentView: View {
@StateObject private var appSettings: AppSettings = .init()
var body: some View {
NavigationStack {
List {
ForEach(1..<10, id: \.self) { index in
NavigationLink("Index item number: \(index)") {
Text("Index item number: \(index)")
}
}
}
.navigationTitle("My title")
.background(appSettings.tint.opacity(0.1))
.toolbar {
ColorPicker("", selection: $appSettings.tint)
}
}
.scrollContentBackground(.hidden)
.toolbarBackground(appSettings.tint.opacity(0.1))
}
}
final class AppSettings: ObservableObject {
@Published var tint: Color = .red {
didSet { updateNavigationBarAppearance() }
}
init() {
updateNavigationBarAppearance()
}
private func updateNavigationBarAppearance() {
let appearance = UINavigationBarAppearance()
appearance.backgroundColor = UIColor(tint.opacity(0.1))
appearance.largeTitleTextAttributes = [
.font: UIFont.preferredFont(forTextStyle: .largeTitle).roundedBold,
.foregroundColor: UIColor(tint).withAlphaComponent(0.9)
]
appearance.titleTextAttributes = [
.font: UIFont.preferredFont(forTextStyle: .headline).roundedBold,
.foregroundColor: UIColor(tint).withAlphaComponent(0.9)
]
let navBarAppearance = UINavigationBar.appearance()
navBarAppearance.standardAppearance = appearance
navBarAppearance.compactAppearance = appearance
navBarAppearance.scrollEdgeAppearance = appearance
}
}
extension UIFont {
var roundedBold: UIFont {
guard let descriptor = fontDescriptor
.withDesign(.rounded)?
.withSymbolicTraits(.traitBold) else { return self }
return UIFont(descriptor: descriptor, size: pointSize)
}
}
This approach seems viable until interaction begins.
Issue with Background in Navigation Bar
The main issue here is the persistent shadow or line adjacent to the List
element, despite having a matching background for the navigation bar.
Although removing the shadow from the navigation bar in the updateNavigationBarAppearance()
method is possible, it also affects the compact appearance.
...
appearance.shadowColor = .clear
...
Transparent navigation bar
Making the navigation bar transparent is a workaround for the shadow issue.
This can be achieved with:
...
appearance.configureWithTransparentBackground()
...
However, this approach has its own set of drawbacks, especially when scrolling.
Non-updating colour
A significant limitation of the above method is that updating the color picker does not affect the title color.
Despite this, the background color does update, which would be beneficial for app-wide tint or theme adjustments.
A Fix Without Customisation
To make the navigation bar color work, one solution is to rearrange where the modifiers are placed from the NavigationStack
to the List
:
// Original code
var body: some View {
NavigationStack {
List { ... }
.background(appSettings.tint.opacity(0.1))
}
.scrollContentBackground(.hidden)
.toolbarBackground(appSettings.tint.opacity(0.1))
}
// Updated code
var body: some View {
NavigationStack {
List { ... }
.background(appSettings.tint.opacity(0.1))
.scrollContentBackground(.hidden)
.toolbarBackground(appSettings.tint.opacity(0.1))
}
}
However, this comes at the cost of losing customization in the .navigationTitle
, rendering the initial setup moot.
A Solution
Now, let's explore a solution that addresses these issues, followed by a discussion on its drawbacks.
final class AppSettings: ObservableObject {
@Published var showingScrolledTitle = false // 1
@Published var tint: Color = .red
func scrollDetector(topInsets: CGFloat) -> some View { // 2
GeometryReader { proxy in
let minY = proxy.frame(in: .global).minY
let isUnderToolbar = minY - topInsets < 0
Color.clear
.onChange(of: isUnderToolbar) { _, newVal in
self.showingScrolledTitle = newVal
}
}
}
}
struct ContentView: View {
@StateObject private var appSettings: AppSettings = .init()
var body: some View {
GeometryReader { outer in // 3
NavigationStack {
List {
Section {
ForEach(1..<10, id: \.self) { index in
NavigationLink("Index item number: \(index)") {
Text("Index item number: \(index)")
}
}
} header: { // 4
Text("My title")
.font(.largeTitle)
.fontDesign(.monospaced)
.fontWeight(.heavy)
.textCase(nil)
.foregroundStyle(appSettings.tint)
.listRowInsets(.init(top: 4, leading: 0, bottom: 8, trailing: 0))
.background { appSettings.scrollDetector(topInsets: outer.safeAreaInsets.top) }
}
}
.toolbar {
ToolbarItem(placement: .principal) { // 5
Text("My title")
.font(.headline)
.fontDesign(.monospaced)
.fontWeight(.heavy)
.foregroundStyle(appSettings.tint)
.opacity(appSettings.showingScrolledTitle ? 1 : 0)
.animation(.easeInOut, value: appSettings.showingScrolledTitle)
}
ToolbarItem(placement: .topBarTrailing) {
ColorPicker("", selection: $appSettings.tint)
}
}
.navigationTitle("My title")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(appSettings.tint.opacity(0.1))
.toolbarBackground(appSettings.tint.opacity(0.1))
}
}
}
}
Breakdown of implementation
Point 1 - `showScrolledTitle`
This boolean variable tracks the visibility of the .inline
title. It is part of the AppSettings
to allow global access and reuse.
Point 2 - `scrollDetector`
The scrollDetector
detects when the .inline
toolbar height is surpassed, toggling showScrolledTitle
to true
. It uses a Color.clear
background in the Section(header:)
to monitor its global position.
Point 3 - `GeometryReader`
In order to allow for the safe area insets at the top, it would be good if the scrollDetector
could refer to the coordinate space of the List
.
However, I found that it is not possible to refer to the coordinate space of a container from within a list Section
.
As a workaround here, the scrollDetector
uses the global coordinate space and it is supplied with the size of the top safe area insets. These are measured using another GeometryReader
surrounding the main content.
Point 4 - `Section(header:)`
To easily customise a look and feel of a .largeTitle
we can set the header
of the Section
to be whatever we want.
Doing so allows the view to appear as if it was using the in-build UI but it is completely customisable.
Point 5 - `.principal` toolbar
The .inline
heading can simply be defined as a ToolbarItem
with placement .principal
, this supports full styling.
Downsides and Considerations
Multiple GeometryReader
The solution to the customisation is using a GeometryReader
to detect the scroll position of our custom heading title, and when to convert or display it in the .inline
formatting.
But since this is a full system theming solution, it is important to note that you're going to need a GeometryReader
on every view. Depending on what your app is, and how complex it can cause a severe over head for something as small as a UI element.
Non-global solution
Tying into the GeometryReader
note, is that to make your entire app fit with the same UI is that you're going to have to utilise this customisation on every screen.
Opposed to injecting the UINavigationBarAppearance()
changes into the AppDelegate
or init
(ignoring the live customisations) where it would flow on for every screen no matter where in the stack.
Unable to modularise
One of the great aspects in the Swift language is the ability to modularise or even convert elements into modifiers to reduce repetitive code, and maintain an "update once" approach.
This solution doesn't lend itself to this mentality, with the only thing we really can do is group a few existing modifiers into one:
// Old modifiers
{ ... }
.navigationTitle("My title")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.foregroundStyle(appSettings.tint)
.background(appSettings.tint.opacity(0.1))
.toolbarBackground(appSettings.tint.opacity(0.1))
// Grouped modifier
{ ... }
.themedNavigation("My title", color: appSettings.tint)
But that's realistically just calling the following modifier:
struct ThemedNavigation: ViewModifier {
let title: LocalizedStringKey
let theme: Color
func body(content: Content) -> some View {
content.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.foregroundStyle(theme)
.background(theme.opacity(0.1))
.toolbarBackground(theme.opacity(0.1))
}
}
extension View {
func themedNavigation(_ title: LocalizedStringKey, theme: Color) -> some View {
self.modifier(ThemedNavigation(title: title, theme: theme))
}
}
Is it really much of a solution? Well it helps maintain the code and cleanliness, but the rest of the customisation is still stuck in manual setup.
The positives
Despite the challenges, this approach offers unparalleled customisation, allowing for distinctive UI elements that stand out.
Different views
The ability to independently customise the section header, the .primary
, and the actual .navigationTitle
offers flexibility in design.
Standout UI
This method supports creating a unified app appearance that differs from the default look, enhancing the overall user experience.
Hopes for the future
Looking forward, it's desirable for future iOS versions to offer more straightforward ways to customise .navigationTitle
elements, reducing the need for complex workarounds.
I think we're already on a trajectory to this solution with the use of the Text
element being allowed to be passed in, but it would be nice to customise it without being stripped out:
{ ... }
.navigationTitle(
Text("My title")
.font(.largeTitle)
.fontDesign(.monospaced)
.fontWeight(.heavy)
.foregroundStyle(appSettings.tint)
)
References
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