Can't get SwiftUI List selection to work with custom struct
Asked Answered
G

2

6

I'm trying to make a List of favorite newspapers. In edit mode the list displays all available newspapers from which the user can select his favorites. After selecting favorites the list displays only the favorites. Here is my code:

 struct Newspaper: Hashable {
     let name: String
 }
 
 struct ContentView: View {
     @State var editMode: EditMode = .inactive
     @State private var selection = Set<Newspaper>()
     var favorites: [Newspaper] {
         selection.sorted(by: ({ $0.name < $1.name }))
     }
     
     let newspapers = [
         Newspaper(name: "New York Times"),
         Newspaper(name: "Washington Post")
     ]
     
     var body: some View {
         NavigationView {
             List(editMode == .inactive ? favorites : newspapers, id: \.name, selection: $selection) { aliasItem in
                 Text(aliasItem.name)
             }
             .toolbar {
                 EditButton()
             }
             .environment(\.editMode, self.$editMode)
         }
     }
 }

The problem is that the list enters edit mode, but the selection widgets don't appear. If I replace Newspaper with just an array of String (and modify the rest of the code accordingly), then the selection widgets do appear and the list works as expected. Can anyone explain what the problem is?

I originally tried using an Identifiable Newspaper like this:

 struct Newspaper: Codable, Identifiable, Equatable, Hashable {
     var id: String { alias + publicationName }
     let alias: String
     let publicationName: String
 }

Since this didn't work, I tested the simpler version above to try to pinpoint the problem.

Since I need to save the favorites, the Newspaper has to be Codable and thus can't use UUID as they are read from disk and the complete Newspapers array is fetched from a server. That's why I have the id as a computed property.

Yrb:s answer provided the solution to the problem: the type of the selection Set has to be the same type as the id you are using in your Identifiable struct and not the type that you are displaying in the List.

So in my case (with the Identifiable Newspaper version) the selection Set has to be of type Set<String> and not Set<Newspaper> since the id of Newspaper is a String.

Grewitz answered 15/1, 2022 at 17:6 Comment(0)
S
6

Your issue stems from the fact that List's selection mode uses the id: property to track your selection. Since you are declaring your List as List(..., id: \.name, ...), your selection var needs to be of type String. If you change it to List(..., id: \.self, ...), it will work, but using self in a list like that brings it own problems. In keeping with best practice, and forgetting the selection for a moment, you should be using an Identifiable struct. List should then identify the elements by the id parameter on the struct. (I used a UUID)

Working up to the selection, that means you need to define it as @State private var selection = Set<UUID>(). That leaves dealing with your favorites computed variable. Instead of returning an array of your selection, you simply filter the newspapers array for those element contained in selection. In the end, that leaves you with this:

struct Newspaper: Identifiable, Comparable {
    let id = UUID()
    let name: String
    
    static func < (lhs: Newspaper, rhs: Newspaper) -> Bool {
        lhs.name < rhs.name
    }
}

 struct ContentView: View {
    @State var editMode: EditMode = .inactive
    @State private var selection = Set<UUID>()
    var favorites: [Newspaper] {
        newspapers.filter { selection.contains($0.id) }
    }
    
    let newspapers = [
        Newspaper(name: "New York Times"),
        Newspaper(name: "Washington Post")
    ]
    
    var body: some View {
        NavigationView {
            VStack {
                List(editMode == .inactive ? favorites.sorted() : newspapers, selection: $selection) { aliasItem in
                    Text(aliasItem.name)
                }
                .toolbar {
                    EditButton()
                }
                .environment(\.editMode, self.$editMode)
                Text(selection.count.description)
            }
        }
    }
}

EDIT:

In your comment, you said that Newspaper needs to be 'Codable', and implied that there is no id parameter in the server response. The below Newspaper is Codable, but will not expect an id in the server response, but will simply add its own constant id. It is a very bad idea to have a computed id. id should never change and it should be unique. UUID gives you that.

struct Newspaper: Identifiable, Comparable, Codable {
    let id = UUID()
    let name: String
    
    enum CodingKeys:String,CodingKey {
        case name
    }

    static func < (lhs: Newspaper, rhs: Newspaper) -> Bool {
        lhs.name < rhs.name
    }
}
Superstitious answered 15/1, 2022 at 18:16 Comment(5)
I need to save the favorites so Newspaper has to be codable. The complete Newspapers array is fetched from a server. That's why I used a computed property for id. Do you know why the original Identifiable Newspaper did not work in the List?Grewitz
Now I got it! I didn't understand that the selection set has to be a set of values of the type of id your Identifiable has and not the type in the List. Now it works as expected.Grewitz
I can't speak to the "original Identifiable Newspaper" because you never posted it. The version you posted was simply Hashable. I am presuming that the individual array elements are not Identifiable from the server? If not, you can always add an id constant to it after decoding. I have updated my answer to reflect this.Superstitious
Thanks @Yrb. The alias and publication name of a Newspaper are always the same, so I don't see why I can't use them as a computed id.Grewitz
Do they only ever exist once from the server? If you are sent two of the same by accident, you have a problem. You don't control what happens server side, hence a constant id being best practice. And it is not like it is difficult to implement.Superstitious
B
2

You can use a struct for a List's selection, but you have to tell SwiftUI about it, so it doesn’t use the struct’s ID. The original code works with an appropriate tag added, specifically:

Text(aliasItem.name)
    .tag(aliasItem)
Browse answered 2/1, 2023 at 19:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.