0

Given the below code, I made a strange observation (on macOS). If a List is wrapped in a NavigationView I suddenly get two updates per click on a row (one on mouseDown - aka holding the mouse click down and not releasing it, one on mouseUp - aka releasing the mouse click). This doesn't happen on a plain simple list, or if it's wrapped in an HStack instead. Does anyone know why and how I can control / change this behavior?

See HStack version in action:

HStack based

See NavigationView version in action:

NavigationView based

struct ContentView: View {
    @State var selection: Set<Int> = []

    var body: some View {
        List(0..<20, selection: Binding(get: {
            self.selection
        }, set: { val in
            print("set \(val)")
            self.selection = val
        })) { idx in
            Text("\(idx)")
        }

        Color.red
        Color.green
        Color.blue
    }
}

// Wrapped in a HStack, 1 update per row selection is triggered, as expected!
struct HStackVersion: View {
    var body: some View {
        HStack(spacing:0.0) {
            ContentView()
        }
    }
}

// Wrapped in a NavigationView, 2 updates per row selection are triggered??
struct NavigationViewVersion: View {
    var body: some View {
        NavigationView {
            ContentView()
        }
    }
}
calimarkus
  • 9,955
  • 2
  • 28
  • 48

1 Answers1

1

The screenshot below shows the stacktrace for a breakpoint in the setter showing that SwiftUI.ListCoreCoordinator is setting the binding twice. I've seen a similar problem with Map here. I'm not sure if duplicate calls to binding setters is part of their design or not but it understandably could be, given that SwiftUI coalesces all state changes into a single call to body.

If you'd like to work around the issue then I would suggest going with the more standard onChange modifier instead of that custom Binding, onChange is only called once for the new value, e.g.

struct MacListProbView: View {
    @State var selection: Set<Int> = []

    var body: some View {        
        List(0..<20, selection: $selection) { idx in
            Text("\(idx)")
        }
        .onChange(of: selection) { newSelection in
            print("set \(newSelection)")
        }
    }
}

struct MacListProbViewNav: View {
    var body: some View {
        NavigationView {
            MacListProbView()
            Color.red // note it's more efficient to have these here because they are not affected by the selection value.
            Color.green
            Color.blue
        }
    }
}

xcode stacktrace and breakpoints

malhal
  • 26,330
  • 7
  • 115
  • 133
  • 1
    Thanks! Ah I didn't see the stack trace before, because it collapsed everything in-between.. should have expanded it. Yeah so apparently both `willChange` and `didChange` get forwarded to the selection binding in this specific case (when wrapped in a navView).. which seems surprising. In the HStack case it's only `didChange`. (`NSTableView`'s `_postSelectionIsChangingAndMark` and `_sendSelectionChangedNotificationForRows`) I was powering wider appstate using the selection binding directly.. but I can try using `onChange` instead. – calimarkus May 13 '22 at 01:20
  • 1
    Also btw - the body get's actually called twice, which is the annoying part. As it's not the same runloop, as there's a natural delay between mouseDown and mouseUp. So it can result in a flickery UI. (Usually not an issue, as the mouseDown one would match the previous state, but in my app that's not always the case, as I'm powering more than just the list itself by this selection binding.) – calimarkus May 13 '22 at 01:25