SwiftUI: Bound @State Variable Not Updated When Showing Sheet

182 Views Asked by At

I am experiencing an issue with SwiftUI where a bound @State variable (displayString) does not seem to get updated in time when a sheet is presented. The code provided consists of a parent view (SimpleParentView) and a child view (SimpleChildView). The parent view presents a sheet when showSheet is set to true, and the sheet displays the displayString value.

The problem arises when either "Button 1" or "Button 2" in the SimpleChildView is pressed for the first time. When pressed, it's intended to update displayString and then present the sheet by setting showSheet to true. However, when the sheet is presented on the first button press, it still displays "Initial String," indicating that the displayString hasn't been updated in time for the presentation of the sheet.

Interestingly, if the user dismisses the sheet and then presses the opposite button or the same button again, the sheet displays the updated displayString correctly, and any subsequent button presses also work as intended.

I've tried using DispatchQueue.main.asyncAfter to delay the setting of showSheet to true, hoping this would allow enough time for displayString to get updated, but this approach doesn't seem to make any difference.

I also tired "priming" the assignment by assigning a throw-away value first, in hopes that this would take care of that initial mysterious miss-assignement, but that doesn't work either.

Why is this happening?

import Foundation

import SwiftUI

struct SimpleParentView: View {
    @State private var showSheet = false
    @State private var displayString = "Initial String"
    
    var body: some View {
        VStack {
            SimpleChildView(showSheet: $showSheet, displayString: $displayString)
        }
        .sheet(isPresented: $showSheet) {
            VStack {
                Text(displayString)
                Button("Close") {
                    showSheet = false
                }
                .padding()
            }
        }
    }
}

struct SimpleChildView: View {
    @Binding var showSheet: Bool
    @Binding var displayString: String
    
    var body: some View {
        Button(action: {
            // This "priming the assignment" hack makes no difference
            // displayString = ""
            displayString = "Updated String 1"
            showSheet = true
            // This delayed assignment hack makes no difference
            /*
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                showSheet = true
            }*/
        }) {
            Text("Button 1 (show sheet in parent)")
        }
        Button(action: {
            displayString = "Updated String 2"
            showSheet = true
        }) {
            Text("Button 2 (show sheet in parent)")
        }
    }
}

struct SimpleParentView_Previews: PreviewProvider {
    static var previews: some View {
        SimpleParentView()
    }
}
3

There are 3 best solutions below

0
Sweeper On BEST ANSWER

This doesn't seem intentional and might be a bug in SwiftUI.

It appears that SwiftUI made an initial version of the sheet, and showed that when showSheet becomes true, failing to notice that the sheet's content uses displayString and hence should be redrawn. ContentView.body is only evaluated once, until you press a different button.

ContentView's body (not counting the sheet) doesn't use displayString - only bindings of it. That might have caused SwiftUI to think it doesn't need to be updated. This is only speculation though.

In any case, if you make ContentView have the slightest dependency on displayString, this behaviour does not happen. It could be a hidden Text:

Text(displayString).hidden()

or even just an onChange modifier:

VStack {
    ...
}
.onChange(of: displayString) { _ in }

And this is my favourite - even just discarding displayString with let _ = works:

VStack {
    let _ = displayString
    SimpleChildView(showSheet: $showSheet, displayString: $displayString)
        .sheet(...) { ... }
}

As a final note, if displayString can be nil when the sheet is not showing, you can use sheet(item:onDismiss:content:) instead. This way SimpleChildView just needs one binding.

0
workingdog support Ukraine On

Use a closure to capture the state var displayString, such as:

.sheet(isPresented: $showSheet) { [displayString] in  // <--- here
        VStack {
            Text(displayString)
            Button("Close") {
                showSheet = false
            }
            .padding()
        }
}
0
malhal On

SimpleParentView's body isn't called when displayString is set because it wasn't previously read.

SwiftUI only calls body when a @State is set if its get was used. This is SwiftUI's dependency tracking feature (which is not documented).

working dog's answer is one way to get the displayString but if you get it anywhere else in body it will achieve the same effect. I suppose it is a bug that the dependency tracking does not reach into a sheet's closure, it does however work for other kinds of View closures. It's probably because the sheet closure is more of an action rather than a view builder.