5

I just started working on SwiftUI and I have some difficulty to manage several Pickers with dynamic data. In that case there's two Pickers, for Country and City. When I try to switch the Picker from a country with more cities than the other, the app would crash :

Fatal error: Index out of range

Any idea how I could fix that ?

App Screenshot

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
}

struct ContentView: View {

    @State var selectedCountry = 0
    @State var selectedCity = 0

    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")])]

    var body: some View {
        VStack {
            Picker(selection: $selectedCountry,label: Text("")){
                ForEach(0 ..< countries.count){ index in
                    Text(self.countries[index].name)
                }
            }.labelsHidden()
            .clipped()
            Picker(selection: $selectedCity,label: Text("")){
                ForEach(0 ..< countries[selectedCountry].cities.count){ index in
                    Text(self.countries[self.selectedCountry].cities[index].name)
                }
            }.labelsHidden()
            .clipped()
        }
    }
}
  • @Asperi by definition, Picker must have fixed (constant) number of selectable values. otherwise it must be recreated. SwiftUI is not dynamically checking that, by the way you are informed about that with warning (while debugging the code) – user3441734 Feb 05 '20 at 20:21
  • @Asperi Yes thank you. I had to force refresh the Picker indeed. –  Feb 05 '20 at 21:49

1 Answers1

10

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 enter image description here

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.

enter image description here

user3441734
  • 16,722
  • 2
  • 40
  • 59
  • Thank you for the quick answer, it's working! –  Feb 05 '20 at 21:50
  • @onionjonas it could be even better! see the update – user3441734 Feb 05 '20 at 22:07
  • Wow thank you that's a great idea! I was actually working on a more complicated example with three Pickers (Picker#2 depending on Picker#1 selection and Picker#3 depending on Picker#1 and Picker#2 selections) so I added a storage for Picker#3 with `[[Int:Int] : Int]` to take into account the first two Pikers selections. –  Feb 07 '20 at 11:24
  • @onionjonas you could expand it easy, but what is this ?? [[Int:Int] : Int] it is Dictionary, Int> ?? are you sure that this is a good idea? make it as simple as possible! – user3441734 Feb 07 '20 at 12:08
  • Oh no my bad I ment `[[Int] : Int]` where the first array is the couple [Value of Picker#1, Value of Picker#2]. Because Picker#3 depends on the value of this couple. –  Feb 07 '20 at 16:02