Skip to content

Instantly share code, notes, and snippets.

@ryanlintott
Last active February 5, 2025 13:33
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

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