Search code examples
swiftswiftuibindingstateswiftui-state

SwiftUI: Bound @State Variable Not Updated When Showing Sheet


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

Solution

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