SwiftUI ObservedObject в представлении имеет две ссылки (экземпляры) - PullRequest
0 голосов
/ 25 февраля 2020

Не знаю почему, но у меня очень неприятная ошибка в моем представлении SwiftUI. Это представление имеет ссылку на объект ViewModel. Но этот вид создается несколько раз на экране, и в конце один вид имеет несколько ссылок на объект ViewModel. Я ссылаюсь на этот объект модели представления в пользовательском установщике / получателе привязки или в замыкании. Но ссылки на объекты в Binding и в замыкании совершенно разные. Это вызывает много проблем с корректным обновлением View или сохранением изменений.

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"))
            }
        }
    }
}

Я сталкиваюсь с этой проблемой в нескольких местах при написании кода SwiftUI. У меня есть несколько обходных путей:

1) как вы можете видеть здесь, я использую дополнительную переменную @State для хранения dealStageId и передаю ее в viewModale.changeDealStage () вместо обновления в viewModal

2) в других местах я использую Wrapper View вокруг такого представления, затем добавьте @State var viewModel: SomeViewModel, затем передайте этот viewModel и назначьте @ObservedObject.

Но эта ошибка происходит случайно в зависимости от размещения этого Представления как Подвид других Представлений. Иногда это работает, иногда это не работает.

Весьма вероятно, что ОДНОСТОРОННЕЕ представление может иметь ссылки на несколько моделей представления, даже если оно создается несколько раз.

Может быть, проблема в замыкании, поскольку оно сохраняет ссылку на первый экземпляр ViewModel, а затем это замыкание не обновляется в модификаторе представления adaptivePicker?

Чтобы обойти эту проблему, нужно написать много отладочных и шаблонных кодов!

Любой может помочь, что я делаю неправильно или что не так с SwiftUI / ObservableObject?

ОБНОВЛЕНИЕ

Вот использование этого представления:

 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()
                        }
                    }
....

ОБНОВЛЕНИЕ 2

Вот 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)
                }
            }
        }
    }
}
...