1

I do not why but I have very frustrating bug in my SwiftUI view. This view has reference to ViewModel object. But this View is created multiple times on screen appear, and at the end the single View have multiple references to ViewModel object. I reference this view model object in custom Binding setter/getter or in closure. But object references in Binding and in closure are totally different. This causes many problems with proper View refreshing or saving changes.

struct DealDetailsStagePicker : View {

    // MARK: - Observed
    @ObservedObject var viewModel: DealDetailsStageViewModel

    // MARK: - State
    /// TODO: It is workaround as viewModel.dealStageId doesn't work correctly
    /// viewModel object is instantiated several times and pickerBinding and onDone
    /// closure has different references to viewModel object
    /// so updating dealStageId via pickerBinding refreshes it in different viewModel
    /// instance than onDone closure executed changeDealStage() method (where dealStageId
    /// property stays with initial or nil value.
    @State var dealStageId: String? = nil

    // MARK: - Binding
    @Binding private var showPicker: Bool

    // MARK: - Properties
    let deal : Deal

    // MARK: - Init
    init(deal: Deal, showPicker: Binding<Bool>) {
        self.deal = deal
        self._showPicker = showPicker

        self.viewModel = DealDetailsStageViewModel(dealId: deal.id!)

    }

    var body: some View {

        let pickerBinding = Binding<String>(get: {
            if self.viewModel.dealStageId == nil {
                self.viewModel.dealStageId = self.dealStage?.id ?? ""
            }
            return self.viewModel.dealStageId!
        }, set: { id in
            self.viewModel.dealStageId = id  //THIS viewModel is reference to object 0x8784783
            self.dealStageId = id
        })

        return VStack(alignment: .leading, spacing: 4) {
            Text("Stage".uppercased())


            Button(action: {
                self.showPicker = true
            }) {
                HStack {
                    Text("\(deal.status ?? "")")
                    Image(systemName: "chevron.down")
                }
                .contentShape(Rectangle())
            }
        }
        .buttonStyle(BorderlessButtonStyle())
        .adaptivePicker(isPresented: $showPicker, selection: pickerBinding, popoverSize: CGSize(width: 400, height: 200), popoverArrowDirection: .up, onDone: {
            // save change
            self.viewModel.changeDealStage(self.dealStages, self.dealStageId) // THIS viewModel references 0x92392983
        }) {
            ForEach(self.dealStages, id: \.id) { stage in
                Text(stage.name)
                 .foregroundColor(Color("Black"))
            }
        }
    }
}

I am experiencing this problem in multiple places writing SwiftUI code. I have several workarounds:

1) as you can see here I use additional @State var to store dealStageId and pass it to viewModale.changeDealStage() instead of updating it on viewModal

2) in other places I am using Wrapper View around such view, then add @State var viewModel: SomeViewModel, then pass this viewModel and assign to @ObservedObject.

But this errors happens randomly depending on placement this View as Subview of other Views. Sometimes it works, sometime it does not work.

It is very od that SINGLE view can have references to multiple view models even if it is instantiated multiple times.

Maybe the problem is with closure as it keeps reference to first ViewModel instance and then this closure is not refreshed in adaptivePicker view modifier?

Workarounds around this issue needs many debugging and boilerplate code to write!

Anyone can help what I am doing wrong or what is wrong with SwiftUI/ObservableObject?

UPDATE

Here is the usage of this View:

 private func makeDealHeader() -> some View {

            VStack(spacing: 10) {

                Spacer()

                VStack(spacing: 4) {
                    Text(self.deal?.name ?? "")


                    Text(NumberFormatter.price.string(from: NSNumber(value: Double(self.deal?.amount ?? 0)/100.0))!)

                }.frame(width: UIScreen.main.bounds.width*0.667)

                HStack {
                    if deal != nil {
                        DealDetailsStagePicker(deal: self.deal!, showPicker: self.$showStagePicker)
                    }
                    Spacer(minLength: 24)

                    if deal != nil {
                        DealDetailsClientPicker(deal: self.deal!, showPicker: self.$showClientPicker)

                    }
                }
                .padding(.horizontal, 24)


                self.makeDealIcons()
                Spacer()
            }
            .frame(maxWidth: .infinity)
            .listRowInsets(EdgeInsets(top: 0.0, leading: 0.0, bottom: 0.0, trailing: 0.0))

    }

 var body: some View {

            ZStack {
                Color("White").edgesIgnoringSafeArea(.all)

                VStack {
                    self.makeNavigationLink()
                    List {
                        self.makeDealHeader()

                        Section(header: self.makeSegmentedControl()) {
                            self.makeSection()
                        }
                    }
....

UPDATE 2

Here is adaptivePicker

extension View {

    func adaptivePicker<Data, ID, Content>(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> ForEach<Data, ID, Content>) -> some View where Data : RandomAccessCollection, ID: Hashable, Content: View {

        self.modifier(AdaptivePicker2(isPresented: isPresented, selection: selection, popoverSize: popoverSize, popoverArrowDirection: popoverArrowDirection, onDone: onDone, content: content))
    }


and here is AdaptivePicker2 view modifier implementation 

struct AdaptivePicker2<Data, ID, RowContent> : ViewModifier, OrientationAdjustable where Data : RandomAccessCollection, ID: Hashable , RowContent: View {

    // MARK: - Environment
    @Environment(\.verticalSizeClass) var _verticalSizeClass
    var verticalSizeClass: UserInterfaceSizeClass? {
        _verticalSizeClass
    }

    // MARK: - Binding
    private var isPresented: Binding<Bool>
    private var selection: Binding<ID>

    // MARK: - State
    @State private var showPicker : Bool = false

    // MARK: - Actions
    private let onDone: (() -> Void)?

    // MARK: - Properties
    private let popoverSize: CGSize?
    private let popoverArrowDirection: UIPopoverArrowDirection
    private let pickerContent: () -> ForEach<Data, ID, RowContent>

    // MARK: - Init
    init(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> ForEach<Data, ID, RowContent>) {

        self.isPresented = isPresented
        self.selection = selection
        self.popoverSize = popoverSize
        self.popoverArrowDirection = popoverArrowDirection
        self.onDone = onDone

        self.pickerContent = content

    }

    var pickerView: some View {
        Picker("Select State", selection: self.selection) {
            self.pickerContent()
        }
        .pickerStyle(WheelPickerStyle())
        .labelsHidden()
    }

    func body(content: Content) -> some View {

        let isShowingBinding = Binding<Bool>(get: {
            DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
                withAnimation {
                    self.showPicker = self.isPresented.wrappedValue
                }
            }
            return self.isPresented.wrappedValue
        }, set: {
            self.isPresented.wrappedValue = $0
        })

        let popoverBinding = Binding<Bool>(get: {
            self.isPresented.wrappedValue
        }, set: {
            self.onDone?()
            self.isPresented.wrappedValue = $0
        })

        return Group {
            if DeviceType.IS_ANY_IPAD {
                if self.popoverSize != nil {
                    content.presentPopover(isShowing: popoverBinding, popoverSize: popoverSize, arrowDirection: popoverArrowDirection) { self.pickerView }
                } else {
                    content.popover(isPresented: popoverBinding) { self.pickerView }
                }
            } else {
                content.present(isShowing: isShowingBinding) {
                    ZStack {
                        Color("Dim")
                            .opacity(0.25)
                            .transition(.opacity)
                            .onTapGesture {
                                self.isPresented.wrappedValue = false
                                self.onDone?()
                            }
                        VStack {
                            Spacer()
                            // TEST: Text("Show Picker: \(self.showPicker ? "True" : "False")")
                             if self.showPicker {
                                VStack {
                                    Divider().background(Color.white)
                                        .shadow(color: Color("Dim"), radius: 4)
                                    HStack {
                                        Spacer()

                                        Button("Done") {
                                            print("Tapped picker done button!")
                                            self.isPresented.wrappedValue = false
                                            self.onDone?()
                                        }
                                        .foregroundColor(Color("Accent"))
                                        .padding(.trailing, 16)
                                    }

                                    self.pickerView
                                    .frame(height: self.isLandscape ? 120 : nil)
                                }
                                .background(Color.white)
                                .transition(.move(edge: .bottom))
                                .animation(.easeInOut(duration: 0.35))
                            }

                        }
                    }
                    .edgesIgnoringSafeArea(.all)
                }
            }
        }
    }
}
4

1 回答 1

1

iOS 14 的新功能似乎@StateObject将在 SwiftUI 中解决此问题。

于 2020-06-24T08:20:20.423 回答