Categories
Software

How to listen for changes in an @AppStorage value

For my flash cards app, I’ve added a few user settings that I’ve discussed in other posts. For example, users can choose whether they want to use the same ask language for all cards (e.g. Always first show words in Japanese). They can also choose whether they want to use かな or Romaji for Japanese. They can also choose a subset of the cards they want to use (eg numbers, months/daysOfTheWeek etc.)

Any time one of these settings gets changed by the user, the app’s LanguageManager singleton needs to update the list of available words that match the settings. (for example, if a given word does not have a French translation and the user specifies fr as their only answer language, that card can’t be shown to the user.)

Strictly speaking, available cards could be recalculated on demand. (ie each time a user switches to a new card) But that would be suboptimal, given the available cards only changes when one of the settings changes. But in the spirit of saving cycles, I feel like finding a way to calculate available cards only when the value changes is a worthy cause.

For a starting point, I have the following code where the UI is setting the UserDefault value.

struct CardPickerView: View {
    
    @AppStorage("cardPile") var cardPile: CardPile = .allRandom
    var body: some View {
        VStack {
            Picker("Card Pile", selection: cardPile) {
                ForEach(CardPile.allCases,
                        id: \.self) { 
                    Text($0.title)
                        .tag($0.rawValue)
                }
            }
        }
    }
}

And the following code where LanguageManager updates availableCards

class LanguagesManager {
    static let shared = LanguagesManager()

    // The UserDefaults values that will impact which cards/words can be used
    @AppStorage("scriptPickers") var scriptPickers:  ScriptPickers = ScriptPickers.defaultDictionary
    @AppStorage("languageChoice") var languageChoice: LanguageChoice = .all
    @AppStorage("cardPile") var cardPile: CardPile = .allRandom

    // All the words
    let words: Words

    // The words that match the criteria
    var availableWords: Words

    func updateAvailableWords() {
        var result = words.filtered(by: cardPile.wordList)
        let askLanguages = languageChoice.askLanguages
        let answerLanguages = languageChoice.answerLanguages
        result = result.matching(askIdentifiers: askLanguages, answerIdentifiers: answerLanguages)
        availableWords = result
    }
}

This code mostly works as expected, however when a user updates scriptPickers or languageChoice or cardPile, availableWords doesn’t get updated. <frownyFace!!> My first thought was to use didSet (like this)

    @AppStorage("cardPile") var cardPile: CardPile = .allRandom {
        didSet { updateAvailableWords() }
    }

Sadly didSet on @AppStorage only works in very specific situations for me. Based on what I saw, it works if:

  1. your code is in a View
  2. you explicitly update the value (eg. self.cardPile = .all)

Your mileage may vary. Bottom line: didSet in LanguagesManager was not getting called for me.

It would have been possible to add calls to updateAvailableWords() in the UI code any time one of the related settings changed. But relying on the UI code to keep LanguagesManager fresh. Just. Felt. Wrong.

I also tried to use combine to subscribe to the @AppStorage values, but was not able to get that working. <anotherFrownyFace>

The solution I ended up using was to create a UserDefaults extension that would create a Binding<> for a specific UserDefaults key value. Anytime this value changes, it will fire a notfication.

extension UserDefaults {
    func cardPileBinding(for defaultsKey: String) -> Binding<CardPile> {
        return Binding {
            let rawValue = self.object(forKey: defaultsKey) as? Int ?? 0
            return CardPile(rawValue: rawValue) ?? .allRandom
        } set: { newValue in
            self.setValue(newValue.rawValue, forKey: defaultsKey)
            NotificationCenter.default.post(name: .userDefaultsChanged, object: defaultsKey)
        }
    }
}

extension Notification.Name {
    static let userDefaultsChanged = Notification.Name(rawValue: "user.defaults.changed"
}

Next, I used these bindings in the UI code where I’d previously been using @AppStorage.

struct CardPickerView: View {
    let cardPileBinding: Binding<CardPile>
    init(userDefaults: UserDefaults = .standard) {
        self.cardPileBinding = userDefaults.cardPileBinding(for: "cardPile")
    }
    var body: some View {
        VStack {
            Picker("Card Pile", selection: cardPileBinding) {
                ForEach(CardPile.allCases,
                        id: \.self) { 
                    Text($0.title)
                        .tag($0.rawValue)
                }
            }
        }
    }
}

Then I updated LanguagesManager to listen for this notification.

class LanguagesManager {
    private var subscriptions = Set<AnyCancellable>()
    init() {
        // a bunch of unrelated init stuff....

        NotificationCenter.default.publisher(for: .userDefaultsChanged)
            .sink(receiveValue: { notification in
                self.updateAvailableWords()
            })
            .store(in: &subscriptions)
    }

And this meets my needs, insofar as the UI code doesn’t need to explicitly fiddle with LanguagesManager state any time a user changes their prefs. I don’t like that the UI code needs to use this special binding to do the updating. I also don’t like that the UserDefaults extension needs to define multiple functions to return the different types of Bindings. If you all know of a way to get around this (generics?) I’m all ears!

I also don’t like that the getters and setters in the Binding values need to translate to and from rawValue. @AppSupport magically handled the translations between rawValue and encodedValue.

And one last problem with this approach, for at least one of my settings UI inplementations, calling the setter in the binding didn’t trigger an update to the UI. (it’s on my todo list to investigate this.) I got around this problem by adding a ‘dummy’ @AppStorage var in the View. Adding this dummy value seems to force the view to redraw when the UserDefault value changes.

    @AppStorage("cardPile") var cardPileDummy: CardPile = .allRandom
Categories
Software

Flipping #*&$%@! Cards with SwiftUI

Despite the salty title this was a very low stress investigation. I wanted to find a way to use animation to flip my flash cards. There are many great sources to do something like this:

struct ContentView: View {
    @State var angle: CGFloat = 0    
    var body: some View {
        VStack {
            Text("contentText: frontText")
                .padding(40)
                .background(.green)
                .rotation3DEffect(.degrees(angle), axis: (x: 0.0, y: 1.0, z: 0.0))
                .animation(.default, value: angle)
            Button("Flip") {
                angle += 180
            }
        }
    }
}

Tapping the button in the above app will flip the card, and show an empty green rectangle. So how would I go about showing different (dynamic) content on the ‘back’ of the card? The idea I’m currently going with is to create a front content view and a back contentView. The back contentView begins pre-rotated, so that when the parent view gets rotated it ends up being the correct orientation,

        VStack {
            ZStack {
                FlippableContent(contentText: frontText)
                FlippableContent(contentText: rearText)
                    .rotation3DEffect(
                        .degrees(180), axis: axis)
            }
            .rotation3DEffect(.degrees(angle), axis: axis)
            .animation(.default, value: angle)
            Button("Flip") {
                angle += 180
            }
        }
...
struct FlippableContent: View {
    let contentText: String
    var body: some View {
        VStack {
            Text(contentText)
        }
        .frame(width: 300, height: 250)
        .background(.green)
    }
}

This is close, but not quite right. It always only ever shows the FlippableContent view showing rearText (sometimes forwards, sometimes reverse). I experimented with different angles on the FlippableContents, however I’m not entirely clear on exactly how multiple 3d rotation effects get combined. Not my circus, not my monkeys I guess. Tho I’m definitely a bit curious…

To fix my problem, I’ve created logic that creates different opacity values for the front and back content of the card.

extension CGFloat {
    var rearOpacity: CGFloat {
        return (self / 180).truncatingRemainder(dividingBy: 2)
    }
    var frontOpacity: CGFloat {
        return 1 - rearOpacity
    }
}

And these new functions get used in opacity modifiers in the ZStack.

            ZStack {
                FlippableContent(contentText: frontText)
                    .opacity(angle.frontOpacity)
                FlippableContent(contentText: rearText)
                    .rotation3DEffect(
                        .degrees(180), axis: axis)
                    .opacity(angle.rearOpacity)
            }

Now the view gets initialized with angle at zero, which shows the front text. When the user flips the card, angle gets increased by 180 degrees. This animates through the rotation, and hides the front face, and shows the rear face. On the next flip, the rear face gets hidden and the front face gets shown.

Categories
Software

AppStorage part 2

Turns out this topic needs more than just one post. In part 1, I described how I was able to use AppStorage for an enum with associated values.

Here I will discuss a challenge I encountered when attempting to build the View to enable users to change their value for the languageChoice enum.

enum LanguageChoice: Equatable, Codable {
    case all
    case oneToOne(Language, Language)
    case oneToAll(Language)
    case allToOne(Language)
    case symmetricSubset(Set<Language>)

Step 1, create a version of LanguageChoice with no associated values:

    enum SimpleLanguageChoice: String, CaseIterable {
        case all
        case oneToOne
        case oneToAll
        case allToOne
        case symmetricSubset
    }

Step 2, create a state variable using the new enum type and bind it to a picker:

    @State var languageChoice: SimpleLanguageChoice = .all
    var body: some View {
        VStack {
            Picker("Languages Choice", selection: $languageChoice) {
                ForEach(SimpleLanguageChoice.allCases, id: \.self) {
                    Text($0.rawValue)
                        .tag($0)
                }
            }
        }
    }

Step 3, add UI below the picker to display the appropriate controls to go with the value of languageChoice.

    @ViewBuilder var bottomStuff: some View {
        switch languageChoice {
        case .all:
            EmptyView()
        case .oneToAll:
            Picker("Ask in", selection: $language) {
                ForEach(Language.allCases, id: \.self) {
                    Text($0.rawValue)
                        .tag($0)
                }
            }
     // et cetera

Step 4, add an AppStorage var to get the persisted LanguageChoice value and use it to configure the Picker UI.

    @AppStorage("languageChoice") var persistedLanguageChoice: LanguageChoice = .all
    @State var languageChoice: SimpleLanguageChoice

init() {
     languageChoice = SimpleLanguageChoice.simpleChoice(for: persistedLanguageChoice)
}

Insert record scratch sound here! This code created the following compiler error:
'self' used before all stored properties are initialized

I wanted to use the persisted languageChoice to set up the UI to allow users to choose a new languageChoice. But doing this required reading persisted languageChoice, which was not allowed. Alas. So to get things to work I needed to set a default value for languageChoice in the declaration and then update the value in init. Just do this, right?

    @State var languageChoice: SimpleLanguageChoice = .all
    init() {
        languageChoice = SimpleLanguageChoice.simpleChoice(for: persistedLanguageChoice)

Nope. If you set an initial value for a State variable in the declaration, updating it in init gets more complicated. You need to do this:

    init() {
        _languageChoice = State(initialValue: SimpleLanguageChoice.simpleChoice(for: persistedLanguageChoice)

Categories
Software

SwiftUI AppStorage wrinkles

The SwiftUI property wrapper AppStorage is a great way to cut down on boiler plate code needed to manage/access UserDefaults. At the risk of sounding like I’m looking a gift horse in the mouth, I feel compelled to describe a couple of challenges I encountered while using AppStorage in my current project, the language flash cards app.

AppStorage for enums with associated values

The type I wanted to persist using AppStorage was a fairly gnarly enum with a variety of associated values. It is the value that persists the users preferences for languages to use for asking and answering. See this blog post for more details on how this enum came to be.

enum LanguageChoice: Equatable, Codable {
    case all
    case oneToOne(Language, Language)
    case oneToAll(Language)
    case allToOne(Language)
    case symmetricSubset(Set<Language>)
}

Out of the box, AppStorage only works with the most basic of types. (Int, String, Bool, etc.) There are several great resources on how to extend AppStorage support to basic structs. If you google “AppStorage RawRepresentable” you’ll be away to the races in no time. The tl;dr version is: implement
public init?(rawValue: String)
and
public var rawValue: String

My first inclination was to use JSONDecoder() in init and JSONEncoder() in rawValue. Unfortunately JSONEncoder() was calling rawValue, which was calling JSONEncoder() etc.

So instead I defined a custom scheme to encode (and decode) the LanguageChoice enum. This required a fair bit of custom code (see below) and allowed LanguageChoice to work with @AppStorage. But I wasn’t out of the woods yet. There was still more work to be done, which I’ll describe in a future post.

Here is how I chose to conform to RawRepresentable.

    public init?(rawValue: String) {
        let components = rawValue.components(separatedBy: ":")
        guard let first = components.first else {
            return nil
        }
        switch first {
        case "all":
            self = .all
        case "oneToOne":
            guard components.indices.contains(2),
                  let lang1 = Language.init(rawValue: components[1]),
                  let lang2 = Language.init(rawValue: components[2]) else {
                return nil
            }
            self = .oneToOne(lang1, lang2)
        case "oneToAll":
            guard components.indices.contains(1),
                  let lang = Language.init(rawValue: components[1]) else {
                return nil
            }
            self = .oneToAll(lang)
        case "allToOne":
            guard components.indices.contains(1),
                  let lang = Language.init(rawValue: components[1]) else {
                return nil
            }
            self = .allToOne(lang)
        case "symmetricSubset":
            guard components.indices.contains(1) else {
                return nil
            }
            let languageComponents = components[1].components(separatedBy: ",")
            let languages: Set<Language> = Set(languageComponents.compactMap { Language(rawValue: $0) } )
            self = .symmetricSubset(languages)
        default:
            return nil
        }
    }

    public var rawValue: String {
        let data = try? JSONEncoder().encode(self)
        print("data: \(data)")
        let result: String
        switch self {
        case .all:
            result = "all"
        case .oneToOne(let language, let language2):
            result = "oneToOne:\(language.rawValue):\(language2.rawValue)"
        case .oneToAll(let language):
            result = "oneToAll:\(language.rawValue)"
        case .allToOne(let language):
            result = "allToOne:\(language.rawValue)"
        case .symmetricSubset(let set):
            let setRawValue = set.sorted().reduce("") {
                $0 + $1.rawValue + ","
            }
            
            result = "symmetricSubset:\(setRawValue)"
        }
        return result
    }

Unit tests for this code have been left as an exercise for the student.

Categories
Software

Binding to a Dictionary

SwiftUI is mostly awesome, but sometimes in the corners things get a bit messy. In my recent work on the multi-lingual flash cards app, I encountered one such corner.

I am planning to include languages that use writing systems other than the good ol’ Western Alphabet. These include:

  • ਜਪਾਨੀ (Japanese)
  • 旁遮普语 (Punjabi)
  • китайський (Chinese)
  • ウクライナ語 (Ukrainian)

Just for fun, the above list is: Japanese (in Punjabi), Punjabi (in Chinese), Chinese (in Ukrainian), and Ukrainian (in Japanese)

When using the app, I want to give users the choice to see these words in their native script or in the ‘Roman’ alphabet. But I didn’t immediately how to implement this ‘feature.’

I saw two challenges:

  1. How to store the differently scripted versions of the same language
  2. How to map user preferences to the list of languages to use when quizzing users.

Problem #1: Storing the Languages

I ended up creating multiple localizations for each languages. For Japanese I used ‘ja’ and ‘ja-JP.’ The ‘ja’ localization stores the kana (and possibly kanji) version of the flash card content. The ‘ja-JP’ localization stores the romaji version of the content.

To be honest, I don’t LOVE this implementation option, but I really didn’t see anything better. Try not to judge me too harshly!

Problem #2: Mapping the user preferences to the languages list

Thanks to my solution to problem #1, the internal list of available languages will now look something like: en, fr, ja, ja-JP. But we never want to show users this list. Instead we will want to show them either ja or ja-JP. Depending on a user’s preferences, their language list will either be: en, fr, ja OR en, fr, ja-JP.

For each language with local script or western/roman alphabet options, the user will set a bool preference value. The bool preference values will be used to create a set of excluded languages. Here is the logic for the case where Japanese is the only multi-script language.

    var scriptExcludedLanguages: [Language] {
        let ja: Language = useNativeScript ? .ja_roman : .ja_nonRoman
        return [ja]
    }

The app can then remove the scriptExcludedLanguages to generate the list of languages available to the current user with the following code:

    var allLanguages: Set<Language> {
        let result = Language.allLanguages.subtracting(scriptExcludedLanguages)
        return result
    }

In the course of implementing this feature, I uncovered one other piece that ended up being non-obvious.

Problem #3 Binding the UI and UserDefaults for the Dictionary of Bools

First I defined the type:
typealias ScriptPickers = [String: Bool]
And then in the picker view added the following AppStorage property:
    @AppStorage("scriptPickers") var scriptPickers:  ScriptPickers = ScriptPickers.defaultDictionary
This lead to the following cryptic compiler error:
No exact matches in call to initializer 
It turned out the fix for this was to make ScriptPickers conform to RawRepresentable. Here’s what that looks like:

extension ScriptPickers: RawRepresentable where Key == String, Value == Bool {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),  
            let result = try? JSONDecoder().decode(ScriptPickers.self, from: data)
        else {
            return nil
        }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),   
              let result = String(data: data, encoding: .utf8) 
        else {
            return "{}"  // empty Dictionary respresented as String
        }
        return result
    }

}
// hat tip: actw https://stackoverflow.com/questions/65382590/how-to-use-appstorage-for-a-dictionary-of-strings-string-string 

But there was still the need to map the UI toggles to the ScriptPickers dictionary. Each language toggle needs a binding to the corresponding entry in dictionary. Here is the basic structure for doing that.

struct ContentView: View {
    
    @AppStorage("scriptPickers") var scriptPickers:  ScriptPickers = ScriptPickers.defaultDictionary

    var body: some View {
        VStack {
            ForEach(scriptPickers.keys.sorted(), id: \.self) { key in
                Toggle(key, isOn: binding(for: key))
            }
        }
        .padding()
    }
    private func binding(for key: String) -> Binding<Bool> {
        return .init(
            get: { self.scriptPickers[key, default: false] },
            set: { self.scriptPickers[key] = $0 })
    }

}

I am no noticing that some languages don’t just have roman and non-roman options. Punjabi for example can be expressed in Gurmukhi, Shamukhi, and the roman alphabet. Japanese can be expressed in Kanji (pictograms), Kana (non-Roman alphabets), and Romaji (using the Roman alphabet.)
So in the future, it feels like this code may need to be extended. Also, it feels like the languages should be expressed as an enum, rather than as strings.

Categories
Software

SwiftUI Being a bit Quirky

The Ecstasy and the Agony of Making Software

I recently wanted to create some content that would sometimes display in an HStack and sometimes in a VStack. The content was the same in both cases, so I wanted to avoid doing:

if goVertical {
    VStack {
        thing1
        thing2
    }
} else {
    HStack {
        thing1
        thing2
    }
}

Fortunately, I was able to use a ViewBuilder to contain the contents of the stacks.

@ViewBuilder var stackContents: some View {
    thing1
    thing2
}

...

if goVertical {
    VStack {
        contents
            .padding(.horizontal)
    }
} else {
    HStack {
        contents
    }
}

So far so good. (Aside: Am I the only one who didn’t know @ViewBuilder can be used to return a list of SwiftUI views?!) However I eventually got myself into a bit of trouble with my stack contents in an interesting way. Here are the details.

First you need to know that thing1 is an image and thing 2 is a VStack of sliders, steppers and such. Here’s how the view looks in portrait and landscape.

BUT… since I will be adding more sliders, steppers and such, I moved them into a ScrollView. But (as you can see on the right) when I did this, The ScrollView took more height than it needed and short changed the Image.

My first thought was in use a layoutPriority modifier on the image, but this had no effect, so I removed it. Next, I tried adding background modifiers to the VStack contents, and that proved to be very illuminating. (see below)

Here is the code with the background modifiers:

        VStack {
            contents
                .background(.red)
                .padding(.horizontal)
                .background(.blue)
        }
        .background(.yellow)

Applying horizontal padding to contents did not apply it once, it (presumably) iterated through all the items in contents and applied the padding to each item in contents.

I moved the padding (and background modifiers to apply to the VStack, instead of contents

        VStack {
            contents
        }
        .background(.red)
        .padding(.horizontal)
        .background(.blue)

Great, this looks more like what I was expecting with the padding. But the image is still narrower.

So I re-added the layoutPriority modifier to the image, and that did the trick. I still don’t understand why the ScrollView ‘steals’ vertical space from the Image. (And maybe one day I’ll better understand what causes me to anthropomorphize SW modules like layout engines.)

The interesting thing for me in this issue was how doing something incorrectly (ie applying the horizontal padding to the contents of the VStack, rather than to the VStack) worked correctly at first, but when the butterfly flapped its wings (ie one of the elements in contents was wrapped in a ScrollView) the wheels fell off.

Encountering and eventually overcoming this type of problem is a big part of the ecstasy and the agony of writing software.

Categories
Software

A SwiftUI Picker Using an Swift Enum Part 2

In Part 1, we created a basic SwiftUI Picker that was bound to an enum variable that included n possible values. When users pick a value from the picker, the app’s data model is aware of this change and the UI updates to reflect the user’s selection.

In this post we are going to extend this basic functionality.

Display Text for Sort Types

Life would be better if we could customize the display text for each of the different sort types. To do this we will add a function to our enum.

    func displayText() -> String {
        switch self {
        case .name:
            return NSLocalizedString("Name", comment: "display text for sort type: name")
        case .height:
            return NSLocalizedString("Height", comment: "display text for sort type: height")
        case .averageScore:
            return NSLocalizedString("Average Score", comment: "display text for sort type: averageScore")
        }
    }

In order to use this new function, replace rawValue calls with displayText() calls.

struct ContentView: View {
    @ObservedObject var settings = Settings.shared
    var body: some View {
        VStack {
            Picker(selection: $settings.sortType, label: Text("Sort Type")) {
                ForEach(SortType.allCases, id: \.self) { sortType in
                    Text("\(sortType.displayText())")
                }
            }
            Text("sort type is: \(settings.sortType.displayText())")
        }
    }
}

Persist Preferred Sort Type

In this section we will add code to remember a user’s previously selected sort type. So if the user closes the app and relaunches it, their preferred sort type will still be selected. To do this, we will write the sortType to UserDefaults, and then read this value when the app launches. These changes will be made in the Settings class.

class Settings: ObservableObject {
    static let shared = Settings()
    @Published var sortType: SortType {
        didSet {
            UserDefaults.standard.setValue(sortType.rawValue, forKey: "sortType")
        }
    }
    init() {
        sortType = SortType(rawValue: UserDefaults.standard.string(forKey: "sortType") ?? "name") ?? .name
    }
}

I’m mildly pained by the need to include both a fallback value for the string read from UserDefaults and also a fallback value SortType(rawValue: ) return value. I guess this is just my way to demonstrate that I don’t like forced unwraps !

Add a New Sort Type

So what happens when end requirements change and now our data can also be sorted by…. let’s say Shoe Size? What needs to change in our example? In fact, very little needs to change. Basically just add the new enum case, and add a corresponding case to the displayText function

enum SortType: String, CaseIterable {
    case name
    case height
    case averageScore
    case shoeSize
    
    func displayText() -> String {
        switch self {
        case .name:
            return NSLocalizedString("Name", comment: "display text for sort type: name")
        case .height:
            return NSLocalizedString("Height", comment: "display text for sort type: height")
        case .averageScore:
            return NSLocalizedString("Average Score", comment: "display text for sort type: averageScore")
        case .shoeSize:
            return NSLocalizedString("Shoe Size", comment: "display text for sort type: shoeSize")
        }
    }
}
Categories
Software Uncategorized

A SwiftUI Picker Using an Swift Enum

These two items (the SwiftUI Picker and a Swift enum) work really well together. Some might say they go together as well as Peanut Butter and Banana.

Requirement: Your app needs a way for a user to choose how to sort their list items. Today list items can be sorted by Name, Height and Average Score. Some time in the future, the list of sort types is expected to grow.

Eventually we are going to need some UI for this, but let’s start be defining an enum to define the sort types. Our enum needs to conform to CaseIterable because we will need to call the allCases class method. I don’t think String is required, but is helpful in the initial stage before we polish the UI.

enum SortType: String, CaseIterable {
    case name
    case height
    case averageScore
}

And also a Settings model object to store our source of truth (ie sort type) We will access the Settings singleton via the shared static variable. Settings needs to conform to ObservableObject because the Picker will bind to the sortType property.

class Settings: ObservableObject {
    static let shared = Settings()
    @Published var sortType: SortType
    init() {
        sortType = .name
    }
}

For the UI, we will use the following Picker init

    public init(selection: Binding<SelectionValue>, label: Label, @ViewBuilder content: () -> Content)

The UI content view will start with something like this:

struct ContentView: View {
    @ObservedObject var settings = Settings.shared
    var body: some View {
        VStack {
            Picker(selection: $settings.sortType, label: Text("Sort Type")) {
                ForEach(SortType.allCases, id: \.self) { sortType in
                    Text("\(sortType.rawValue)")
                }
            }
            Text("sort type is: \(settings.sortType.rawValue)")
        }
    }
}

If we run this code, we’ll see a picker above a text label. When we pick a different value in the picker, the text label updates accordingly. Woot!

In Part 2, we will:

  1. Improve the UI by adding display names for the sort types
  2. Use UserDefaults to persist and recall the selected sort type
  3. Add another sort type