In SwiftUI, how do I know when a Picker selection was changed? Why doesn't didSet work?
Asked Answered
O

4

9

I have a Picker in a SwiftUI Form, and I'm trying to process the value whenever the picker changes. I expected to be able to do this with didSet on a variable that represents the currently selected value.

import SwiftUI

struct ContentView: View {

    enum TransmissionType: Int {
        case automatic
        case manual
    }

    @State private var selectedTransmissionType: Int = TransmissionType.automatic.rawValue {
        didSet {
            print("selection changed to \(selectedTransmissionType)")
        }
    }

    private let transmissionTypes: [String] = ["Automatic", "Manual"]

    var body: some View {
        NavigationView {
            Form {
                Section {
                    Picker(selection: $selectedTransmissionType,
                           label: Text("Transmission Type")) {
                        ForEach(0 ..< transmissionTypes.count) {
                            Text(self.transmissionTypes[$0])
                        }
                    }
                }
            }
        }
    }
}

The Picker UI works (mostly) as expected: I can see the default value is selected, tap into the picker, it opens a new view, I can select the other value, and then it goes back to the main form and shows me the new value is selected. However, didSet is never called.

I saw this question, but it seems odd to me to add more to my View code instead of just processing the new value when the variable changes, if that is even possible. Is it better to use onReceive even though it results in a more complicated view? My main question is: What's wrong with my code to prevent didSet from being called?

I used this example to get to this point.

Beyond my regular question, I have a few others about this example:

A) It seems weird how I have an enum and also an Array to represent the same two values. Can someone also suggest a better way to structure it to avoid this redundancy? I considered a TransmissionType object, but that seemed like overkill compared to an enum... maybe it's not?

B) When tapping into the picker, the screen with the picker options slides over, and then the two options jump up a bit. This feels jarring and jumpy and a bad user experience. Am I doing something wrong here that's causing the bad UX? Or is it probably a SwiftUI bug? I'm getting this error every time I change the picker:

[TableView] Warning once only: UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window). This may cause bugs by forcing views inside the table view to load and perform layout without accurate information (e.g. table view bounds, trait collection, layout margins, safe area insets, etc), and will also cause unnecessary performance overhead due to extra layout passes. Make a symbolic breakpoint at UITableViewAlertForLayoutOutsideViewHierarchy to catch this in the debugger and see what caused this to occur, so you can avoid this action altogether if possible, or defer it until the table view has been added to a window.

Organize answered 5/11, 2019 at 22:8 Comment(0)
B
7

I started typing this out earlier, and returned to find LuLuGaGa had beaten me to the punch. :D But since I have this anyway...

Main question: From the Swift Language Guide:

"When you assign a default value to a stored property, or set its initial value within an initializer, the value of that property is set directly, without calling any property observers."

So the property observer will not fire when the view is constructed. But when a @State variable changes, a new instance of the view is constructed (remember, views are structs, or value types). Thus, the didSet property observer is, practically speaking, not useful on @State properties.

What you want to do is create a class that conforms to ObservableObject, and reference it from your view with the @ObservedObject property wrapper. Because the class exists outside the struct, you can set property observers on its properties, and they will fire like you're expecting.

Question A: You can use just the enum if you make it conform to CaseIterable (see example below)

Question B: This appears to be a SwiftUI bug, as it happens with any Picker inside of a NavigationView/Form combo, as far as I can tell. I'd recommend reporting it to Apple.

Here is how I would remove the redundancy of the enum and array, and save the selection in UserDefaults:

extension ContentView {
    // CaseIterable lets us use .allCases property in ForEach
    enum TransmissionType: String, CaseIterable, Identifiable, CustomStringConvertible {
        case automatic
        case manual

        // This lets us omit 'id' parameter in ForEach
        var id: TransmissionType {
            self
        }

        // This just capitalizes the first letter for prettier printing
        var description: String {
            rawValue.prefix(1).uppercased() + rawValue.dropFirst()
        }
    }

    class SelectionModel: ObservableObject {
        // Save selected type to UserDefaults on change
        @Published var selectedTransmissionType: TransmissionType {
            didSet {
                UserDefaults.standard.set(selectedTransmissionType.rawValue, forKey: "TransmissionType")
            }
        }

        // Load selected type from UserDefaults on initialization
        init() {
            if let rawValue = UserDefaults.standard.string(forKey: "TransmissionType") {
                if let transmissionType = TransmissionType(rawValue: rawValue) {
                    self.selectedTransmissionType = transmissionType
                    return
                }
            }
            // Couldn't load from UserDefaults
            self.selectedTransmissionType = .automatic
        }
    }
}

Then your view just looks like

struct ContentView: View {
    @ObservedObject var model = SelectionModel()

    var body: some View {
        NavigationView {
            Form {
                Section {
                    Picker(selection: $model.selectedTransmissionType, label: Text("Transmission Type")) {
                        ForEach(TransmissionType.allCases) { type in
                            Text(type.description)
                        }
                    }
                }
            }
        }
    }
}
Brainstorming answered 6/11, 2019 at 6:57 Comment(1)
Perfect explanation πŸ‘ŒπŸ» – Galaxy
C
6

There are two problems here.

1) the jumping can be solved if title style is ".inline".

2) OnReceive() is one of the simplest way to replace the didSet request with a combine framework method, which is the core tech of SwiftUI.

struct ContentView: View {



enum TransmissionType: Int {
    case automatic
    case manual
}

 @State private var selectedTransmissionType: Int =   TransmissionType.automatic.rawValue {
    didSet {
        print("selection changed to \(selectedTransmissionType)")
    }
}

private let transmissionTypes: [String] = ["Automatic", "Manual"]

var body: some View {
    NavigationView {
        Form {
            Section {
                Picker(selection: $selectedTransmissionType,
                       label: Text("Transmission Type")) {
                    ForEach(0 ..< transmissionTypes.count) {
                        Text(self.transmissionTypes[$0])
                    }
                }
            }
        }.navigationBarTitle("Title", displayMode: .inline) // this solves jumping and all warnings.
    }.onReceive(Just(selectedTransmissionType)) { value in
    print(value) // Just one step can monitor the @state value.
    }
   }


}
Cutlery answered 6/11, 2019 at 6:4 Comment(2)
I like this use of Combine to read the change in @State variables. +1 Unfortunately, although the large vertical jump doesn't occur with displayMode: .inline, there is still horizontal jittering when you first open the Picker, so it's still a bug that needs reporting. – Brainstorming
Thanks for the tip about .inline but I have a large title on the main navigation view, and it is still buggy when I make this one .inline` unless I also set that one to .inline. Seems like it must be bugs in SwiftUI – Organize
K
3

didSet doesn't get called on @State because it is a wrapper - you are setting a value within the state not the state itself. The he important question is why would you want to know that it was set?

Your view can be simplified a bit:

If you declare your enum as having raw type of String, you don't need your Array of names. If f you declare it as CaseIterable you will be able to get all cases by calling Array(TransmissionType.allCases). If it is declared as Identifiable as well you will be able to pass all cases straight into ForEach. Next you need to pass the rawValue into the text and remember to place the tag on it so that the selection can be made:

struct ContentView: View {

    enum TransmissionType: String, CaseIterable, Identifiable {

        case automatic
        case manual

        var id: String {
            return self.rawValue
        }
    }

    @State private var selectedTransmissionType = TransmissionType.automatic

    var body: some View {
        NavigationView {
            Form {
                Section {
                    Picker(selection: $selectedTransmissionType,
                           label: Text("Transmission Type")) {
                            ForEach(Array(TransmissionType.allCases)) {
                                Text($0.rawValue).tag($0)
                            }
                    }
                }
            }
        }
    }
}

I can't see where the weird jumping comes from - have you replicated it on device as well?

Khat answered 5/11, 2019 at 22:32 Comment(2)
Thanks! The reason I want to know when the value changes is so I can save it in a UserDefault to save between app launches. Also, yes, the jumping happens on device. – Organize
In that case you have yo use ObjectBinding not a State as this is a not variable local to the view – Khat
C
1

Hey did not read the whole thing but if you want to know what the changed value of the picker is. Just use .onChange(of: YourPicker) { changedValue in }

Catlaina answered 20/9, 2022 at 15:58 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.