Swift Talk # 440

Keeping Local View State in Sync

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We explore the pitfalls of keeping local view state in sync using the date picker component from last episode.

00:06 We're still working on our date picker that lets us select a date from semantic values — "tonight" or "tomorrow" — or a custom date value. As discussed last week, it's unfortunate that we always reset the custom date to a default value whenever we switch from "tomorrow" to the custom option. So, today we want to work on keeping that custom date state around.

00:30 The other thing we want to do is replace the buttons with toggles, and we'll write a custom toggle style to make the picker behave more like a radio button group.

01:03 But first, we can do some cleanup. We can get rid of everything we used to check if our binding forces view updates — i.e. the background color helper, the subscript on Time, the toggle, and the extra state property:

struct TimePicker: View {
    @State private var selection: Time

    var body: some View {
        VStack {
            Button("Tonight") { selection = .tonight }
            Button("Tomorrow") { selection = .tomorrow }
            Button("Custom") { selection = .custom(Date.now) }
            if case .custom = selection {
                DatePicker("Custom date", selection: $selection.customDateOrDefault)
                    .labelsHidden()
            }
            Text("\(selection)")
        }
    }
}

01:55 We also turn the selection property into a binding, so that the state can be stored outside of the picker view:

struct TimePicker: View {
    @Binding var selection: Time

    var body: some View {
        // ...
    }
}

struct ContentView: View {
    @State private var selection: Time = .tonight

    var body: some View {
        VStack {
            TimePicker(selection: $selection)
        }
        .padding()
        .dynamicTypeSize(.xxxLarge)
    }
}

Toggle Style

02:37 We now want to style the picker's options with a custom toggle style. Since a toggle works on a Boolean binding, we need a way to derive Boolean bindings from our selection binding. As we found out previously, we definitely don't want to construct these bindings with getter and setter closures, because those types of bindings cause unnecessary view invalidations.

03:12 Instead, we can write a helper property that returns true if the selection is in the correct case. And in the property's setter, we update the selection if the received value is true.

04:05 It'd be handy to have a macro or a library like CasePaths for this, because we need one of these properties for each of the options:

extension Time {
    var isTonight: Bool {
        get {
            if case .tonight = self { true } else { false }
        }
        set {
            if newValue { self = .tonight }
        }
    }

    var isTomorrow: Bool {
        get {
            if case .tomorrow = self { true } else { false }
        }
        set {
            if newValue { self = .tomorrow }
        }
    }

    
}

04:29 In the helper property for the .custom case, we're faced with the question of what date to apply in the setter. For now, we can use the current date as the default:

extension Time {
    // ...

    isCustom: Bool {
        get {
            if case .custom = self { true } else { false }
        }
        set {
            if newValue { self = .custom(Date.now) }
        }
    }
}

04:49 Now we can replace the buttons with toggles in TimePicker, using the helper properties to create Boolean bindings for the toggles:

struct TimePicker: View {
    @Binding var selection: Time

    var body: some View {
        VStack {
            Toggle("Tonight", isOn: $selection.isTonight)
            Toggle("Tomorrow", isOn: $selection.isTomorrow)
            Toggle("Custom", isOn: $selection.isCustom)
            if case .custom = selection {
                DatePicker("Custom date", selection: $selection.customDateOrDefault)
                    .labelsHidden()
            }
            Text("\(selection)")
        }
        
    }
}

05:42 We write a custom ToggleStyle to change the way the control is rendered. From the style's body method, we can return any view we want to construct using the label and the binding given to us via the configuration parameter. For example, we can return a button that sets the Boolean binding to true in its action closure, and we can make the button's label bold if the binding is set to true:

struct TimePickerStyle: ToggleStyle {
    func makeBody(configuration: Configuration) -> some View {
        Button {
            configuration.isOn = true
        } label: {
            configuration.label
        }
        .bold(configuration.isOn)
    }
}

07:41 And then we apply the new toggle style:

struct TimePicker: View {
    @Binding var selection: Time

    var body: some View {
        VStack {
            Toggle("Tonight", isOn: $selection.isTonight)
            Toggle("Tomorrow", isOn: $selection.isTomorrow)
            Toggle("Custom", isOn: $selection.isCustom)
            if case .custom = selection {
                DatePicker("Custom date", selection: $selection.customDateOrDefault)
                    .labelsHidden()
            }
            Text("\(selection)")
        }
        .toggleStyle(TimePickerStyle())
    }
}

08:04 We can now switch between the different options, and the currently selected option is highlighted with a bold font. However, we still have the issue that we lose the selected date when we switch back and forth between the custom date option and one of the other options.

Custom Date State

08:24 We need to store the selected date, and we have to decide if we want to make it a part of the model, or if it's a transient piece of state for the user interface. If we wanted to change our model, we'd create a new struct that stores both an enum of the selected option as well as a custom date value:

struct TimeAlt {
    var payload: TimeAltPayload
    var date: Date

    enum TimeAltPayload {
        case tonight
        case tomorrow
        case custom
    }
}

09:23 The problem with this approach is that it creates an invalid state for our model: it doesn't make much sense for both the .tonight case and a custom date value to be true at once. So perhaps we should store the custom date value as an extra piece of state of the picker view.

09:38 But we have to pay attention to how we use this state. If we simply use the separate date state property for the date picker, we aren't actually updating the selection when we choose a new date:

struct TimePicker: View {
    @Binding var selection: Time
    @State private var date = Date()

    var body: some View {
        VStack {
            Toggle("Tonight", isOn: $selection.isTonight)
            Toggle("Tomorrow", isOn: $selection.isTomorrow)
            Toggle("Custom", isOn: $selection.isCustom)
            if case .custom = selection {
                DatePicker("Custom date", selection: $date)
                    .labelsHidden()
            }
            Text("\(selection)")
        }
        .toggleStyle(TimePickerStyle())
    }
}

10:05 We have to use onChange(of:) to update the selection binding whenever the date state is changed:

struct TimePicker: View {
    @Binding var selection: Time
    @State private var date = Date()

    var body: some View {
        VStack {
            Toggle("Tonight", isOn: $selection.isTonight)
            Toggle("Tomorrow", isOn: $selection.isTomorrow)
            Toggle("Custom", isOn: $selection.isCustom)
            if case .custom = selection {
                DatePicker("Custom date", selection: $date)
                    .labelsHidden()
            }
            Text("\(selection)")
        }
        .toggleStyle(TimePickerStyle())
        .onChange(of: date) {
            selection = .custom(date)
        }
    }
}

10:42 But when we switch to a different option and then return to the custom date, the date picker and the selection value are out of sync again. When we switch to the custom date option, we have to write the value of the date property into the selection value. But currently, we're using the current time as a default value:

extension Time {
    // ...
    var isCustom: Bool {
        get {
            if case .custom = self { true } else { false }
        }
        set {
            if newValue { self = .custom(Date.now) }
        }
    }
}

11:20 In order to pass another value in, we can convert the helper property into a subscript:

extension Time {
    // ...
    subscript(custom date: Date) -> Bool {
        get {
            if case .custom = self { true } else { false }
        }
        set {
            if newValue { self = .custom(date) }
        }
    }
}

12:18 We really don't like this API, because it's impossible to tell what the subscript does just by looking at its signature. Perhaps we shouldn't make these properties public, but keep them as local helpers in the picker view.

13:06 We use the subscript to construct the binding for the custom date toggle:

struct TimePicker: View {
    @Binding var selection: Time
    @State private var date = Date()

    var body: some View {
        VStack {
            Toggle("Tonight", isOn: $selection.isTonight)
            Toggle("Tomorrow", isOn: $selection.isTomorrow)
            Toggle("Custom", isOn: $selection[custom: date])
            if case .custom = selection {
                DatePicker("Custom date", selection: $date)
                    .labelsHidden()
            }
            Text("\(selection)")
        }
        .toggleStyle(TimePickerStyle())
        .onChange(of: date) {
            selection = .custom(date)
        }
    }
}

13:29 Now we can switch to a custom date, select a date, and we'll still see the same date after we switch back and forth between the options.

Syncing State

13:46 But we can think of another way to break the view. Outside the picker, we add a button to reset the selection state to the current time:

struct ContentView: View {
    @State private var selection: Time = .tonight

    var body: some View {
        VStack {
            TimePicker(selection: $selection)
            Button("Reset") {
                selection = .custom(Date.now)
            }
            Text("\(selection)")
        }
        .padding()
        .dynamicTypeSize(.xxxLarge)
    }
}

14:16 This would be a perfectly normal thing to do; the picker doesn't own the selection state, so it could be changed from outside of the component. But currently, if we first pick a different custom date inside the picker and then tap the reset button, the date picker inside the component doesn't show the new date, because the date picker doesn't get notified about changes to selection.

14:49 This problem isn't specific to SwiftUI — it's the fact that we don't have a single source of truth. The enum in our model holds the actual custom date, but our interface caches another date value. In a scenario like this, we have to be careful to keep the two values in sync.

15:48 We can add another onChange(of:) call to update the custom date if selection is changed from outside the picker:

struct TimePicker: View {
    @Binding var selection: Time
    @State private var date = Date()

    var body: some View {
        VStack {
            Toggle("Tonight", isOn: $selection.isTonight)
            Toggle("Tomorrow", isOn: $selection.isTomorrow)
            Toggle("Custom", isOn: $selection[custom: date])
            if case .custom = selection {
                DatePicker("Custom date", selection: $date)
                    .labelsHidden()
            }
        }
        .toggleStyle(TimePickerStyle())
        .onChange(of: date) {
            selection = .custom(date)
        }
        .onChange(of: selection, initial: true) {
            guard case .custom(let date) = selection else {
                return
            }
            self.date = date
        }
    }
}

17:34 The two onChange(of:) calls synchronize the date values in both directions. The one updating the date state has the initial parameter set to true. The other one doesn't need this, because the date state can only change once the date picker has been rendered on screen and the user does something with it.

18:00 It's very tricky — besides the two onChange(of:) calls, we also needed the subscript to write a custom date into the Time value. That's three different parts of our code that had to be changed, while everything looked like it worked initially.

18:27 Perhaps it'd be easier to store the selected date as a separate property in our model, even though that would enable nonsensical values to be stored.

19:03 That's it for today. We can use our project as a starting point for what we are going to do in the next episode, because we want to take a look at all the nice text formatting options SwiftUI is building into the Text view, and we can start with formatting dates.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

174 Episodes · 60h55min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes