I recently encountered a situation with SwiftUI Pickers that I thought would make an interesting blog post. I’m going to walk through what I encountered and how I handled it.

Simple Picker example with strings

This SwiftUI picker starts with the second item selected, which is “Figment” from the variable ‘dragon’. You can select other choices with the Picker, but once this app is restarted it’s going to go back to “Figment” because that’s what we’re initializing the pickerSelection State variable to.

let mouse = "Mickey"
let dragon = "Figment"
let duck = "Donald"
let debatable = "Goofy"
let allCharacters = [mouse, dragon, duck, debatable]

struct ContentView: View {
    @State private var pickerSelection = dragon
    
    var body: some View {
        NavigationView {
            Form {
                Picker("Favorite", selection: $pickerSelection) {
                    ForEach(allCharacters, id:\.self) { oneThing in
                        Text(oneThing)
                    }
                }
            }
        }
    }
}

The AppStorage property wrapper added in iOS 14 was such a nice addition. It easily lets us access saved data in UserDefaults, will cause our View to be updated when the value changes, and lets us specify a default value.

    //@State private var pickerSelection = dragon
    @AppStorage("character storage") private var pickerSelection : String = dragon

By changing just one line we’ve made our Picker selection persistent. If “character storage” isn’t found in UserDefaults then a value for ‘dragon’ is used. But if we select another character with the Picker then that choice will be remembered when the app restarts.

Objects as the data source

But I wanted to use a Picker with objects. Here’s a struct I created for this example. It shows the result of the reactions produced by combining two things.

struct Combinations : Identifiable {
    let id = UUID()
    let name : String
    let first : String
    let second : String
    let result : String
    let happinessPercentage : Int
}

I made some test data to populate the source array:

let allCombos = [Combinations(name: "parks",
                              first: "🎡",
                              second: "🎢",
                              result: "😃",
                              happinessPercentage: 95),
                 Combinations(name:  "island",
                              first: "🗿",
                              second: "🌴",
                              result: "👍",
                              happinessPercentage: 96),
                 Combinations(name: "abomination",
                              first: "🍕",
                              second: "🍍",
                              result: "🤮",
                              happinessPercentage: 0)]

I switched the Picker to use the index of my array for the binding. It will default to zero, but once a selection is made it will remember what index was chosen if the app restarts.

    @AppStorage("my combo") private var pickerIndex = 0

    var body: some View {
        NavigationView {
            Form {
                Picker("Favorite", selection: $pickerIndex) {
                    ForEach(allCombos.indices, id:\.self) { oneIndex in
                        let foundCombo = allCombos[oneIndex]
                        Text("\(foundCombo.first)+\(foundCombo.second)=\(foundCombo.result)(\(foundCombo.happinessPercentage)%)")
                            .tag(oneIndex)
                    }
                }
            }
        }
    }

As you can see the Picker will display all of the choices from our array.

The Picker showing our choices from our array of objects.

This initially seemed great but then I realized there was a problem that could happen in the future. What if, for example, the user selected the second item but then I later added more entries to the beginning of the array the Picker is displaying? That will shift the order of the existing items and would leave the stored selection pointing to the wrong item.

Since storing an integer array index number didn’t seem like the greatest solution I started researching more what you could and couldn’t store related to Pickers with AppStorage. As usual Paul Hudson was helpful. Here’s what he said about the the topic:

However, right now at least @AppStorage doesn’t make it easy to handle storing complex objects such as Swift structs – perhaps because Apple wants us to remember that storing lots of data in there is a bad idea!

So I can’t store the selected struct via AppStorage. But what I really need is just a way to save what struct was chosen. Since the “name” variable for each struct is something I’m going to keep unique I decided to go with that.

Note: If you’d rather not dive into the guts of how I ensured unique, sorted data (and other ways I could have accomplished the same thing) just use this link to jump forward to the rest of the discussion about Pickers.

Ensuring unique data

First I created an array with the “map” method. The closure we pass to map tells it how to create the array. I’m telling it just to return the name. I could have used the $0 shortcut, but I prefer naming my closure arguments. This will produce an array that contains the names of individual structs in the source array.

let justNames = allCombos.map { oneItem in
    oneItem.name
}

I want to make sure that all of these names are unique. This post from Cameron Bardell got me to revisit this blog post from Donny Wals recently so I got a reminder that Set is a great way to do that.

I can turn my array into a set and back into an array again. After that is done all array elements are going to be unique.

let justNames = Array(Set(allCombos.map { oneItem in
    oneItem.name
}))

We can also add a call to sorted() to ensure that the names are in order.

let justNames = Array(Set(allCombos.map { oneItem in
    oneItem.name
})).sorted()

As you can see that even though the Picker isn’t displaying the “name” variable it is using it to sort. The “abomination” has moved to the top of the choices.

The Picker showing our choices from our array of objects.

January 2, 2021 Update

Michael Hulet comes through with another great tip.

This is all I had to do to get a sorted Array back from my Set.

let justNames = Set(allCombos.map { oneItem in
    oneItem.name
}).sorted()

Swift Algorithms

David Steppenbeck, creator of my favorite widget [via McClockface], pointed out using uniqued() would be another way to do this.

uniqued() is a part of Apple’s open-source Swift Algorithms package. Of course Paul Hudson has an excellent write-up about it. All I have to do to use methods from Swift Algorithms is add the “swift-algorithms” package to my project and add “import Algorithms” to the top of my code. By just calling uniqued() I avoid having to change the array to a set and back to an array.

let justNames = allCombos.map { oneItem in
    oneItem.name
}.uniqued().sorted()

Tying everything together

Now that we’ve got a sorted, unique array of the names of our objects, how do we tie that to our Picker? First of all we’ll switch back to using a string with AppStorage. I just specified the first sorted name as the default. Since the array of names is always going to be populated I did a force unwrap when calling first(), which returns an optional value.

    @AppStorage("my combo") private var theComboName : String = justNames.first!

Inside of the Picker I use each name to find its associated struct via the first(where:) method. I pass it a closure that looks for a matching name in the array of structs.

    var body: some View {
        NavigationView {
            Form {
                Picker("Favorite", selection: $theComboName) {
                    ForEach(justNames, id:\.self) { oneName in
                        if let foundCombo = allCombos.first(where: { oneCombo in
                            oneCombo.name == oneName
                        }) {
                            Text("\(foundCombo.first)+\(foundCombo.second)=\(foundCombo.result)(\(foundCombo.happinessPercentage)%)")
                                .tag(oneName)
                        }
                    }
                }
            }
        }
    }

Benefits

By doing this I’ve accomplished these things:

  • Instead of storing the entire struct in UserDefaults I’m just storing a string when saving the selection.
  • I can add more structs that go anywhere in the sorted array and it will not affect the existing saved selection.
  • The selection will be retained if the app restarts.

Other Views that want to make use of the current Picker selection can do something similar to what we just did (read the name via AppStorage and find the individual struct associated with the name).

Bonus

I also thought of the scenario where the existing saved selection is deleted and therefore no longer offered as a choice by the Picker. I fixed that by adding this onAppear() to the NavigationView. If the saved name isn’t found in the array of names then change the value of the AppStorage ’theComboName’ variable to be the first name in the array. Now the Picker will always have an active selection.

        .onAppear() {
            if nil == allCombos.first(where: { oneCombo in
                oneCombo.name == theComboName
            }) {
                theComboName = justNames.first!
            }
        }

Update January 7, 2022

I got a wonderful tip from Limited DFS that a Dictionary could prevent performance issues with a large amount of data.

Let’s break down this tip.

It’s using the object’s name as the key for the Dictionary. By switching to a Dictionary the concern about duplicate names is gone. A Dictionary created with Dictionary(uniqueKeysWithValues:) cannot contain duplicate keys! We can still use map() but this time we’ll be using it to populate a Dictionary.

let combosByName = Dictionary(uniqueKeysWithValues: allCombos.map({ oneCombo in
    (oneCombo.name, oneCombo)
}))

Now that we know that all keys, which are names, are unique we can extract them using the “keys” collection, which returns just the keys. Add a call to the sorted() method on the same line and we’re back to having a sorted array of unique names.

let justNames = combosByName.keys.sorted()

The ForEach in the Picker can be changed to use the Dictionary.

                Picker("Favorite", selection: $theComboName) {
                    ForEach(justNames, id:\.self) { oneName in
                        if let foundCombo = combosByName[oneName] {
                            Text("\(foundCombo.first)+\(foundCombo.second)=\(foundCombo.result)(\(foundCombo.happinessPercentage)%)")
                                .tag(oneName)
                        }
                    }
                }

Also the code in the .onAppear can be more concise using a Dictionary. It still retains the logic of using the first entry in the sorted array if the saved entry isn’t found.

        .onAppear() {
            if nil == combosByName[theComboName] {
                theComboName = justNames.first!
            }
        }

Here’s the entire modified version:

let combosByName = Dictionary(uniqueKeysWithValues: allCombos.map({ oneCombo in
    (oneCombo.name, oneCombo)
}))
let justNames = combosByName.keys.sorted()

struct ContentView: View {
    @AppStorage(Combinations.UserDefKey) private var theComboName : String = justNames.first!

    var body: some View {
        NavigationView {
            Form {
                Picker("Favorite", selection: $theComboName) {
                    ForEach(justNames, id:\.self) { oneName in
                        if let foundCombo = combosByName[oneName] {
                            Text("\(foundCombo.first)+\(foundCombo.second)=\(foundCombo.result)(\(foundCombo.happinessPercentage)%)")
                                .tag(oneName)
                        }
                    }
                }
            }
        }
        .onAppear() {
            if nil == combosByName[theComboName] {
                theComboName = justNames.first!
            }
        }
    }
}

I really appreciate Limited DFS taking the time to provide sample code with this alternate approach. I love learning and this is an approach I wouldn’t have considered.

Conclusion

I often mention in my blog posts that what I post isn’t necessarily the best way of doing things. It’s what I’ve seen or come up with on my own as I learn SwiftUI. This time I really mean it because I’m not sure what I came up with is ideal or the “right” way to do things. As always, I welcome feedback about my approach.

If this post was helpful to you I’d love to hear about it! Find me on Twitter.