Fix: Cannot assign to value: ‘isSheetPresented’ is a ‘let’ constant

Fix: Cannot assign to value: 'isSheetPresented' is a 'let' constant

Written by

in

Quick answer: A function parameter is a constant, so you cannot reassign a Binding<Bool> parameter or return it where a Bool is expected. Forward the binding straight to .sheet(isPresented:), or if you must rebuild it, read and write isSheetPresented.wrappedValue — its setter is nonmutating, so it works through a let.

Why this happens

A function parameter is a constant. When you declare isSheetPresented: Binding<Bool>, the name isSheetPresented inside the body is a let of type Binding<Bool> — not a Bool, and not something you can reassign. That produces both errors at once:

  • get: { isSheetPresented } gives Cannot convert value of type 'Binding<Bool>' to closure result type 'Bool', because the closure must return a Bool and you handed it a Binding.
  • set: { isSheetPresented = $0 } gives Cannot assign to value: 'isSheetPresented' is a 'let' constant, because you are trying to replace the parameter itself.

It helps to remember what a Binding actually is: a small struct holding a getter closure and a setter closure. It is not a pointer to your @State. To build a new one you have to supply () -> Bool and (Bool) -> Void, and the only way to reach the underlying storage from inside your extension is through the binding you were given.

The inout attempt fails for an unrelated reason. An escaping closure cannot capture an inout parameter, because that parameter’s storage is only guaranteed to exist for the duration of the call — hence Escaping closure captures 'inout' parameter. Making the method mutating does not change that.

How to fix it

1. Forward the binding instead of rebuilding it

The parameter already is a Binding<Bool>, and .sheet(isPresented:) wants exactly that. There is nothing to convert, so the whole Binding(get:set:) dance can be dropped:

import SwiftUI

extension View {
    func sheet<Content: View>(
        isSheetPresented: Binding<Bool>,
        onDismiss: (() -> Void)? = nil,
        @ViewBuilder content: @escaping () -> Content
    ) -> some View {
        sheet(
            isPresented: isSheetPresented,
            onDismiss: onDismiss,
            content: content
        )
    }
}

Call site:

struct SomeView: View {
    @State private var isSheetPresented = false

    var body: some View {
        Button("Show") { isSheetPresented = true }
            .sheet(isSheetPresented: $isSheetPresented) {
                Text("Hello")
            }
    }
}

Because the first argument label differs (isSheetPresented: versus isPresented:), overload resolution picks SwiftUI’s method inside the extension rather than recursing. Giving your wrapper a distinct name such as presentSheet(isPresented:) makes that obvious to anyone reading it later.

2. If you really must rebuild the binding, use wrappedValue

Sometimes you do need a new Binding — for example to log writes, clamp a value, or adapt one type to another. In that case go through wrappedValue:

import SwiftUI

extension View {
    func sheet<Content: View>(
        isSheetPresented: Binding<Bool>,
        onDismiss: (() -> Void)? = nil,
        @ViewBuilder content: @escaping () -> Content
    ) -> some View {
        let bridged = Binding<Bool>(
            get: { isSheetPresented.wrappedValue },
            set: { isSheetPresented.wrappedValue = $0 }
        )
        return sheet(isPresented: bridged, onDismiss: onDismiss, content: content)
    }
}

This compiles because Binding.wrappedValue is declared with a nonmutating set. Assigning to it does not modify the local let — it forwards the write to whatever storage the binding was created from, which is your @State. The let rule is respected; the mutation happens somewhere else entirely.

3. Use @ViewBuilder, not @ContentBuilder

The snippets in the original attempt annotate the trailing closure with @ContentBuilder. No such attribute exists in SwiftUI, so that line fails before you ever reach the binding problem. The correct attribute is @ViewBuilder, and it belongs on the parameter, as shown above. Keep @escaping as well, since the closure outlives the call.

4. If the wrapper was for fresh sheet content, a capture list is cheaper

The original motivation was a workaround: rebuilding the binding appeared to make the sheet’s content see current state. One answer points out that the rebuilt Binding is not what creates that dependency — reading the @State in body does, and passing $isSheetPresented to .sheet already does that read. If the goal is simply to make the content closure use the value from the body pass that presented the sheet, capture it explicitly:

struct SomeView: View {
    @State private var isSheetPresented = false
    @State private var name = ""

    var body: some View {
        Button("Show") { isSheetPresented = true }
            .sheet(isPresented: $isSheetPresented) { [name] in
                Text("Name at presentation: \(name)")
            }
    }
}

A capture list copies the value into the closure at creation time and allocates nothing extra, which is why it is the lighter option. Note that this addresses stale content inside the sheet, not the compile error — the compile error is fixed by steps 1 and 2.

How to prevent it

  • Before writing Binding(get:set:), check whether you already have a Binding in hand. Usually you do, and forwarding it is enough.
  • Treat Binding as a pair of closures, not a reference. Reach its storage through wrappedValue.
  • Remember that every function parameter is a let. If you need a mutable local, declare var copy = value and work on that.
  • Never combine inout with an escaping closure; the compiler will reject it every time.
  • Use @ViewBuilder for view-producing closure parameters, and keep @escaping on them.
  • Give wrapper methods distinct argument labels so overload resolution against the SwiftUI original stays unambiguous.

FAQ

Why can I assign to isSheetPresented.wrappedValue when isSheetPresented is a let?

Because wrappedValue‘s setter is nonmutating. The assignment does not write into the Binding struct you hold; it calls the setter closure stored inside it, which writes to the original @State. Swift only forbids mutating the constant itself.

Can I make the extension mutating and take an inout Bool?

No. Even if the method compiles, the escaping content closure cannot capture an inout parameter, so you get Escaping closure captures 'inout' parameter. Pass a Binding instead — that is the type designed for exactly this hand-off.

Does this pattern work for .fullScreenCover and .alert too?

Yes. Those modifiers also take isPresented: Binding<Bool>, so the same forwarding wrapper works unchanged — just call the corresponding modifier inside your extension and keep the argument labels distinct from the SwiftUI originals.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.