How can I change my NavigationView's accent color depending on its current content?

I have an app with a regular NavigationView. The root content is a List in grouped style with a button on the trailing edge of the navigation bar; this is colored blue with the default accent color. The detail views for each item in the list make use of a user-supplied color (part of the model) to provide a header view of sorts, and this looks really nice using

.edgesIgnoringSafeArea(.top)
, extending this nice vibrant color to the top of the display.


I'm using a gradient to darken the color towards the bottom enough that I can use a white primary color for text/icons, but for the life of me I can't determine how to change the accent color of the navigation bar, which now has a clear background. On the root view, where the navigation view is defined, I can use

.accentColor(.white)
or similar to make the back button appear in white, but that effectively makes the navigation bar contents on the root view invisible there. If I skip that, then the back button in the detail view is either all-but invisible (over a blue header background) or rather displeasing (over a red or green background, for example).


Ideally there would be a method like

.navigationBarAccentColor(_ color: Color?)
on
View
, but that's not there. I wonder if there's a way to use the Preference subsystem to have the accent color read dynamically from a custom
PreferenceKey
whose value I can replace from subviews—can anyone point me in the right direction here?

I've found something of a workaround by replacing the standard back button with one of my own. A search turned up some useful information on StackOverflow describing how you can translate

UIViewController.presentingViewController.dismissViewController(_:completion:)
into SwiftUI. The sad thing here is that the animation behavior of the prior controller's title into the new controller's back button is gone, but at least the button is visible now.


Here's how I did it in the end, within my detail view:


struct DetailView: View {
    @Environment(\.presentationMode)
    var presentationMode: Binding

    
    var backButton: some View {
        Button(action: {
            self.presentationMode.wrappedValue.dismiss()
        }) {
            HStack(spacing: 4) {
                Image(systemName: "chevron.left")
                Text("Back")
            }
            .foregroundColor(.white)
        }
    }


    var body: some View {
        VStack {
            // ...content...
        }
        .edgesIgnoringSafeArea(.top)
        .navigationBarItems(leading: backButton)
    }
}

I figured out the correct answer, and it is all to do with PreferenceKeys. Given that the accent color lives in the environment (according to the WWDC talks, anyway) it seems a rather roundabout way of changing this, but needs must and all that.


It ultimately took four pieces:

  1. A @State variable in the root view (containing the NavigationView) whose value would be passed to the .accentColor() modifier on the navigation view.
  2. A PreferenceKey type with a value matching the parameter for .accentColor() (e.g. a Color?).
  3. An onPreferenceChange() modifier on the navigation view that resets the state variable to the preference value.
  4. A .preference(key:value:) modifier on the pushed view setting the new color.


With this, when the detail view is pushed it will set the preference, the top-level view will react by updating its state, and SwiftUI will respond to that by re-fetching the top level view, which will get the new accent color. When you pop the detail view, the preference value will be reset to the value used on the root view, which is the default (nil), so the accent color resets. Of course, everything animates nicely. Yay!


Note that this works for iPhone where the detail view replaces the master list. On iPad, or another place where the master and detail views coexist, you'd likely have to detect that situation somehow and behave differently. Maybe the presentationMode value in the environment would do that?


Sample code, being a slightly modifed Master-Detail project template:


import SwiftUI

private let dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateStyle = .medium
    dateFormatter.timeStyle = .medium
    return dateFormatter
}()

struct AccentColorPreferenceKey: PreferenceKey {
    typealias Value = Color?
    
    static func reduce(value: inout Color?, nextValue: () -> Color?) {
        guard let next = nextValue() else { return }
        value = next
    }
}

struct ContentView: View {
    @State private var dates = [Date]()
    @State private var navAccentColor: Color? = nil

    var body: some View {
        NavigationView {
            MasterView(dates: $dates)
                .navigationBarTitle(Text("Master"))
                .navigationBarItems(
                    leading: EditButton(),
                    trailing: Button(
                        action: {
                            withAnimation { self.dates.insert(Date(), at: 0) }
                        }
                    ) {
                        Image(systemName: "plus")
                    }
                )
            DetailView()
        }
        .navigationViewStyle(DoubleColumnNavigationViewStyle())
        .accentColor(navAccentColor)
        .onPreferenceChange(AccentColorPreferenceKey.self) {
            self.navAccentColor = $0
        }
    }
}

struct MasterView: View {
    @Binding var dates: [Date]

    var body: some View {
        List {
            ForEach(dates, id: \.self) { date in
                NavigationLink(
                    destination: DetailView(selectedDate: date)
                ) {
                    Text("\(date, formatter: dateFormatter)")
                }
            }.onDelete { indices in
                indices.forEach { self.dates.remove(at: $0) }
            }
        }
    }
}

struct DetailView: View {
    var selectedDate: Date?

    var body: some View {
        ZStack {
            Rectangle()
                .fill(Color(red: 0.3, green: 0.3, blue: 1.0))
                .edgesIgnoringSafeArea(.all)

            Group {
                if selectedDate != nil {
                    Text("\(selectedDate!, formatter: dateFormatter)")
                        .foregroundColor(.accentColor)
                } else {
                    Text("Detail view content goes here")
                }
            }
        }
        .preference(key: AccentColorPreferenceKey.self, value: Color.white)
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Nope, turns out that's not right. I was confused by the fact that the title was changing color to white and then back again.


In fact, the preference value set by the detail view is still set when that view disappears. When popping the view, the reduce on the preference key still sees a 'white' from somewhere:


reduce: v = , n = 
reduce: v = , n = 
reduce: v = , n = white
reduce: v = white, n = 
reduce: v = white, n =


Also, my onDisappear {} blocks don't seem to get triggered, not that there's any way to actually set a preference value from there. However, that means that an approach using a custom environment variable of type Binding<Color?> can't ever be un-set either. When popping the detail view, the detail view's onDisappear() is never called, and neither is the master view's onAppear().

How can I change my NavigationView's accent color depending on its current content?
 
 
Q