In my macOS app project, I have a SwiftUI List view of NavigationLinks build with a foreach loop from an array of items:
struct MenuView: View {
@EnvironmentObject var settings: UserSettings
var body: some View {
List(selection: $settings.selectedWeek) {
ForEach(settings.weeks) { week in
NavigationLink(
destination: WeekView(week: week)
.environmentObject(settings)
tag: week,
selection: $settings.selectedWeek)
{
Image(systemName: "circle")
Text("(week.name)")
}
}
.onDelete { set in
settings.weeks.remove(atOffsets: set)
}
.onMove { set, i in
settings.weeks.move(fromOffsets: set, toOffset: i)
}
}
.navigationTitle("Weekplans")
.listStyle(SidebarListStyle())
}
}
This view creates the sidebar menu for a overall NavigationView.
In this List view, I would like to use the selection mechanic together with tag from NavigationLink. Week is a custom model class:
struct Week: Identifiable, Hashable, Equatable {
var id = UUID()
var days: [Day] = []
var name: String
}
And UserSettings looks like this:
class UserSettings: ObservableObject {
@Published var weeks: [Week] = [
Week(name: "test week 1"),
Week(name: "foobar"),
Week(name: "hello world")
]
@Published var selectedWeek: Week? = UserDefaults.standard.object(forKey: "week.selected") as? Week {
didSet {
var a = oldValue
var b = selectedWeek
UserDefaults.standard.set(selectedWeek, forKey: "week.selected")
}
}
}
My goal is to directly store the value from List selection in UserDefaults. The didSet property gets executed, but the variable is always nil. For some reason the selected List value can’t be stored in the published / bindable variable.
Why is $settings.selectedWeek always nil?
2
Answers
A couple of suggestions:
List
behaviors. One of them isselection
— there are a number of things that either completely don’t work or at best are slightly broken that work fine with the equivalent iOS code. The good news is thatNavigationLink
andisActive
works like a selection in a list — I’ll use that in my example.@Published
didSet
may work in certain situations, but that’s another thing that you shouldn’t rely on. The property wrapper aspect makes it behave differently than one might except (search SO for "@Published didSet" to see a reasonable number of issues dealing with it). The good news is that you can use Combine to recreate the behavior and do it in a safer/more-reliable way.A logic error in the code:
Week
in your user defaults with a certain UUID. However, you regenerate the array ofweeks
dynamically on every launch, guaranteeing that their UUIDs will be different. You need to store your week’s along with your selection if you want to maintain them from launch to launch.Here’s a working example which I’ll point out a few things about below:
UserSettings.init
the weeks are loaded if they’ve been saved before (guaranteeing the same IDs)$selectedWeek
instead ofdidSet
. I only store the ID, since it seems a little pointless to store the wholeWeek
struct, but you could alter thatNavigationLink
sisActive
property — the link is active if the storedselectedWeek
is the same as theNavigationLink
‘s week ID.selection
onList
, justisActive
on theNavigationLink
Week
again if you did theonMove
oronDelete
, so you would have to implement that.Bumped into a situation like this where multiple item selection didn’t work on macOS. Here’s what I think is happening and how to workaround it and get it working
Background
So on macOS NavigationLinks embedded in a List render their Destination in a detail view (by default anyway). e.g.
Renders like so
Problem
When NavigationLinks are used it is impossible to select multiple items in the sidebar (at least as of Xcode 13 beta4).
… but it works fine if just Text elements are used without any NavigationLink embedding.
What’s happening
The detail view can only show one NavigationLink View at a time and somewhere in the code (possibly NavigationView) there is piece of code that is enforcing that compliance by stomping on multiple selection and setting it to nil, e.g.
What happens in these case is to the best of my knowledge not defined. With some Views such as TextField it goes out of sync with it’s original Source of Truth (for more), while with others, as here it respects it.
Workaround/Fix
Previously I suggested using a
ZStack
to get around the problem, which works, but is over complicated.Instead the idiomatic option for macOS, as spotted on the Lost Moa blog post is to not use
NaviationLink
at all.It turns out that just placing sidebar and detail Views adjacent to each other and using binding is enough for
NavigationView
to understand how to render and stops it stomping on multiple item selections. Example shown below:Have fun.