iosswiftswiftuitoolbarnavigationbar

Custom Title in Navigation Bar in SwiftUI


In my SwiftUI detail view I have this code (part of it)

import SwiftUI
import MapKit
import Kingfisher
import BottomSheet

struct VehiclePositionView: View {
    @Environment(\.isPresented) var isPresented
    @Environment(\.colorScheme) var colorScheme
    @EnvironmentObject var themeProvider: ThemeProvider
    
    @State var vehicle: VehicleModel
    
    @State private var bottomSheetPosition: BottomSheetPosition = .dynamicBottom
    @State private var cameraPosition = MapCameraPosition.region(
        MKCoordinateRegion(
            center: CLLocationCoordinate2D(latitude: 0, longitude: 0),
            span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
        )
    )
    
    let span: (CGFloat, CGFloat) = (0.005, 0.005)
    let imgUrl = URL(string: "https://clientes.gesfrota.pt/Gesfrota_Images/1/Viaturas/viatura_14563.jpg")!
    
    var body: some View {
        Map(position: $cameraPosition) {
            Annotation(vehicle.plate, coordinate: location) {
                Image(systemName: "car")
                    .foregroundColor(themeProvider.accentColor)
                    .background(
                        Circle()
                            .fill(Color.white)
                            .frame(width: 30, height: 30)
                            .shadow(color: getVehicleStatusColor(for: vehicle), radius: 3)
                    )
            }
        }
        .mapStyle(.standard(elevation: .realistic, showsTraffic: true))
        .onAppear {
            NotificationCenter.notify(.toggleBottomBar)
            cameraPosition = .region(
                MKCoordinateRegion(
                    center: CLLocationCoordinate2D(latitude: vehicle.latitude, longitude: vehicle.longitude),
                    span: MKCoordinateSpan(latitudeDelta: span.0, longitudeDelta: span.1)
                )
            )
        }
        .onChange(of: isPresented) {
            if !isPresented {
                NotificationCenter.notify(.toggleBottomBar)
            }
        }
        .toolbar {
            ToolbarItem(placement: .principal) {
                VStack(spacing: 3) {
                    ZStack {
                        KFImage(imgUrl)
                            .fade(duration: 0.25)
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: 50, height: 50)
                            .clipShape(Circle())
                            .shadow(
                                color: colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.3),
                                radius: 5
                            )
                        Circle()
                            .fill(getVehicleStatusColor(for: vehicle))
                            .frame(width: 12, height: 12)
                            .offset(x: 18, y: 20)
                    }
                    Text(vehicle.plate)
                    Text("\(vehicle.brand) \(vehicle.model)")
                        .font(.footnote)
                        .foregroundStyle(themeProvider.secondary)
                }
                //.padding(.top, 50)
            }
        }
        .toolbarBackground(.thinMaterial, for: .navigationBar)
        .toolbarRole(.editor)
        .toolbarVisibility(.visible, for: .navigationBar)
        .bottomSheet(bottomSheetPosition: $bottomSheetPosition, switchablePositions: [.dynamicTop, .dynamicBottom]) {
            VStack(alignment: .leading, spacing: 8) {
                Text(vehicle.plate)
                    .font(.title2)
                    .bold()
                    .foregroundStyle(themeProvider.primary)
                Text("\(vehicle.brand) \(vehicle.model)")
                    .font(.callout)
                    .foregroundStyle(themeProvider.secondary)
            }
            .padding(.horizontal)
        } mainContent: {
            
        }        
    }
    
    private var location: CLLocationCoordinate2D {
        return CLLocationCoordinate2D(latitude: vehicle.latitude, longitude: vehicle.longitude)
    }
    
}

I was expecting to get the following output, and this is exactly what I get in the Xcode preview

Expected Output

But on a real device/simulator I get this weird behaviour Behaviour on real device/simulator

As you can see, when the transition starts it does appear as expected but when the animation ends it clips the toolbar item

On the parent view I'm using a NavigationStack, no special configuration. On the detail (this view) the configuration for the toolbar/navbar is provided in this post. I'm targeting iOS 18

UPDATE

After some debugging I was able to narrow down the issue to this part of the code in the parent view

            .navigationTitle("vehicles")
            .searchable(text: $searchField, isPresented: $searchVisible, placement: .navigationBarDrawer, prompt: "search")
            .toolbar {
                ToolbarItemGroup(placement: .topBarTrailing) {
                    Button() { searchVisible = true } label: { Image(systemName: "magnifyingglass") }
                    Menu {
                        Picker(selection: $vehicleState) {
                            ForEach(VehicleState.allCases, id:\.self) { choice in
                                Text(LocalizedStringKey(choice.rawValue))
                            }
                        } label: {
                            Text("Select a state")
                        }
                    } label: {
                        if vehicleState == .all {
                            Image(systemName: "line.3.horizontal.decrease.circle")
                        } else {
                            Image(systemName: "line.3.horizontal.decrease.circle.fill")
                        }
                    }
                }
            }

Full RootView code

NavigationStack {
            List(appState.vehicles.filter {
                (
                    $0.model.lowercased().contains(searchField.lowercased()) ||
                    $0.plate.lowercased().contains(searchField.lowercased()) ||
                    $0.brand.lowercased().contains(searchField.lowercased()) ||
                    $0.driverName.lowercased().contains(searchField.lowercased()) ||
                    (vehicleLocations[$0.id]?.lowercased().contains(searchField.lowercased()) ?? false) ||
                    searchField.isEmpty
                ) && (
                    vehicleState == .all ||
                    (vehicleState == .off && !$0.ignition) ||
                    (vehicleState == .running && $0.ignition && $0.speed >= 5) ||
                    (vehicleState == .idle && $0.ignition && $0.speed < 5)
                )
            }) { vehicle in
                NavigationLink(destination: VehiclePositionView(vehicle: vehicle)) {
                    VehicleCell(vehicle: vehicle, locatedCallback: {
                        vehicleLocations[vehicle.id] = $0
                    })
                }
            }
            .safeAreaInset(edge: .bottom) {
                Spacer()
                    .frame(height: navbarHeight + 10)
            }
            .navigationBarTitleDisplayMode(.large)
            .navigationTitle("vehicles")
            .searchable(text: $searchField, isPresented: $searchVisible, placement: .navigationBarDrawer, prompt: "search")
            .toolbar {
                ToolbarItemGroup(placement: .topBarTrailing) {
                    Button() { searchVisible = true } label: { Image(systemName: "magnifyingglass") }
                    Menu {
                        Picker(selection: $vehicleState) {
                            ForEach(VehicleState.allCases, id:\.self) { choice in
                                Text(LocalizedStringKey(choice.rawValue))
                            }
                        } label: {
                            Text("Select a state")
                        }
                    } label: {
                        if vehicleState == .all {
                            Image(systemName: "line.3.horizontal.decrease.circle")
                        } else {
                            Image(systemName: "line.3.horizontal.decrease.circle.fill")
                        }
                    }
                }
            }
        }

Solution

  • I was able to reproduce the problem by reducing the detail view to just Map().

    It helps if you set the navigation bar title display mode to .large:

    var body: some View {
        Map()
            .navigationBarTitleDisplayMode(.large) // 👈 here
            .toolbar {
                // ...
            }
            // + other modifiers
    }
    

    With a few small tweaks to sizes and spacing, you should be able to fit your header into the larger space that this gives you. Example:

    Screenshot


    Here is the full code for reproducing the example above:

    import MapKit
    
    struct ContentView: View {
        var body: some View {
            NavigationStack {
                NavigationLink("Go to next") {
                    VehiclePositionView()
                }
            }
        }
    }
    
    struct VehiclePositionView: View {
        @Environment(\.colorScheme) var colorScheme
    
        var body: some View {
            Map()
                .navigationBarTitleDisplayMode(.large)
                .toolbar {
                    ToolbarItem(placement: .principal) {
                        VStack(spacing: 4) {
                            ZStack {
                                Image(systemName: "car.fill")
                                    .resizable()
                                    .foregroundStyle(.red)
                                    .scaledToFit()
                                    .frame(width: 50, height: 50)
                                    .clipShape(Circle())
                                    .shadow(
                                        color: colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.3),
                                        radius: 5
                                    )
                                Circle()
                                    .fill(.gray)
                                    .frame(width: 12, height: 12)
                                    .offset(x: 18, y: 20)
                            }
                            Text("ABC 123") // vehicle.plate
                            Text("Apple Car") // "\(vehicle.brand) \(vehicle.model)"
                                .font(.caption)
                                .offset(y: -6)
                        }
                        .padding(.top, 50)
                    }
                }
                .toolbarBackground(.thinMaterial, for: .navigationBar)
                .toolbarRole(.editor)
                .toolbarVisibility(.visible, for: .navigationBar)
        }
    }