Home > Software design >  SwitUI parent child binding: @Published in @StateObject doesn't work while @State does
SwitUI parent child binding: @Published in @StateObject doesn't work while @State does

Time:08-04

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.

CodePudding user response:

@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.

CodePudding user response:

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 = ""
}
  • Related