Mete Polat
Mete Polat

Reputation: 103

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).

enter image description here

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
                    })
                }
                .cornerRadius(8)
            }
        }
    }
    .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 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.

Upvotes: 1

Views: 173

Answers (1)

Mete Polat
Mete Polat

Reputation: 103

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.

enter image description here

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

Upvotes: 1

Related Questions