How to make Picker onChange update a selected struct?

76 Views Asked by At

I have two structures. One of these structures Idea has a field that stores an instance of the other structure Option. In turn, Option has a list of IDs of Idea structures associated with it.

I created a Picker that onChange of Idea.Option adds the id of this idea to the list of associated ideas in Option.

This throws this error. This throws this error.

Here's the code

//ContentView
import SwiftUI

struct ContentView: View {
    
    var options: [Option] = [
        Option(name: "option1", assositatedIdea: []),
        Option(name: "option2", assositatedIdea: []),
        Option(name: "option3", assositatedIdea: [])
    ]
    
    @Binding var ideas: Idea
    
    
    var body: some View {
        Picker("Option: \(ideas.option.name)", selection: $ideas.option) {
            ForEach(options) {option in
                Text(option.name).tag(option)
            }
        }.onChange(of: ideas.option) {newOption in
            ideas.option.addAssosiatedIdea(ideaId: ideas.id)
        }
        
    }
}

struct Option: Hashable, Identifiable, Equatable {
    let id: UUID
    var name: String
    var assositatedIdea: [UUID] // Stores IDs of structs of Ideas
    
    init(id: UUID = UUID(), name: String, assositatedIdea: [UUID]) {
        self.id = id
        self.name = name
        self.assositatedIdea = assositatedIdea
        
    }
    
    mutating func addAssosiatedIdea(ideaId: UUID) {
        
        
        if (assositatedIdea.contains(ideaId)) {
            return
        }
        self.assositatedIdea.append(ideaId)
    }

}

extension Option {
    static var emptyOption: Option {
        Option(name: "", assositatedIdea: [])
    }
}

struct Idea {
    let id: UUID
    var name: String
    var option: Option
    
    init(id: UUID = UUID(), name: String, option: Option) {
        self.id = id
        self.name = name
        self.option = option
        
    }

}
// test1App.swift 
import SwiftUI

@main
struct test1App: App {
    
    @State var ideas: Idea = Idea(name: "big brain idea", option: Option.emptyOption)
    var body: some Scene {
        WindowGroup {
            ContentView(ideas: $ideas)
        }
    }
}

I want Picker to preview the new Option. However, Picker refresehs and displays the first Option in options.

I did some print debugging and found out that in fact, Idea gets its field updated.

1

There are 1 best solutions below

1
Benzy Neez On

Explanation

Firstly, here is an explanation for the error.

  • Your struct Option has an id of type UUID.
  • Every Option therefore has a unique but unpredictable id.
  • You are creating the Picker using an array of Option, which is local to the view ContentView.
  • You are binding the Picker to $ideas.option.
  • The bound value is not going to match any of the Option in the array, because it is sure to have its own unique id.
  • This is why the Picker gives the error that "the selection [Option] is invalid and does not have an associated tag".

Another reason why two Option will probably not match is because each Option has an array of UUID. These are the ids of the associated Idea.

Quick fix

  1. The quick way to fix the issues is to make sure that two options that are supposed to be the same actually compare as equal. So instead of using an id of type UUID, you could use a simple Int. In order to ignore the associated ids, you should also implement your own equality function:
struct Option: Hashable, Identifiable, Equatable {
    let id: Int // <- not UUID
    var name: String
    var assositatedIdea: [UUID] // Stores IDs of structs of Ideas

    init(id: Int, name: String, assositatedIdea: [UUID]) {
        self.id = id
        self.name = name
        self.assositatedIdea = assositatedIdea
    }

    static func ==(lhs: Option, rhs: Option) -> Bool {
        lhs.id == rhs.id
    }
  1. The empty option can have an id of 0:
    static var emptyOption: Option {
        Option(id: 0, name: "", assositatedIdea: [])
    }
  1. Even with these changes in place, the picker will still complain that the option held by the binding does not match any tag, because you are creating ideas with an empty option. So include an empty option in the list of pickable options:
    var options: [Option] = [
        Option(id: 0, name: "empty", assositatedIdea: []), // <- ADDED
        Option(id: 1, name: "option1", assositatedIdea: []),
        Option(id: 2, name: "option2", assositatedIdea: []),
        Option(id: 3, name: "option3", assositatedIdea: [])
    ]

Alternative model

The changes above get it working, but it's still a bit messy. In particular, the way that Option holds a collection of Idea ids means the two structs are inter-dependent.

Here are some suggestions on how the data model and implementation could perhaps be made a bit cleaner:

  • Use an enum to define the possible options, if possible (this works if the options are fixed and known in advance).
  • Use let instead of var whenever possible.
  • Instead of having the Options know about the Ideas that reference them, use another new type to model this relationship.
  • Instead of collecting the ids (UUID) of the Ideas, just collect the actual Ideas.
  • Idea probably doesn't need an id at all.

The following illustrates how it can be implemented this way. The new type that models the relationship between Option and associated Ideas is OptionAndIdeas, which is a class type. The class AllOptionsAndIdeas is a wrapper for a map of these classes.

enum Option: Int, Identifiable {
    case empty = 0
    case option1 = 1
    case option2 = 2
    case option3 = 3

    var id: Int {
        self.rawValue
    }

    var asString: String {
        "\(self)"
    }
}

struct Idea: Equatable {
    let option: Option
    let name: String
}

class OptionAndIdeas {
    let option: Option
    var associatedIdeas: [Idea]

    init(option: Option, associatedIdeas: [Idea] = []) {
        self.option = option
        self.associatedIdeas = associatedIdeas
    }

    func addAssociatedIdea(idea: Idea) {
        if !associatedIdeas.contains(idea) {
            associatedIdeas.append(idea)
        }
    }

    func removeAssociatedIdea(idea: Idea) {
        if let index = associatedIdeas.firstIndex(of: idea) {
            associatedIdeas.remove(at: index)
        }
    }
}

class AllOptionAndIdeas {

    /// A map of OptionAndIdeas, keyed by option
    private var allOptionsAndIdeas = [Option: OptionAndIdeas]()

    func switchIdeaToOption(idea: Idea, option: Option) -> Idea {

        // Remove the idea from its previous association
        if let optionAndIdeas = allOptionsAndIdeas[idea.option] {
            optionAndIdeas.removeAssociatedIdea(idea: idea)
        }
        // Re-create the idea, associating it with the new option
        let result = Idea(option: option, name: idea.name)

        // Update the associations between options and ideas
        let optionAndIdeas = allOptionsAndIdeas[option] ?? OptionAndIdeas(option: option)
        optionAndIdeas.addAssociatedIdea(idea: result)
        allOptionsAndIdeas[option] = optionAndIdeas

        return result
    }
}

struct PickerExample: View {

    private let options: [Option] = [ .empty, .option1, .option2, .option3 ]

    @Binding private var idea: Idea
    private let allOptionsAndIdeas: AllOptionAndIdeas
    @State private var selectedOption: Option

    init(idea: Binding<Idea>, allOptionsAndIdeas: AllOptionAndIdeas) {
        self._idea = idea
        self.allOptionsAndIdeas = allOptionsAndIdeas
        self._selectedOption = State(initialValue: idea.wrappedValue.option)
    }

    var body: some View {
        Picker("Option", selection: $selectedOption) {
            ForEach(options) { option in
                Text(option.asString).tag(option)
            }
        }
        .onChange(of: selectedOption) { newOption in

            // Switch to the selected option
            idea = allOptionsAndIdeas.switchIdeaToOption(idea: idea, option: newOption)
        }
    }
}

struct ContentView: View {
    private let allOptionsAndIdeas = AllOptionAndIdeas()
    @State private var bigBrainIdea = Idea(option: .empty, name: "big brain idea")

    var body: some View {
        PickerExample(idea: $bigBrainIdea, allOptionsAndIdeas: allOptionsAndIdeas)
    }
}