Search code examples
swiftswiftuipicker

SwiftUI Picker Selection Error and Search Field Causing List Items to Disappear


In this code, I'm experiencing two issues while selecting items in a Picker:

enter image description here

This error occurs when selecting an item in the first Picker. The selection seems to be invalid, and I receive the above error for both parent and child items.

Search Field Issue: When I use the search field, all elements in the list disappear even though there should be matches. Could someone help me resolve these issues? Here’s the relevant code snippet:

import SwiftUI

struct Parent: Hashable {
    let title: String
    let children: [Child]
}

struct Child: Hashable {
    let title: String
}

extension Parent {
    static var mock: [Parent] = [
        .init(title: "Parent 1", children: [.init(title: "Child 1"), .init(title: "Child 2")]),
        .init(title: "Parent 2", children: [.init(title: "Child 3"), .init(title: "Child 4"), .init(title: "Child 5")])
    ]
}

@MainActor
@Observable
final class TestViewModel {
    var parents: [Parent] = Parent.mock
    var selectedParent: Parent
    var searchParentText = ""
    var searchParents: [Parent] {
        if searchParentText.isEmpty {
            return parents
        } else {
            return parents.filter { $0.title.contains(searchParentText) }
        }
    }
    
    var children: [Child] {
        selectedParent.children
    }
    var selectedChild: Child
    var searchChildText = ""
    var searchChildren: [Child] {
        if searchChildText.isEmpty {
            return children
        } else {
            return children.filter { $0.title.contains(searchChildText) }
        }
    }
    
    init() {
        self._selectedParent = .mock[0]
        self._selectedChild = Parent.mock[0].children[0]
    }
    
    func updateSelectedItems() {
        self.selectedChild = self.selectedParent.children[0]
    }
}

@MainActor
struct TestSwiftUIView: View {
    @State var vm = TestViewModel()
    
    var body: some View {
        NavigationStack {
            Form {
                Picker("Parents",
                       selection: $vm.selectedParent) {
                    ForEach(vm.searchParents, id: \.self) { parent in
                        Text(parent.title)
                            .tag(parent.title)
                            .searchable(text: $vm.searchParentText)
                    }
                }
                       .pickerStyle(.navigationLink)
                
                Picker("Parents",
                       selection: $vm.selectedChild) {
                    ForEach(vm.searchChildren, id: \.self) { child in
                        Text(child.title)
                            .tag(child.title)
                            .searchable(text: $vm.searchChildText)
                    }
                }
                       .pickerStyle(.navigationLink)
            }
            .onChange(of: vm.selectedParent) {
                vm.updateSelectedItems()
            }
        }
    }
}

What could be causing these issues, and how can I fix them? Or there is any better way to achieve 2 pickers in relation parent/child, category/subcategory etc.


Solution

  • Firstly, you have a type mismatch. The first picker is bound to $vm.selectedParent, which is of type Parent. The tags have the value parent.title, which is of type String. The tags should have the same type as the bound variable. The same applies to the second picker.

    As it happens, the way you currently have it might actually work, at least for the child, because the tag values are compared to the selectable items by their hash value. Still, I would suggest you change the tags to the following:

    Text(parent.title)
        .tag(parent)
    
    //...
    
    Text(child.title)
        .tag(child)
    

    This change won't fix the problem you reported. The reason it is happening is because the second picker contains the children of the selected parent. When you change the selection from Parent 1 to Parent 2, the second picker is automatically re-populated with the children of Parent 2. However, the previously-selected child belonged to Parent 1. This child is no longer found amongst the children of Parent 2, so you get the error.

    When the parent selection changes, you are calling updateSelectedItems to select the first child of the newly-selected parent. Unfortunately, this happens too late. The child picker is dynamically re-populated when the parent selection changes and when this happens it is not able to retain the previous child selection.

    One way to fix would be to update the view model:

    • add a variable for holding the parent that was previously selected
    • add setter observers to selectedParent and update the new variable in willSet and didSet:
    // TestViewModel
    
    private var previouslySelectedParent: Parent?
    
    var selectedParent: Parent {
        willSet {
            if newValue != selectedParent {
                previouslySelectedParent = selectedParent
            }
        }
        didSet {
            selectedChild = selectedParent.children[0]
            previouslySelectedParent = nil
        }
    }
    

    Then update the computed property that delivers the children:

    var children: [Child] {
        var combinedChildren = selectedParent.children
        if let previouslySelectedParent, previouslySelectedParent != selectedParent {
            combinedChildren += previouslySelectedParent.children
        }
        return combinedChildren
    }
    

    Finally, the didSet function on selectedParent is now performing the work of updateSelectedItems, so you can remove this function and take out the .onChange callback:

    //    func updateSelectedItems() {
    //        self.selectedChild = self.selectedParent.children[0]
    //    }
    
    // TestSwiftUIView
    
    Form {
        // ...
    }
    // .onChange(of: vm.selectedParent) {
    //      vm.updateSelectedItems()
    // }