NavigationLink selected state area is larger than the underlying view

I'm using a NavigationSplitView with two columns for a macOS app. The sidebar column shows a list of items and the right pane shows the details for the selected item. I'm using NavigationLink to create clickable sidebar items and pass my own View-type struct that displays the row information.

When I decided to add a slight background color to the rows & slight hover state, I noticed that the row view container is smaller than the NavigationLink when selected (I made the colors brighter to emphasize the issue - rows have a gray background & brighter gray hover state and blue is the built-in styling provided by NavigationLink).

My code for the NavigationSplitView which lives in the body of the main ContentView:

       NavigationSplitView(columnVisibility: $columnVisibility) {
                VStack{
                    if userSettings.cases.isEmpty {
                        Text("Press + to add your first case")
                            .font(.title2)
                            .foregroundColor(.gray)
                            .multilineTextAlignment(.center)
                    } else {
                        List(userSettings.cases, selection: self.$selectedCase) { caseData in
                            NavigationLink(value: caseData) {
                                CaseRow(caseData: caseData, deleteAction: { caseToDelete in
                                    self.caseToDelete = caseToDelete
                                })
                            }
                        }
                    }
                }
                .frame(minWidth: 350, idealWidth: 500)
                .sheet(isPresented: $showingAddCaseView) {
                    AddCaseView { caseName, caseId in
                        let newCase = Case(id: UUID(), name: caseName, caseId: caseId, caseStatus: "Checking...")
                        userSettings.cases.append(newCase)
                        updateCaseStatus(newCase)
                        showingAddCaseView = false
                    }
                }
                .alert(item: $caseToDelete) { caseToDelete in
                    Alert(title: Text("Delete Case"),
                          message: Text("Are you sure you want to delete this case?"),
                          primaryButton: .destructive(Text("Yes, Delete")) {
                              if let index = userSettings.cases.firstIndex(where: { $0.id == caseToDelete.id }) {
                                  userSettings.cases.remove(at: index)
                              }
                          },
                          secondaryButton: .cancel())
                }
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        Button(action: {
                            showingAddCaseView = true
                        }) {
                            Image(systemName: "plus")
                        }
                    }
                }

        } detail: {
                CaseDetailView(caseData: selectedCase)
                    .frame(minWidth: 100, maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color(NSColor.textBackgroundColor))
                    .navigationTitle(selectedCase?.caseId ?? "No Case Selected")
        }
        .onAppear() {
            columnVisibility = .all
        }
    }

Code for CaseRow.swift:

import SwiftUI

struct CaseRow: View {
    let caseData: Case
    let deleteAction: (Case) -> Void

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(caseData.name)
                    .font(.headline)
                Text(caseData.caseId)
                    .font(.subheadline)
            }
            Spacer()
            Text(caseData.caseStatus)
            Button(action: {
                deleteAction(caseData)
            }) {
                Image(systemName: "trash")
                    .foregroundColor(.red)
            }
        }
        .padding()
        .padding(.horizontal, 4)
        .textSelection(.enabled)
        .rowBackground()
    }
}

Code for RowModifier.swift (which applies the background to the row & changes it on hover):

import SwiftUI

struct RowModifier: ViewModifier {
    @State private var isHovered = false
    
    func body(content: Content) -> some View {
        content
            .background(Color.gray.opacity(isHovered ? 0.50 : 0.05))
            .cornerRadius(8)
            .onHover { hover in
                withAnimation(.easeInOut(duration: 0.15)) {
                    isHovered = hover
                }
            }
    }
}

extension View {
    func rowBackground() -> some View {
        self.modifier(RowModifier())
    }
}
  • I tried using .buttonStyle(PlainButtonStyle()) or .buttonStyle(.plain) for the NavigationLink but it doesn't do anything. I did notice that if I apply .buttonStyle(PlainButtonStyle()), the buttons inside the row get new styling, but not the entire row.
  • I tried modifying my custom cell styling (RowModifier.swift) with different padding - didn't help either.
  • Another sign that something is off with my NavigationLink implementation is that I can't change the accent color from system blue. I tried using .accentColor(.green) and .tint(.green) on the content inside the sidebar and on the entire NavigationSplitView, but neither changed the row selection color - it stays system blue.
  • I tried creating a custom NavigationLink and applying the background color & hover color to it directly, but still unable to control the selected state in any way.

I can't find any way to control the system blue styling and its sizing. I just want the row view containers to be the same size in the selected & unselected states so it doesn't look like there's one cell inside another.

I know this could be accomplished by tracking the selected state through bindings, but I'm hoping to use as much of native NavigationSplitView & NavigationLink functionality as it can be ported to iOS later.

A hacky workaround I found for having a hover state, a fill, and a select state that don't conflict - pass the $selectedCase all the way down to Case Row and then use it as a condition to apply my .rowBackground() styling. So the styling only gets applied to the items that are not selected. It prevents a grey box inside the blue select box.

It's not perfect because the $selectedCase only gets updated on full click, so when you just click (without releasing just yet), the previously selected case gets no container at all and the case you're about to select has the grey container inside blue selected one. But it's pretty fast so not as much of an issue. I tried things like .simultaneousGesture(TapGesture().onEnded { ... } to pass a hover state to the CaseRow to remove / apply the styling on mouse down instead of full click but couldn't figure it out.

If someone knows a way to modify the native selected state or evolve this solution with the mouse down logic, would appreciate your help.

NavigationLink selected state area is larger than the underlying view
 
 
Q