在SwiftUI中,我如何知道Picker的选择已更改?为什么不起作用didSet?

9

我在SwiftUI的Form中有一个Picker,我正在尝试在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从未被调用。
我看到了这个问题,但我觉得在我的View代码中添加更多内容似乎有点奇怪,而不是在变量更改时仅处理新值,如果这是可能的话。即使它导致了一个更复杂的视图,使用onReceive是否更好?我的主要问题是:我的代码有什么问题防止didSet被调用? 我使用了这个示例来达到这个程度。
除了我的常规问题外,我还有一些关于这个示例的其他问题: A) 我有一个enum和一个Array来表示相同的两个值,这似乎很奇怪。有人能建议一个更好的结构来避免这种冗余吗?我考虑过一个TransmissionType对象,但与enum相比,这似乎过于复杂了...也许不是吗? B) 点击选择器时,屏幕上的选择器选项会滑动,然后这两个选项会跳起来。这感觉很突兀和跳跃,用户体验很差。这里有什么做错了导致糟糕的UX吗?还是这可能是一个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.
4个回答

7

我开始写这篇文章,回来后发现LuLuGaGa已经抢先一步了。:D 但既然我已经准备好了...

主要问题:Swift Language Guide中可知:

“当您为存储属性分配默认值或在初始化程序内设置其初始值时,该属性的值将直接设置,而不调用任何属性观察器。”

因此,当视图构建时,不会触发属性观察器。但是,当 @State 变量更改时,会构建一个新的视图实例(记住,视图是结构体或值类型)。因此,在实践中,didSet 属性观察器对于 @State 属性没有实际作用。

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

问题 A:如果您使枚举符合 CaseIterable 协议,则只需使用枚举即可(请参见以下示例)

问题 B:据我观察,这似乎是 SwiftUI 的一个 bug,因为它适用于任何在 NavigationView/Form 组合内的 Picker。我建议您将此报告给 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)
                        }
                    }
                }
            }
        }
    }
}

完美的解释 - iGhost

6

这里有两个问题。

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变量的更改。遗憾的是,虽然在displayMode: .inline下不会出现大的垂直跳跃,但是当您首次打开Picker时仍会出现水平抖动,因此这仍然是需要报告的错误。 - John M.
谢谢你提供.inline的提示,但是我在主导航视图上有一个很大的标题,如果我只将这个设置为.inline,它仍然存在问题,除非我也将那个设置为.inline。看起来这必须是SwiftUI中的错误。 - gohnjanotis

3
由于@State是一个包装器,所以在设置值时不会调用didSet。重要的问题是为什么您想知道它已被设置?
您的视图可能会简化一些:
如果将枚举声明为String类型,则不需要使用名称数组。如果将其声明为CaseIterable,则可以通过调用Array(TransmissionType.allCases)获取所有情况。如果还声明为Identifiable,则可以直接传递所有情况到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中,在应用程序启动之间保存。另外,是的,跳跃发生在设备上。 - gohnjanotis
1
在这种情况下,您必须使用ObjectBinding而不是State,因为它不是视图本地变量。 - LuLuGaGa

1

嘿,我没有读完整个内容,但如果你想知道选择器的更改值是什么,只需使用 .onChange(of: YourPicker) { changedValue in }


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接