Andrei Herford
Andrei Herford

Reputation: 18765

Binding gets broken when presenting .sheet - But where and why?

A SwiftUI view should show different .sheets. For example some EditorView with a picker view in a .sheet for a number of different values.

Instead of using different @State vars and .sheet blocks for any picker, I created the following Router and RouterView to handle the sheet presentation:

class Router: ObservableObject {
    @Published var sheetContent: AnyView? = nil
    
    func present<Content: View>(content: Content) {
        self.sheetContent = AnyView(content)
    }
}


struct RouterView<Content: View>: View {
    @ObservedObject var router: Router
    
    private var content: () -> Content
    
    init (_ router: Router, @ViewBuilder content: @escaping () -> Content) {
        self.router = router
        self.content = content
    }
    
    var body: some View {
        content()
            .sheet(isPresented: Binding(
                get: { router.sheetContent != nil },
                set: { if !$0 { router.sheetContent = nil } }
            )) {
                router.sheetContent
            }
    }
}

While this works fine in general, it somehow breaks @Bindings used in the presented content, like in the demo code below.

The demo includes:

Conclusion: Using the Router somehow breaks the Binding it only works from ValueView back to the viewModel while the other direction (viewModel to ValueView) does not work.

How can this be solved?


enter image description here

Demo Code:

// View Model
extension RouterTestView {
    class ViewModelModel: ObservableObject {
        @Published var router = Router()
        
        @Published var title: String = "The Value"
        @Published var showTitle: Bool = true
        
        func show() {
            router.present(content: ValueView(value: title, showValue: Binding(get: { self.showTitle }, set: { self.showTitle = $0 })))
        }
        
        func show<Content: View>(_ inView: Content) {
            router.present(content: inView)
        }
    }
}


struct RouterTestView: View {
    @StateObject var viewModel: ViewModelModel = .init()
    
    @State private var isPresentingSheet: Bool = false
    
    var body: some View {
        Text("showTitle == \(viewModel.showTitle ? "true" : "false")")
        
        
        // Do not use Router but instead use the classic way to present
        // the sheet. The Binding works without any problem.
        Button("Present Sheet manually") {
            isPresentingSheet = true
        }
        .padding()
        .sheet(isPresented: $isPresentingSheet) {
            ValueView(value: viewModel.title, showValue: $viewModel.showTitle)
        }
        
        
        // Use Router to present the sheet. It makes no difference if the
        // Content is created in the ViewModel or here. The Binding is broken.
        // Changes to $viewModel.showTitle in ValueView are correctly reported
        // to the viewModel, but the changes are not recognized in ValueView
        // itself
        RouterView(viewModel.router) {
            // Sheet with Content created in viewModel
            Button("Present Sheet with Router") {
                viewModel.show()
            }
            .padding()
            
            // Sheet with Content created here.
            Button("Present Sheet with Router") {
                viewModel.show(
                    ValueView(value: viewModel.title, showValue: $viewModel.showTitle)
                )
            }
            .padding()
        }
    }
}

// ValueView as dummy "editor". Changes in the showValue Binding should be reported
// Back to the viewModel in RouterTestView and toggle the visibility of the value here.
struct ValueView: View {
    let value: String
    @Binding var showValue: Bool
    
    var body: some View {
        Text("Here is the value:")
        
        if showValue {
            Text(value)
        }
        
        Button("Toggle") {
            showValue.toggle()
        }
    }
}

#Preview {
    RouterTestView()
} 

Upvotes: -3

Views: 60

Answers (1)

lorem ipsum
lorem ipsum

Reputation: 29614

No, this setup is not expected to work.

The simplest solution is to use .sheet(item:).

But things like AnyView completely hide any underlying updates.

I suggest watching “Demystify SwiftUI” and looking at this old post.

SwiftUI: Understanding .sheet / .fullScreenCover lifecycle when using constant vs @Binding initializers

SwiftUI is value based, not reference based like UIKit.

Upvotes: 1

Related Questions