3

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 2

5

A couple of suggestions:

  1. SwiftUI (specifically on macOS) is unreliable/unpredictable with certain List behaviors. One of them is selection -- 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 that NavigationLink and isActive works like a selection in a list -- I'll use that in my example.
  2. @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:

  1. You are storing a Week in your user defaults with a certain UUID. However, you regenerate the array of weeks 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:

import SwiftUI
import Combine

struct ContentView : View {
    var body: some View {
        NavigationView {
            MenuView().environmentObject(UserSettings())
        }
    }
}

class UserSettings: ObservableObject {
    @Published var weeks: [Week] = []
    
    @Published var selectedWeek: UUID? = nil
    
    private var cancellable : AnyCancellable?
    private var initialItems = [
        Week(name: "test week 1"),
        Week(name: "foobar"),
        Week(name: "hello world")
    ]
    
    init() {
        let decoder = PropertyListDecoder()
                
        if let data = UserDefaults.standard.data(forKey: "weeks") {
            weeks = (try? decoder.decode([Week].self, from: data)) ?? initialItems
        } else {
            weeks = initialItems
        }
        
        if let prevValue = UserDefaults.standard.string(forKey: "week.selected.id") {
            selectedWeek = UUID(uuidString: prevValue)
            print("Set selection to: \(prevValue)")
        }
        cancellable = $selectedWeek.sink {
            if let id = $0?.uuidString {
                UserDefaults.standard.set(id, forKey: "week.selected.id")
                let encoder = PropertyListEncoder()
                if let encoded = try? encoder.encode(self.weeks) {
                    UserDefaults.standard.set(encoded, forKey: "weeks")
                }
            }
        }
    }
    
    func selectionBindingForId(id: UUID) -> Binding<Bool> {
        Binding<Bool> { () -> Bool in
            self.selectedWeek == id
        } set: { (newValue) in
            if newValue {
                self.selectedWeek = id
            }
        }

    }
}

//Unknown what you have in here
struct Day : Equatable, Hashable, Codable {
    
}

struct Week: Identifiable, Hashable, Equatable, Codable {
    var id = UUID()
    var days: [Day] = []
    var name: String
}


struct WeekView : View {
    var week : Week
    
    var body: some View {
        Text("Week: \(week.name)")
    }
}

struct MenuView: View {
    @EnvironmentObject var settings: UserSettings
    
    var body: some View {
        List {
            ForEach(settings.weeks) { week in
                NavigationLink(
                    destination: WeekView(week: week)
                        .environmentObject(settings),
                    isActive: settings.selectionBindingForId(id: week.id)
                )
                {
                    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())
    }
}
  1. In UserSettings.init the weeks are loaded if they've been saved before (guaranteeing the same IDs)
  2. Use Combine on $selectedWeek instead of didSet. I only store the ID, since it seems a little pointless to store the whole Week struct, but you could alter that
  3. I create a dynamic binding for the NavigationLinks isActive property -- the link is active if the stored selectedWeek is the same as the NavigationLink's week ID.
  4. Beyond those things, it's mostly the same as your code. I don't use selection on List, just isActive on the NavigationLink
  5. I didn't implement storing the Week again if you did the onMove or onDelete, so you would have to implement that.
Sign up to request clarification or add additional context in comments.

5 Comments

thank you, I am not that experienced yet with SwiftUI. Now that I know that there are bugs and how to search for it, I find plenty of the same statements on the internet. But before I just found examples etc. for iOS showing the way or similar like I tried it. Nevertheless my question got downvoted 1 min after I posted it here on SO for some reason. Asking questions on SO is so annoying. People please understand that there maybe might be similar questions or explanations already, but they might e.g. be for iOS and another problem occurs for macOS. Asking questions is not a bad thing...
I barely find SwiftUI examples, code and stuff for macOS or Multiplatform app projects on the internet. Google always shows the same ol pages like SO and others, that do the stuff either for iOS or dare you to ask a freaking solid question.
Could you maybe please explain the use of cancellable in this example a little further? I implemented the only selectionBindingForId and It works as well now. Whats the purpose of cancellable?
Sure — when you use a Combine publisher, you have to keep a reference to it. Otherwise, it goes out of scope immediately and you don’t continue to get updates. The cancellable let’s you keep it in memory, get updates, cancel it if you need to, and if you don’t cancel it manually, it gets cancelled upon deinit so that you don’t end up leaking memory.
Thank you, I will have to look closer into Combine and cancellable, but this helped me already a lot.
1

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.

struct ContentView: View {
    let beatles = ["John", "Paul", "Ringo", "George", "Pete"]
    @State var listSelection = Set<String>()

    var body: some View {
        NavigationView {
            List(beatles, id: \.self, selection: $listSelection) { name in
                NavigationLink(name) {
                    Text("Some details about \(name)")
                }
            }
        }
    }
}

Renders like so

enter image description here

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.

let selectionBinding = Binding {
    backingVal
} set: { newVal in
    guard newVal <= 1 else {
        backingVal = nil
        return
    }
    backingVal = newVal
}

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:

struct ContentView: View {
    let beatles = ["John", "Paul", "Ringo", "George", "Pete"]
    @State var listSelection: Set<String> = []

    var body: some View {
        NavigationView {
            SideBar(items: beatles, selection: $listSelection)
            Detail(ids: listSelection)
        }
    }

    struct SideBar: View {
        let items: Array<String>
        @Binding var selection: Set<String>

        var body: some View {
            List(items, id: \.self, selection: $selection) { name in
                Text(name)
            }
        }
    }

    struct Detail: View {
        let ids: Set<String>

        var detailsMsg: String {
            ids.count == 1 ? "Would show details for \(ids.first)"
                : ids.count > 1 ? "Too many items selected"
                : "Nothing selected"
        }

        var body: some View {
            Text(detailsMsg)
        }
    }
}

Have fun.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.