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