Search code examples
iosswiftswiftuiswiftui-navigationstack

Change NavigationStack title font, tint, and background in SwiftUI


I am updating the UI of an iOS app, now targeting iOS 16 and above.

In the previous version of the design I was changing the .navigationTitle font, style, and colour using something like this code:

final class AppSettings: ObservableObject {
  @Published var tint: Color = .red {
    didSet { updateNavigationBarAppearance() }
  }

  init() {
    updateNavigationBarAppearance()
  }

  private func updateNavigationBarAppearance() {
    let appearance = UINavigationBarAppearance()
    appearance.configureWithTransparentBackground()
    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)
  }
}

That gave me something that looks like this:

Rounded navigation title font and colour

But had the bug of this occurring on scrolled views as we were using the appearance.configureWithTransparentBackground(), and transparency doesn't work in the UINavigationBar.appearance().backgroundColor configuration.

Scrolled content overlapped

With the OS version bump, we can now take advantage of using .toolbarBackground which has fixed the transparency issue when scrolling into the nav area.

However, when that modifier is implemented it affects the font of the navigation title, returning it to the default serif face and black/white colour.

Does anyone know of a way to customise the title while also using the .toolbarBackground modifier?

Though the .navigationTitle accepts a Text element, it seems you cannot customise it, for example:

  .navigationTitle(
    Text("Today")
      .font(.system(.largeTitle, design: .rounded, weight: .black))
  )

As you get this warning in Xcode:


Solution

  • I couldn't find a way to change the font or color of the main navigation title. However, a workaround is to show your own title. You can then style it any way you like.

    • In your case, the title can be shown as a header to a List Section, so that it scrolls with the List.

    • A GeometryReader in the background of the substitute title can be used to detect when the title has been scrolled. This in turn can be used to control the visibility (opacity) of the secondary heading.

    • The secondary heading can simply be defined as a ToolbarItem with placement .principal, this supports full styling.

    • If you want the navigation title to appear as the back link on nested views then it can be set in the usual way but with display mode .inline. It seems the toolbar item with placement .principal takes priority over the inline title, so setting a title in this way does not impact the custom styling.

    • In order to allow for the safe area insets at the top, it would be good if the scroll detector 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 scroll detector 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.

    @State private var showingScrolledTitle = false
    
    private func scrollDetector(topInsets: CGFloat) -> some View {
        GeometryReader { proxy in
            let minY = proxy.frame(in: .global).minY
            let isUnderToolbar = minY - topInsets < 0
            Color.clear
                // pre iOS 17: .onChange(of: isUnderToolbar) { newVal in
                .onChange(of: isUnderToolbar) { _, newVal in
                    showingScrolledTitle = newVal
                }
        }
    }
    
    var body: some View {
        GeometryReader { outer in
            NavigationStack {
                List {
                    Section {
                        ForEach(1...5, id: \.self) { val in
                            NavigationLink("List item \(val)") {
                                Text("List item \(val)")
                            }
                        }
                    } header: {
                        Text("Today")
                            .font(.custom("Chalkboard SE", size: 36))
                            .textCase(nil)
                            .bold()
                            .listRowInsets(.init(top: 4, leading: 0, bottom: 8, trailing: 0))
                            .background {
                                scrollDetector(topInsets: outer.safeAreaInsets.top)
                            }
                    }
                }
                .toolbar {
                    ToolbarItem(placement: .topBarLeading) {
                        Image(systemName: "gearshape.fill")
                    }
                    ToolbarItem(placement: .principal) {
                        Text("Today")
                            .font(.custom("Chalkboard SE", size: 18))
                            .bold()
                            .opacity(showingScrolledTitle ? 1 : 0)
                            .animation(.easeInOut, value: showingScrolledTitle)
                    }
                    ToolbarItem(placement: .topBarTrailing) {
                        Image(systemName: "calendar")
                            .padding(.trailing, 20)
                    }
                    ToolbarItem(placement: .topBarTrailing) {
                        Image(systemName: "plus.circle.fill")
                    }
                }
                .navigationTitle("Today")
                .navigationBarTitleDisplayMode(.inline)
                .scrollContentBackground(.hidden)
                .foregroundStyle(.indigo)
                .background(.indigo.opacity(0.1))
                .toolbarBackground(.indigo.opacity(0.1))
            }
        }
    }
    

    Animation