在 SwiftUI 中,我如何知道 Picker 选择何时更改?为啥 didSet 不起作用?

Posted

技术标签:

【中文标题】在 SwiftUI 中,我如何知道 Picker 选择何时更改?为啥 didSet 不起作用?【英文标题】:In SwiftUI, how do I know when a Picker selection was changed? Why doesn't didSet work?在 SwiftUI 中,我如何知道 Picker 选择何时更改?为什么 didSet 不起作用? 【发布时间】:2019-11-05 22:08:59 【问题描述】:

我在 SwiftUI Form 中有一个 Picker,并且我正在尝试在选择器更改时处理该值。我希望能够在代表当前选定值的变量上使用didSet 来执行此操作。

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

Picker UI (大部分)按预期工作:我可以看到默认值已被选中,点击选择器,它会打开一个新视图,我可以选择其他值,然后返回主表单并显示新值已被选中。但是,永远不会调用 didSet

我看到了this question,但在我的View 代码中添加更多内容而不是仅在变量更改时处理新值(如果可能的话)似乎很奇怪。使用onReceive 是否更好,即使它会导致更复杂的视图?我的主要问题是:阻止调用 didSet 的代码有什么问题?

我使用this example 来达到这一点。

除了我的常规问题之外,我还有一些关于此示例的其他问题:

A) 我有一个enum 和一个Array 来表示相同的两个值,这似乎很奇怪。有人还可以建议一种更好的方法来构建它以避免这种冗余吗?我考虑过 TransmissionType 对象,但与 enum 相比,这似乎有点过分了……也许不是?

B) 当点击选择器时,带有选择器选项的屏幕会滑过,然后两个选项会向上跳跃一点。这让人感觉刺耳、紧张和糟糕的用户体验。我在这里做错了什么导致糟糕的用户体验吗?或者它可能是一个 SwiftUI 错误?每次更改选择器时都会出现此错误:

[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.

【问题讨论】:

【参考方案1】:

我早些时候开始输入这个,然后回来发现 LuLuGaGa 打败了我。 :D 但是反正我有这个...

主要问题:来自Swift Language Guide:

"当你给一个存储的属性分配一个默认值,或者设置它的 初始化器中的初始值,该属性的值被设置 直接,无需调用任何属性观察者。”

所以当视图被构建时属性观察器不会被触发。但是当@State 变量发生变化时,会构造一个新的视图实例(请记住,视图是结构或值类型)。因此,didSet 属性观察器实际上对@State 属性没有用处。

您要做的是创建一个符合ObservableObject 的类,并使用@ObservedObject 属性包装器从您的视图中引用它。因为该类存在于结构之外,所以您可以在其属性上设置属性观察器,它们会像您期望的那样触发。

问题 A:如果您使枚举符合 CaseIterable,则可以只使用枚举(参见下面的示例)

问题 B:据我所知,这似乎是一个 SwiftUI 错误,因为在 NavigationView/Form 组合中的任何 Picker 都会发生这种情况。我推荐reporting it to Apple。

这是我将如何删除枚举和数组的冗余,并将选择保存在 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
        
    

那么你的视图就像

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)
                        
                    
                
            
        
    

【讨论】:

完美解释??【参考方案2】:

这里有两个问题。

1) 如果标题样式为“.inline”,则可以解决跳转问题。

2) OnReceive() 是用组合框架方法替换didSet 请求的最简单方法之一,这是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.
    
   



【讨论】:

我喜欢使用 Combine 来读取 @State 变量的变化。 +1 不幸的是,虽然displayMode: .inline 没有出现大的垂直跳跃,但是当你第一次打开Picker 时仍然存在水平抖动,所以这仍然是一个需要报告的错误。 感谢有关.inline 的提示,但我在主导航视图上有一个大标题,当我制作这个 .inline` 时它仍然有问题,除非我也将它设置为 @987654327 @。看来这一定是 SwiftUI 中的错误【参考方案3】:

didSet 不会在 @State 上被调用,因为它是一个包装器 - 您在状态中设置一个值而不是状态本身。重要的问题是你为什么想知道它已经设置好?

你的观点可以简化一点:

如果您将枚举声明为具有原始字符串类型,则不需要名称数组。如果您将其声明为 CaseIterable,您将能够通过调用 Array(TransmissionType.allCases) 来获取所有案例。如果它也被声明为可识别,您将能够将所有案例直接传递到 ForEach。接下来您需要将 rawValue 传递到文本中,并记住在其上放置标签以便进行选择:

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)
                            
                    
                
            
        
    

我看不出奇怪的跳跃从何而来 - 你是否也在设备上复制了它?

【讨论】:

谢谢!我想知道值何时更改的原因是我可以将其保存在 UserDefault 中以在应用程序启动之间保存。另外,是的,跳跃发生在设备上。 在这种情况下,您必须使用 ObjectBinding 而不是 State,因为这不是视图本地的变量

以上是关于在 SwiftUI 中,我如何知道 Picker 选择何时更改?为啥 didSet 不起作用?的主要内容,如果未能解决你的问题,请参考以下文章

如何在 SwiftUI 中使用多个 Picker 更改 UIPickerView 宽度?

SwiftUI 为啥 Picker 上的输入不起作用?

如何让 SwiftUI Picker 在子视图中工作? (变灰)

SwiftUI:如何根据 Picker 的值更新 ForEach 循环的范围

SwiftUI 从另一个视图中捕获 Picker 值

SwiftUI 如何让 ForEach 循环根据 Picker 中选定月份的天数进行迭代