Skip to content

Instantly share code, notes, and snippets.

@ryanlintott
Last active March 10, 2025 14:06
Show Gist options
  • Save ryanlintott/54f94bb45afebf824723847154195b75 to your computer and use it in GitHub Desktop.
Save ryanlintott/54f94bb45afebf824723847154195b75 to your computer and use it in GitHub Desktop.
FB15989990 - A Sendable onPreferenceChange closure cannot change main actor view properties in XCode 16.2 beta 3.

In XCode 16.2 beta 3 the onPreferenceChange view modifier closure was changed to be @Sendable. This change caused a number of errors in my code when I try to use the value in the closure to update a view property.

I’ve attached a simple WidthReader example that shows these errors.

This change seems similar to the recent change to the alignmentGuide closure. The difference is the alignmentGuide closure is used to change the value of the alignment guide and I have no expectation of using it to update view properties. The onPreferenceChange closure returns no value and as far as I can figure the only use of it would be to modify view properties.

As a workaround I could wrap each modification in a main actor Task but I’m concerned about this somehow delaying my updates and causing visual glitches in the UI.

Ideally I would like the onPreferenceChange modifier to be annotated with @MainActor instead of @Sendable as I can’t see a use case when someone would use the value to update something off the main thread.

If this is not a bug and this closure will continue to be @Sendable and not @MainActor I would really like to know so I can update my Swift Packages accordingly to deal with this change.

//
// View+onPreferenceChangeMainActor.swift
// FrameUp
//
// Created by Ryan Lintott on 2024-11-28.
//
import SwiftUI
struct OnPreferenceChangeUpdatingBinding<K>: ViewModifier where K : PreferenceKey, K.Value : Equatable & Sendable {
let key: K.Type
@Binding var value: K.Value
var predicate: @Sendable (_ currentValue: K.Value, _ newValue: K.Value) -> Bool
func body(content: Content) -> some View {
content
.onPreferenceChange(key) { [$value] newValue in
if predicate($value.wrappedValue, newValue) {
$value.wrappedValue = newValue
}
}
}
}
internal extension View {
/// Updating a binding based on a predicate.
@preconcurrency nonisolated func onPreferenceChange<K>(_ key: K.Type = K.self, update value: Binding<K.Value>, where predicate: @escaping @Sendable (_ oldValue: K.Value, _ newValue: K.Value) -> Bool) -> some View where K : PreferenceKey, K.Value : Equatable & Sendable {
modifier(OnPreferenceChangeUpdatingBinding(key: key, value: value, predicate: predicate))
}
/// Updating a binding on every change.
@preconcurrency @inlinable nonisolated func onPreferenceChange<K>(_ key: K.Type = K.self, update value: Binding<K.Value>) -> some View where K : PreferenceKey, K.Value : Equatable & Sendable {
onPreferenceChange(key) { newValue in
value.wrappedValue = newValue
}
}
/// Wrap the action in a MainActor Task.
@preconcurrency @inlinable nonisolated func onPreferenceChangeMainActor<K>(_ key: K.Type = K.self, perform action: @escaping @MainActor (K.Value) -> Void) -> some View where K : PreferenceKey, K.Value : Equatable & Sendable {
onPreferenceChange(key) { newValue in
Task { @MainActor in
action(newValue)
}
}
}
}
//
// WidthReader.swift
// FrameUp
//
// Created by Ryan Lintott on 2021-06-10.
//
import SwiftUI
/// Preference key used to pass the width of a child view up the hierarchy.
///
/// Used by `WidthReader`.
///
/// Only one key is necessary and works even in nested situations because the value is captured and used inside reader view. Nested views will replace the value before reading it so the correct value should always be sent through.
public struct WidthKey: PreferenceKey {
public typealias Value = CGFloat
public static let defaultValue: CGFloat = .zero
public static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
/// A view that takes the available width and provides this measurement to its content. Unlike `GeometryReader` this view will not take up all the available height and will instead fit the height of the content.
///
/// Useful inside vertical scroll views where you want to measure the width without specifying a frame height.
public struct WidthReader<Content: View>: View {
let alignment: HorizontalAlignment
@ViewBuilder let content: (CGFloat) -> Content
@State private var width: CGFloat = 0
/// Creates a view takes the available width and provides this measurement to its content.
/// - Parameters:
/// - alignment: Horizontal alignment
/// - content: any `View`
public init(alignment: HorizontalAlignment = .center, @ViewBuilder content: @escaping (CGFloat) -> Content) {
self.alignment = alignment
self.content = content
}
@ViewBuilder
public var elements: some View {
Color.clear.overlay(
GeometryReader { proxy in
Color.clear
.preference(key: WidthKey.self, value: proxy.size.width)
}
)
.frame(height: 0)
.onPreferenceChange(WidthKey.self) { newWidth in
if width == newWidth { return }
width = newWidth
}
/// Only show the content if the width has been set (is not zero)
if width > 0 {
content(width)
}
}
public var body: some View {
VStack(alignment: alignment, spacing: 0) {
elements
}
}
}
@ryanlintott
Copy link
Author

I've added an onPreferenceChangeMainActor wrapper which can be used as a workaround for now. I've only tested it a bit but it appears to work.

@ptrkstr
Copy link

ptrkstr commented Dec 19, 2024

Hey @ryanlintott, did you happen to hear back from Apple on this?

@ryanlintott
Copy link
Author

I did but the answer was "the behavior you experienced is currently functioning as intended."

@Lopdo
Copy link

Lopdo commented Dec 20, 2024

This should also work:

.onPreferenceChange(ValueKey.self) { [$prop] value in
  $prop.wrappedValue = value
}

I came across it on the Vapor discord server and haven't tested it thoroughly myself.

@ryanlintott
Copy link
Author

This works! I'm just not sure how it works. I didn't know you could copy a Binding to another thread and then alter the wrapped value in order to update the original value. I also thought even if you did do this you would probably mess up animations but they seem to work correctly. I guess as long as you don't apply animations when you're updating the wrapped value it should be fine.

@ryanlintott
Copy link
Author

I've updated the code above to include alternatives to onPreferenceChange that could also work. One updates a binding on every change and another updates based on a predicate that's sendable.

@ptrkstr
Copy link

ptrkstr commented Jan 9, 2025

There is a good solution posted in this PR as well: https://github.com/StanfordSpezi/SpeziViews/pull/48/files

if Thread.isMainThread {
    MainActor.assumeIsolated {
        self.width = width
    }
} else {
    Task { @MainActor in
        self.width = width
    }
}

@ryanlintott
Copy link
Author

Is Thread.isMainThread equivalent to running on MainActor? I imagine it could be but I'm not sure. Is there any way isMainThread would return true but MainActor.assumeIsolated would crash? @mattmassicotte Maybe you could help out here?

@mattmassicotte
Copy link

I’m pretty sure this is a safe assumption to make.

However, this does make the Task conditional, and that means sometimes this’ll run synchronously and sometimes not. I’m not sure that’s a great variability to introduce.

@ryanlintott
Copy link
Author

Yeah, I kinda agree with you there. I think I'll stick to using the Task every time for consistency.

@ryanlintott
Copy link
Author

Got another reply from Apple confirming this is operating as designed and that they will no longer monitor my feedback.

@tinder-tannerbennett
Copy link

😑

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment