2

I have a custom modal structure coming from this question (code below). Some property is modified in the modal view and is reflected in the source with a Binding. The catch is that when the property is coming from a @StateObject + @Published the changes are not reflected back in the modal view. It's working when using a simple @State.

Minimal example (full code):

class Model: ObservableObject {
    @Published var selection: String? = nil
}

struct ParentChildBindingTestView: View {
    @State private var isPresented = false

    // not working with @StateObject
    @StateObject private var model = Model()
        
    // working with @State
//    @State private var selection: String? = nil
    
    var body: some View {
        VStack(spacing: 20) {
            Button("Show child", action: { isPresented = true })
            Text("selection: \(model.selection ?? "nil")") // replace: selection
        }
        .modalBottom(isPresented: $isPresented, view: {
            ChildView(selection: $model.selection) // replace: $selection
        })
    }
}

struct ChildView: View {
    @Environment(\.dismissModal) var dismissModal

    @Binding var selection: String?

    var body: some View {
        VStack {
            Button("Dismiss", action: { dismissModal() })
            VStack(spacing: 0) {
                ForEach(["Option 1", "Option 2", "Option 3", "Option 4"], id: \.self) { choice in
                    Button(action: { selection = choice }) {
                        HStack(spacing: 12) {
                            Circle().fill(choice == selection ? Color.purple : Color.black)
                                .frame(width: 26, height: 26, alignment: .center)
                            Text(choice)
                        }
                        .padding(16)
                        .frame(maxWidth: .infinity, alignment: .leading)
                    }
                }
            }
        }
        .padding(50)
        .background(Color.gray)
    }
}

extension View {
    func modalBottom<Content: View>(isPresented: Binding<Bool>, @ViewBuilder view: @escaping () -> Content) -> some View {
        onChange(of: isPresented.wrappedValue) { isPresentedValue in
            if isPresentedValue == true {
                present(view: view(), dismissCallback: { isPresented.wrappedValue = false })
            }
            else {
                topMostController().dismiss(animated: false)
            }
        }
        .onAppear {
            if isPresented.wrappedValue {
                present(view: view(), dismissCallback: { isPresented.wrappedValue = false })
            }
        }
    }
        
    fileprivate func present<Content: View>(view: Content, dismissCallback: @escaping () -> ()) {
        DispatchQueue.main.async {
            let topMostController = self.topMostController()
            let someView = VStack {
                Spacer()
                view
                    .environment(\.dismissModal, dismissCallback)
            }
            let viewController = UIHostingController(rootView: someView)
            viewController.view?.backgroundColor = .clear
            viewController.modalPresentationStyle = .overFullScreen
            topMostController.present(viewController, animated: false, completion: nil)
        }
    }

}

extension View {
    func topMostController() -> UIViewController {
        var topController: UIViewController = UIApplication.shared.windows.first!.rootViewController!
        while (topController.presentedViewController != nil) {
            topController = topController.presentedViewController!
        }
        return topController
    }
}

private struct ModalDismissKey: EnvironmentKey {
    static let defaultValue: () -> Void = {}
}

extension EnvironmentValues {
    var dismissModal: () -> Void {
        get { self[ModalDismissKey.self] }
        set { self[ModalDismissKey.self] = newValue }
    }
}

struct ParentChildBindingTestView_Previews: PreviewProvider {
    static var previews: some View {
        ZStack {
            ParentChildBindingTestView()
        }
    }
}

The changes are reflected properly when replacing my custom structure with a fullScreenCover, so the problem comes from there. But I find it surprising that it works with a @State and not with a @StateObject + @Published. I thought those were identical.

Morpheus
  • 1,189
  • 2
  • 11
  • 33
  • Interesting behavior... moreover, I think it differs (or will differ) from OS version to version. IMO the root cause is in UIKit bridge. – Asperi Aug 03 '22 at 15:05
  • @Asperi I think so too. Did you ever experience different behaviors between `@State` and `@Published` properties? – Morpheus Aug 03 '22 at 15:11
  • @Asperi I removed the UIKit bridge but I'm still experiencing this issue: https://stackoverflow.com/questions/74149682/view-not-updating-in-modal-hierarchy – Morpheus Oct 21 '22 at 06:45
  • My code in this new question is only SwiftUI, you can run it and see – Morpheus Oct 21 '22 at 06:46
  • By bridge I meant using `UIHostingController` and UIKit `.present`, you still use it. GitHub/Twitter regularly. – Asperi Oct 24 '22 at 03:27

3 Answers3

1

If having @StateObject is a must for your code, and your ChildView has to update the data back to its ParentView, then you can still make this works around @StateObject.

Something like this:

struct Parent: View {
  @StateObject var h = Helper()
  var body: some View {
    TextField("edit child view", text: $h.helper)
    Child(helper: $h.helper)
  }
}
struct Child: View {
  @Binding var helper: String
  var body: some View {
    Text(helper)
  }
}
class Helper: ObservableObject {
  @Published var helper = ""
}
Steven-Carrot
  • 2,368
  • 2
  • 12
  • 37
  • That's what I'm trying to achieve, but it doesn't work with my modal structure. – Morpheus Aug 03 '22 at 15:08
  • Seems like modalBottom data flow problem. I will check the modalBottom. – Steven-Carrot Aug 03 '22 at 15:16
  • There is actually another way of data binding with sheet, I think you could try modifying your `modalBottom` to behave like `.sheet(item: Binding)` instead of `.sheet(isPresented)`. `.sheet(item: Binding)` is used for binding optional data and update the views inside it. – Steven-Carrot Aug 03 '22 at 16:31
  • I also have this initializer. I tried with this one, but it doesn't affect this problem because the item passed to this version is not the same one as the `Binding` that exchanges data – Morpheus Aug 03 '22 at 18:22
  • @Steven-Carrot, Great tip, which works nicely, but not sure why? I know the parent-child relationship is `@State <--> @Binding`, and `@StateObject <--> @ObservedObject`, but I thought `@Published <--> MISSING`. However, your example does work, so apparently the parent-child for `@Published` is `@Published <--> @Binding`. Is this a fluke? Or something they might take away later? I haven't seen anything in documentation saying to do this. I thought you had to pass the entire `@StateObject` to the child to bind to a `@Published`. – James Toomey Nov 17 '22 at 18:10
1

I think your can get anwser here

with @State we use onChange because it uses for only current View

with @Published we use onReceive because it uses for many Views

Moon
  • 296
  • 1
  • 6
0

@State should be used with @Binding

@StateObject with @ObservedObject

In your case, you would pass the model to the child view and update it's properties there.

cora
  • 1,916
  • 1
  • 10
  • 18
  • I'd like to keep my child independent from this specific model structure. That's possible with `@Published` properties (see other answer). – Morpheus Aug 03 '22 at 15:09