Skip to content

SwiftUI: Create a Custom Navigation Bar

In this article we create a reusable navigation bar in SwiftUI: a centred or large title, leading and trailing buttons, your own colours, and a Back button that actually pops the screen. It's applied as a modifier, so it sits where the system bar would.

When to use it

A custom bar replaces the system navigation bar, and hiding the system bar also turns off swipe to go back. The Back button keeps working, but the swipe doesn’t.

That makes it a good fit for sheets, full-screen covers and the root screen of a stack, where there is nothing to swipe back to. If a pushed screen only needs a custom title, your own buttons or a background colour, the system bar can already do that and keeps the swipe:

List(items) { item in
    Text(item.name)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
    ToolbarItem(placement: .principal) {
        Text("Title")
            .font(.system(.headline, design: .rounded))
    }
    ToolbarItem(placement: .topBarTrailing) {
        Button("Save") {}
    }
}
.toolbarBackground(.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)

The component

One file, no dependencies, iOS 17 and later:

//
//  CustomNavigationBar.swift
//
//  A custom top bar for SwiftUI screens.
//
//  Reach for .navigationTitle + .toolbar first. Hiding the system bar also
//  switches off swipe-to-go-back, and there is no public API to get it back,
//  so this is best on sheets and on the root of a stack.
//
//  Article: https://dipinkrishna.com/blog/2023/08/swiftui-create-a-custom-navigation-bar/
//  Requires iOS 17.
//
 
import SwiftUI
 
enum CustomNavigationBarTitleDisplayMode {
    case inline
    case large
}
 
extension View {
    /// Hides the system navigation bar and pins a custom one to the top safe area.
    ///
    /// On a pushed screen the Back button still works, but swiping back does
    /// not - UIKit turns the gesture off whenever the bar is hidden.
    func customNavigationBar<Title: View, Leading: View, Trailing: View>(
        titleDisplayMode: CustomNavigationBarTitleDisplayMode = .inline,
        foreground: Color = .primary,
        background: some ShapeStyle = .bar,
        @ViewBuilder title: () -> Title,
        @ViewBuilder leading: () -> Leading = { NavigationBackButton() },
        @ViewBuilder trailing: () -> Trailing = { EmptyView() }
    ) -> some View {
        toolbar(.hidden, for: .navigationBar)
            .safeAreaInset(edge: .top, spacing: 0) {
                CustomNavigationBar(
                    titleDisplayMode: titleDisplayMode,
                    title: title,
                    leading: leading,
                    trailing: trailing
                )
                .foregroundStyle(foreground)
                .tint(foreground)
                .background(background, ignoresSafeAreaEdges: .top)
            }
    }
}
 
struct CustomNavigationBar<Title: View, Leading: View, Trailing: View>: View {
    var titleDisplayMode: CustomNavigationBarTitleDisplayMode = .inline
    @ViewBuilder var title: Title
    @ViewBuilder var leading: Leading
    @ViewBuilder var trailing: Trailing
 
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            BarLayout {
                HStack(spacing: 16) { leading }
                    .layoutValue(key: BarSlotKey.self, value: .leading)
 
                if titleDisplayMode == .inline {
                    title
                        .font(.headline)
                        .lineLimit(1)
                        .accessibilityAddTraits(.isHeader)
                        .layoutValue(key: BarSlotKey.self, value: .title)
                }
 
                HStack(spacing: 16) { trailing }
                    .layoutValue(key: BarSlotKey.self, value: .trailing)
            }
            .frame(minHeight: 44)
 
            if titleDisplayMode == .large {
                title
                    .font(.largeTitle.bold())
                    .accessibilityAddTraits(.isHeader)
                    .padding(.bottom, 8)
            }
        }
        .padding(.horizontal)
    }
}
 
/// Pops the current screen. Renders nothing on the root of a stack.
struct NavigationBackButton: View {
    @Environment(\.isPresented) private var isPresented
    @Environment(\.dismiss) private var dismiss
 
    var body: some View {
        if isPresented {
            Button {
                dismiss()
            } label: {
                // At large text sizes, drop the word and keep the chevron.
                ViewThatFits(in: .horizontal) {
                    HStack(spacing: 4) {
                        chevron
                        Text("Back").lineLimit(1)
                    }
                    chevron
                }
            }
            .accessibilityLabel("Back")
        }
    }
 
    private var chevron: some View {
        Image(systemName: "chevron.backward").fontWeight(.semibold)
    }
}
 
private enum BarSlot {
    case leading, title, trailing
}
 
private struct BarSlotKey: LayoutValueKey {
    static let defaultValue = BarSlot.title
}
 
/// Leading and trailing hug the edges. The title stays centred on the bar
/// and truncates before it can run into either side.
private struct BarLayout: Layout {
    var spacing: CGFloat = 8
 
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let bar = measure(width: proposal.width, subviews: subviews)
        return CGSize(width: bar.width, height: bar.height)
    }
 
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let bar = measure(width: bounds.width, subviews: subviews)
 
        for subview in subviews {
            let slot = subview[BarSlotKey.self]
            let (x, anchor): (CGFloat, UnitPoint) = switch slot {
            case .leading: (bounds.minX, .leading)
            case .title: (bounds.midX, .center)
            case .trailing: (bounds.maxX, .trailing)
            }
            subview.place(
                at: CGPoint(x: x, y: bounds.midY),
                anchor: anchor,
                proposal: ProposedViewSize(bar.sizes[slot] ?? .zero)
            )
        }
    }
 
    private func measure(width: CGFloat?, subviews: Subviews) -> (sizes: [BarSlot: CGSize], width: CGFloat, height: CGFloat) {
        func size(of slot: BarSlot, maxWidth: CGFloat) -> CGSize {
            subviews.first { $0[BarSlotKey.self] == slot }?
                .sizeThatFits(ProposedViewSize(width: maxWidth, height: nil)) ?? .zero
        }
 
        let available = width ?? .infinity
 
        // Each side may use up to a third of the bar...
        let leading = size(of: .leading, maxWidth: available / 3)
        let trailing = size(of: .trailing, maxWidth: available / 3)
 
        // ...and the title gets what is left after reserving the wider side on
        // *both* edges. That reservation is what keeps it centred.
        let side = max(leading.width, trailing.width)
        let title = size(of: .title, maxWidth: max(0, available - 2 * (side + spacing)))
 
        return (
            [.leading: leading, .title: title, .trailing: trailing],
            width ?? 2 * (side + spacing) + title.width,
            max(leading.height, title.height, trailing.height)
        )
    }
}
 
#Preview("Sheet header") {
    Text("Behind the sheet")
        .sheet(isPresented: .constant(true)) {
            List(1...30, id: \.self) { row in
                Text("Row \(row)")
            }
            .customNavigationBar(
                titleDisplayMode: .large,
                foreground: .white,
                background: .green
            ) {
                Text("Title")
            } leading: {
                EmptyView()
            } trailing: {
                Button("Done") {}
            }
        }
}

How it works

It’s a modifier. customNavigationBar hides the system bar and pins the custom one with .safeAreaInset(edge: .top, spacing: 0). The screen’s content is laid out below the bar and scrolls underneath it, the same as with the system bar.

The background reaches the top of the screen. .background(_:ignoresSafeAreaEdges: .top) fills in behind the status bar. The default background is the .bar material the system uses, and the default foreground is .primary.

The slots are generic. Title, leading and trailing are @ViewBuilder closures, so any view goes in them without AnyView. Pass EmptyView() for a slot you don’t want.

The Back button knows where it is. NavigationBackButton reads @Environment(\.isPresented) and calls dismiss(). It pops a pushed screen and draws nothing on the root of a stack. At large text sizes ViewThatFits drops the word and keeps the chevron.

The title stays centred. BarLayout is a custom Layout. Each side gets at most a third of the width, and the title gets what’s left after the wider side is reserved on both edges. A short button on one side and a long one on the other don’t pull the title off centre, and a long title truncates instead of running into the buttons.

The height follows the text. The bar is at least 44 points tall and grows with larger text sizes.

The custom bar: inline on black, a large title, and a sheet header

Using it

An inline title on a black bar, with a Save button on the right and the default Back button on the left:

EditorView()
    .customNavigationBar(foreground: .white, background: .black) {
        Text("Title")
    } trailing: {
        Button("Save") {}
    }

A large title under the Back button:

ProfileView()
    .customNavigationBar(
        titleDisplayMode: .large,
        foreground: .white,
        background: .blue
    ) {
        Text("Title")
    }

A sheet header with a close button. In a sheet, replace the default Back button with nothing, or with your own button:

.sheet(isPresented: $showingEditor) {
    NoteEditor()
        .customNavigationBar(
            titleDisplayMode: .large,
            foreground: .white,
            background: .green
        ) {
            Text("New Note")
        } leading: {
            EmptyView()
        } trailing: {
            Button {
                showingEditor = false
            } label: {
                Image(systemName: "xmark.circle")
                    .font(.title)
            }
            .accessibilityLabel("Close")
        }
}

You can download the file from GitHub: CustomNavigationBar.swift

Hope it helps!

Dipin Krishna

Written by Dipin Krishna

Senior full-stack engineer with 15 years across Django, Laravel, SwiftUI and the infrastructure underneath. Available for contract work.

Work with me →

Leave a note

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.