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.

Custom navigation styling
Custom navigation styling

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:

Important

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.

Shadow line in Navigation Bar
Shadow line in 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
...
No shadow in Navigation Bar
No shadow in Navigation Bar
No division in scrolled Navigation Bar
No division in scrolled Navigation Bar

Transparent navigation bar

Making the navigation bar transparent is a workaround for the shadow issue.

Transparent Navigation Bar
Transparent Navigation Bar

This can be achieved with:

...
appearance.configureWithTransparentBackground()
...

However, this approach has its own set of drawbacks, especially when scrolling.

Issue with scrolled view and transparent bar
Issue with scrolled view and transparent bar

Non-updating colour

A significant limitation of the above method is that updating the color picker does not affect the title color.

Navigation Title colour not updating
Navigation Title colour not updating

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))
    }
}
Styled Navigation Title
Styled Navigation Title
Lost customisation in Navigation Title
Lost customisation in Navigation Title

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


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