Why Certain View Modifiers in Swift 6 Cannot Use the @State Property

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In Xcode 16, to improve SwiftUI’s performance under Swift 6 mode, Apple made several adjustments to the SwiftUI framework’s APIs to meet stricter concurrency checks. The most notable change is the comprehensive annotation of the View protocol with @MainActor. While these optimizations generally enhance the developer experience in Swift 6 mode, they also introduce some seemingly anomalous compile-time errors in specific scenarios. This article delves into why certain view modifiers cannot directly use @State properties and provides corresponding solutions.

The Problem

A typical case on the Apple Developer Forums illustrates the issue: a piece of code that compiles successfully in non-Swift 6 mode triggers a compile error when switched to Swift 6 mode, provided that an @State property is referenced within an alignmentGuide:

Swift
struct ContentView: View {
    @State var isVisible = false
    var body: some View {
        Text("Hello, world!")
            .alignmentGuide(.bottom) {
                // error: Main actor-isolated property 'isVisible' cannot be referenced from a Sendable closure
                isVisible ? $0[.bottom] : $0[.top]
            }
    }
}

This error message is perplexing: Does Swift 6 mode prohibit the use of @State properties within view modifiers? However, testing other view modifiers reveals that most do not produce errors. This raises a critical question: Why do only certain specific view modifiers exhibit this behavior?

Analyzing the Cause

To understand this issue, we need to consider several key points:

1. View Protocol Annotated with @MainActor

Starting with Xcode 16, the View protocol is entirely annotated with @MainActor, which means that any context within it, including isVisible, inherits the @MainActor attribute. Examining the alignmentGuide function’s declaration, we find that it only accepts a @Sendable synchronous closure:

Swift
nonisolated public func alignmentGuide(_ g: VerticalAlignment, computeValue: @escaping @Sendable (ViewDimensions) -> CGFloat) -> some View

From the error message, the Swift compiler perceives isVisible as not being a Sendable type. This might be confusing since, according to State’s declaration:

Swift
extension State: Sendable where Value: Sendable {}

When WrappedValue conforms to Sendable, State should also conform to Sendable. Therefore, using isVisible (i.e., the wrappedValue of State) within the closure should be legitimate.

2. @State and Property Wrappers Are More Complex

However, the reality is more nuanced. @State and other property wrappers aren’t simple stored properties; they are syntactic sugar. At compile time, an @State property is transformed into the following form:

Swift
struct Demo {
    @State var isVisible = false
}

// After transformation
struct Demo {
    private var _isVisible = State(wrappedValue: isVisible)
    var isVisible: Bool {
        get { _isVisible.wrappedValue }
        set { _isVisible.wrappedValue = newValue }
    }
}

This means that accessing isVisible within a closure actually invokes the getter method of the computed property:

Swift
isVisible ? $0[.bottom] : $0[.top]
// Corresponds to
isVisible.getter() ? $0[.bottom] : $0[.top]

Since this getter method is not @Sendable, the Swift 6 compiler raises an error: “Main actor-isolated property ‘isVisible’ cannot be referenced from a Sendable closure.”

3. Verifying with Swift Intermediate Language (SIL)

We can verify this analysis by inspecting the SIL (Swift Intermediate Language) file:

Swift
@MainActor @preconcurrency struct ContentView: View {
  @State @_projectedValueProperty($isVisible) @MainActor @preconcurrency var isVisible: Bool {
    get
    @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
    nonmutating set
    @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
    nonmutating _modify
  }
  @MainActor @preconcurrency var $isVisible: Binding<Bool> { get }
  @_hasStorage @MainActor @preconcurrency @_hasInitialValue var _isVisible: State<Bool> { get set }
  @MainActor @preconcurrency var body: some View { get }
  typealias Body = @_opaqueReturnTypeOf("$s11ContentViewAAV4bodyQrvp", 0) __
  @MainActor @preconcurrency init()
  nonisolated init(isVisible: Bool = false)
}

The SIL code clearly shows that the compiler creates a _isVisible: State<Bool> annotated with @_hasStorage, while isVisible is merely a computed property for accessing this storage property.

Further inspecting the alignmentGuide’s SIL code reveals that reading isVisible indeed involves calling a @MainActor-annotated getter method:

Swift
bb0(%0 : $*ViewDimensions, %1 : @closureCapture $ContentView):
  debug_value %0 : $*ViewDimensions, let, name "$0", argno 1, expr op_deref // id: %2
  debug_value %1 : $ContentView, let, name "self", argno 2 // id: %3
  // function_ref ContentView.isVisible.getter
  %4 = function_ref @$s11ContentViewAAV3topSbvg : $@convention(method) (@guaranteed ContentView) -> Bool // user: %5
  %5 = apply %4(%1) : $@convention(method) (@guaranteed ContentView) -> Bool // user: %6
  %6 = struct_extract %5 : $Bool, #Bool._value    // user: %7
  cond_br %6, bb1, bb2                            // id: %7

From this, we can conclude:

Because in Swift 6 mode, @Sendable synchronous closures are not allowed to call @MainActor methods, it is impossible to directly use the @State property isVisible within alignmentGuide.

Solutions

Understanding the root cause allows us to implement the following two solutions:

Solution 1: Use the Underlying Value of State

As seen from the SIL file, _isVisible is a stored property. Therefore, we can directly use its wrappedValue within the alignmentGuide closure (since Bool conforms to Sendable):

Swift
.alignmentGuide(.bottom) {
    _isVisible.wrappedValue ? $0[.bottom] : $0[.top]
}

Solution 2: Pre-fetch the Sendable Value Outside the Closure

While Solution 1 addresses the issue, it has two notable drawbacks:

  1. Using a property name with an underscore prefix affects code readability.
  2. This approach is difficult to generalize to other property wrappers (such as @StateObject, @Environment, etc.).

Therefore, a more elegant solution is to pre-fetch the State value outside the closure:

Swift
struct ContentView: View {
    @State var isVisible = false
    var body: some View {
        let isVisible = isVisible // Calls the getter on MainActor
        Text("Hello, world!")
            .alignmentGuide(.bottom) {
                isVisible ? $0[.bottom] : $0[.top]
            }
    }
}

Here, the newly declared isVisible is a pure Sendable value, safe to use within the @Sendable closure.

Conclusion

The issue discussed in this article is not limited to alignmentGuide but also occurs in several other view modifiers that use @Sendable synchronous closures, including scrollTransition, visualEffect, and keyframeAnimator. The solutions outlined above apply equally to these cases.

When migrating to Swift 6 mode, developers often encounter various warnings and compile errors. In facing these issues, we should not merely focus on making the code compile but also strive to understand the underlying causes of the errors. This deep understanding not only helps us write better code but also enables us to navigate this significant language version update smoothly, making more informed technical decisions.

This is the final article I published in 2024. I will be taking a break for a while and plan to resume publishing new articles after the Chinese New Year (mid-February 2025). During this time, Fatbobman’s Swift Weekly will continue to be released as usual.

Happy New Year to everyone! 🎉

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed