Swiftui Hierarchical Picker With Dynamic Data Crashes

SwiftUI hierarchical Picker with dynamic data crashes

The trick is to "recreate" the "slave" picker when you select a different country

In your example selection made by user change the state variable, but SwiftUI will recreate only view which is depending on this state variable. SwiftUI don't have any idea why to recreate the second Picker View. I did it "manually", by calling its .id() in case it must be done (the coutry was changed)

What information Apple gives us about View.id() ..

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {

/// Returns a view whose identity is explicitly bound to the proxy
/// value `id`. When `id` changes the identity of the view (for
/// example, its state) is reset.
@inlinable public func id<ID>(_ id: ID) -> some View where ID : Hashable

}

This is "full" single View iOS app, be careful, it will not run in Playground

//
// ContentView.swift
// tmp034
//
// Created by Ivo Vacek on 05/02/2020.
// Copyright © 2020 Ivo Vacek. NO rights reserved.
//

import Foundation
import SwiftUI

struct Country: Identifiable {
var id: Int = 0
var name: String
var cities: [City]
}

struct City: Identifiable {
var id: Int = 0
var name: String
}

class Model: ObservableObject {
let countries: [Country] = [Country(id: 0, name: "USA", cities: [City(id: 0, name: "New York"),City(id: 1, name: "Los Angeles"),City(id: 2, name: "Dallas"),City(id: 3, name: "Chicago")]),Country(id: 1, name: "France", cities: [City(id: 0, name: "Paris")])]

@Published var selectedContry: Int = 0 {
willSet {
selectedCity = 0
id = UUID()
print("country changed")
}
}
@Published var id: UUID = UUID()
@Published var selectedCity: Int = 0
var countryNemes: [String] {
countries.map { (country) in
country.name
}
}
var cityNamesCount: Int {
cityNames.count
}
var cityNames: [String] {
countries[selectedContry].cities.map { (city) in
city.name
}
}
}

struct ContentView: View {
@ObservedObject var model = Model()
var body: some View {

return VStack {
Picker(selection: $model.selectedContry, label: Text("")){
ForEach(0 ..< model.countryNemes.count){ index in
Text(self.model.countryNemes[index])
}
}.labelsHidden()
.clipped()
Picker(selection: $model.selectedCity, label: Text("")){
ForEach(0 ..< model.cityNamesCount){ index in
Text(self.model.cityNames[index])
}
}
// !! changing views id force SwiftUI to recreate it !!
.id(model.id)

.labelsHidden()
.clipped()
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

here you can see the result
Sample Image

UPDATE

It could be even better if the current city selection will persist between different country selections.

Lets try to update our model and logic.

first add the storage

private var citySelections: [Int: Int] = [:]

and next update the model with new versions of

@Published var selectedContry: Int = 0 {
willSet {
print("country changed", newValue, citySelections[newValue] ?? 0)
selectedCity = citySelections[newValue] ?? 0
id = UUID()
}
}
@Published var selectedCity: Int = 0 {
willSet {
DispatchQueue.main.async { [newValue] in
print("city changed", newValue)
self.citySelections[self.selectedContry] = newValue
}
}
}

And HURRA!!! Now it is much more better!
Maybe you ask why

DispatchQueue.main.async { [newValue] in
print("city changed", newValue)
self.citySelections[self.selectedContry] = newValue
}

The answer is simple. "recreating" second Picker will reset its internal state, and because its selection is bind to our model, it will be reset to its initial state. The trick is postpone update of this property AFTER SwiftUI recreate it.

Sample Image

SwiftUI hierarchical Picker with dynamic data

Using the same code as provided in link mentioned in your question I made some small changes to adopt the code for your needs

struct ContentView: View {
@ObservedObject var model = Model()
var body: some View {
NavigationView {
Form {
Section(header: Text("Header").font(.title)) {
Picker(selection: $model.selectedContry, label: Text("Country")){
ForEach(0 ..< model.countryNemes.count){ index in
Text(self.model.countryNemes[index])
}
}
Picker(selection: $model.selectedCity, label: Text("City")){
ForEach(0 ..< model.cityNamesCount){ index in
Text(self.model.cityNames[index])
}
}
.id(model.id)
}
}.navigationBarTitle("Navigation Title")
}
}
}

Please see, that there is no VStack but Section in the Form!
The result works as expected. (the rest of code is without any changes). Try the code on real device (due the known "back button" bug in simulator)

Sample Image

In case you have some trouble with the rest of code, here it is

import Foundation
import SwiftUI

struct Country: Identifiable {
var id: Int = 0
var name: String
var cities: [City]
}

struct City: Identifiable {
var id: Int = 0
var name: String
}

class Model: ObservableObject {
let countries: [Country] = [Country(id: 0, name: "USA", cities: [City(id: 0, name: "New York"),City(id: 1, name: "Los Angeles"),City(id: 2, name: "Dallas"),City(id: 3, name: "Chicago")]),Country(id: 1, name: "France", cities: [City(id: 0, name: "Paris")])]

@Published var selectedContry: Int = 0 {
willSet {
print("country changed", newValue, citySelections[newValue] ?? 0)
selectedCity = citySelections[newValue] ?? 0
id = UUID()
}
}
@Published var id: UUID = UUID()
@Published var selectedCity: Int = 0 {
willSet {
DispatchQueue.main.async { [newValue] in
print("city changed", newValue)
self.citySelections[self.selectedContry] = newValue
}
}
}
var countryNemes: [String] {
countries.map { (country) in
country.name
}
}
var cityNamesCount: Int {
cityNames.count
}
var cityNames: [String] {
countries[selectedContry].cities.map { (city) in
city.name
}
}

private var citySelections: [Int: Int] = [:]
}

SwiftUI Dynamic Pickers in Form crash

The key to the problem is that you hide the picker, so it is best to set its transparency to 0

Picker("Picker Two", selection: $pickerTwoSelection) {
ForEach(1..<32, id: \.self) {
Text(String($0))
}
}
.pickerStyle(WheelPickerStyle())
.opacity(pickerTwoVisible ? 1 : 0)
.frame(width: pickerTwoVisible ? nil : 0, height: pickerTwoVisible ? nil : 0)

Disable a segment in a SwiftUI SegmentedPickerStyle Picker?

you can use this simple trick

import SwiftUI

struct ContentView: View {
@State var selection = 0
let data = [1, 2, 3, 4, 5]
let disabled = [2, 3] // at index 2, 3
var body: some View {

let binding = Binding<Int>(get: {
self.selection
}) { (i) in
if self.disabled.contains(i) {} else {
self.selection = i
}
}

return VStack {
Picker(selection: binding, label: Text("label")) {
ForEach(0 ..< data.count) { (i) in
Text("\(self.data[i])")
}
}.pickerStyle(SegmentedPickerStyle())
Spacer()
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Sample Image

Maybe something like

ForEach(0 ..< data.count) { (i) in
if !self.disabled.contains(i) {
Text("\(self.data[i])")
} else {
Spacer()
}
}

could help to visualize it better

Sample Image

NOTES (based on the discussion)

From user perspective, the Picker is one control, which could be in disabled / enabled state.

The option selected from Picker is not control, it is some value. If you make a list of controls presented to the user, some of them could be disabled, just to inform the user, that the action associated with it is not currently available (like menu, some buttons collection etc.)

I suggest you to show in Picker only values which could be selected. This collection of values could be updated any time.

UPDATE

Do you like something like this?

Sample Image

No problem at all ... (copy - paste - try - modify ...)

import SwiftUI

struct Data: Identifiable {
let id: Int
let value: Int
var disabled: Bool
}

struct ContentView: View {
@State var selection = -1
@State var data = [Data(id: 0, value: 10, disabled: true), Data(id: 1, value: 20, disabled: true), Data(id: 2, value: 3, disabled: true), Data(id: 3, value: 4, disabled: true), Data(id: 4, value: 5, disabled: true)]
var filteredData: [Data] {
data.filter({ (item) -> Bool in
item.disabled == false
})
}
var body: some View {
VStack {
VStack(alignment: .leading, spacing: 0) {
Text("Select from avaialable")
.padding(.horizontal)
.padding(.top)
HStack {
GeometryReader { proxy in
Picker(selection: self.$selection, label: Text("label")) {
ForEach(self.filteredData) { (item) in
Text("\(item.value.description)").tag(item.id)
}
}
.pickerStyle(SegmentedPickerStyle())
.frame(width: CGFloat(self.filteredData.count) * proxy.size.width / CGFloat(self.data.count), alignment: .topLeading)

Spacer()
}.frame(height: 40)
}.padding()
}.background(Color.yellow.opacity(0.2)).cornerRadius(20)
Button(action: {
(0 ..< self.data.count).forEach { (i) in
self.data[i].disabled = false
}
}) {
Text("Enable all")
}
Button(action: {
self.data[self.selection].disabled = true
self.selection = -1
}) {
Text("Disable selected")
}.disabled(selection < 0)
Spacer()
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}


Related Topics



Leave a reply



Submit