diff --git a/Example/Modules/Platform/cocoa/SwiftUI_SPI.swiftinterface b/Example/Modules/Platform/cocoa/SwiftUI_SPI.swiftinterface index d01ddb69d..20aaad6ce 100644 --- a/Example/Modules/Platform/cocoa/SwiftUI_SPI.swiftinterface +++ b/Example/Modules/Platform/cocoa/SwiftUI_SPI.swiftinterface @@ -5,6 +5,23 @@ import QuartzCore import Swift @_exported import SwiftUI +@available(iOS 18.2, macOS 15.2, *) +@frozen public struct TransactionalPreferenceActionModifier : SwiftUI.ViewModifier where Key : SwiftUI.PreferenceKey, Key.Value : Swift.Equatable { + public var action: (Key.Value, SwiftUI.Transaction) -> Swift.Void + @_alwaysEmitIntoClient public init(action: @escaping (Key.Value, SwiftUI.Transaction) -> Swift.Void) { + self.action = action + } + nonisolated public static func _makeView(modifier: SwiftUI._GraphValue>, inputs: SwiftUI._ViewInputs, body: @escaping (SwiftUI._Graph, SwiftUI._ViewInputs) -> SwiftUI._ViewOutputs) -> SwiftUI._ViewOutputs + public typealias Body = Swift.Never +} + +@available(iOS 18.2, macOS 15.2, *) +extension SwiftUI.View { + @_alwaysEmitIntoClient nonisolated public func onPreferenceChange(_ key: Key.Type = Key.self, action: @escaping @Sendable (Key.Value, SwiftUI.Transaction) -> Swift.Void) -> some SwiftUI.View where Key : SwiftUI.PreferenceKey, Key.Value : Swift.Equatable { + return modifier(SwiftUI_SPI.TransactionalPreferenceActionModifier(action: action)) + } +} + @_hasMissingDesignatedInitializers @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) public class CAHostingLayer : QuartzCore.CALayer where Content : SwiftUI.View { public init(rootView: Content, environment: SwiftUI.EnvironmentValues = .init()) diff --git a/Example/OpenSwiftUIUITests/Modifier/ViewModifier/PreferenceActionModifierUITests.swift b/Example/OpenSwiftUIUITests/Modifier/ViewModifier/PreferenceActionModifierUITests.swift new file mode 100644 index 000000000..da035b753 --- /dev/null +++ b/Example/OpenSwiftUIUITests/Modifier/ViewModifier/PreferenceActionModifierUITests.swift @@ -0,0 +1,78 @@ +// +// PreferenceActionModifierUITests.swift +// OpenSwiftUIUITests + +import Foundation +import Testing +@testable import TestingHost + +private final class PreferenceActionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var value: String? + + func record(value: String) { + lock.lock() + defer { lock.unlock() } + self.value = value + } + + func snapshot() -> String? { + lock.lock() + defer { lock.unlock() } + return value + } +} + +private final class TransactionalPreferenceActionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var value: String? + private var disablesAnimations = false + + func record(value: String, disablesAnimations: Bool) { + lock.lock() + defer { lock.unlock() } + self.value = value + self.disablesAnimations = disablesAnimations + } + + func snapshot() -> (value: String?, disablesAnimations: Bool) { + lock.lock() + defer { lock.unlock() } + return (value, disablesAnimations) + } +} + +@MainActor +struct PreferenceActionModifierUITests { + @Test + func onPreferenceChange() { + let recorder = PreferenceActionRecorder() + let controller = AnimationDebugController( + PreferenceActionModifierExample(action: { value in + recorder.record(value: value) + }) + ) + controller.advance(interval: .zero) + #expect(recorder.snapshot() == "changed") + withExtendedLifetime(controller) {} + } + + @Test + @available(iOS 18.2, macOS 15.2, *) + func transactionalOnPreferenceChange() { + let recorder = TransactionalPreferenceActionRecorder() + let controller = AnimationDebugController( + TransactionalPreferenceActionExample(action: { value, transaction in + recorder.record( + value: value, + disablesAnimations: transaction.disablesAnimations + ) + }) + ) + controller.advance(interval: .zero) + let snapshot = recorder.snapshot() + #expect(snapshot.value == "changed") + #expect(snapshot.disablesAnimations) + withExtendedLifetime(controller) {} + } +} diff --git a/Example/Project.swift b/Example/Project.swift index dc92016bc..689a7ae0f 100644 --- a/Example/Project.swift +++ b/Example/Project.swift @@ -460,7 +460,8 @@ let project = Project( targets: targets, schemes: schemes, additionalFiles: [ - "../Configurations/**", + "../Configurations/OpenSwiftUI-Info.plist", + "../Configurations/Shared/basic/**", "Modules/**", "ReferenceImages/**", "OpenSwiftUIUITests/OpenSwiftUIUITests.xctestplan", diff --git a/Example/Shared/ViewModifier/PreferenceActionModifierExample.swift b/Example/Shared/ViewModifier/PreferenceActionModifierExample.swift new file mode 100644 index 000000000..6a926e41c --- /dev/null +++ b/Example/Shared/ViewModifier/PreferenceActionModifierExample.swift @@ -0,0 +1,27 @@ +// +// PreferenceActionModifierExample.swift +// Shared + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct PreferenceActionModifierExample: View { + private struct Key: PreferenceKey { + static let defaultValue = "" + + static func reduce(value: inout String, nextValue: () -> String) { + value = nextValue() + } + } + + var action: (String) -> Void + + var body: some View { + Color.red + .preference(key: Key.self, value: "changed") + .onPreferenceChange(Key.self, perform: action) + } +} diff --git a/Example/Shared/ViewModifier/TransactionalPreferenceActionModifierExample.swift b/Example/Shared/ViewModifier/TransactionalPreferenceActionModifierExample.swift new file mode 100644 index 000000000..d38c076a3 --- /dev/null +++ b/Example/Shared/ViewModifier/TransactionalPreferenceActionModifierExample.swift @@ -0,0 +1,37 @@ +// +// TransactionalPreferenceActionModifierExample.swift +// Shared + +#if OPENSWIFTUI +@_spi(Private) import OpenSwiftUI +#else +import SwiftUI_SPI +#endif + +#if OPENSWIFTUI +@available(OpenSwiftUI_v8_0, *) +#else +@available(iOS 18.2, macOS 15.2, *) +#endif +struct TransactionalPreferenceActionExample: View { + private struct Key: PreferenceKey { + static let defaultValue = "" + + static func reduce(value: inout String, nextValue: () -> String) { + value = nextValue() + } + } + + var action: @Sendable (String, Transaction) -> Void + + var body: some View { + Color.red + .preference(key: Key.self, value: "changed") + .onPreferenceChange(Key.self) { value, transaction in + action(value, transaction) + } + .transaction { transaction in + transaction.disablesAnimations = true + } + } +} diff --git a/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift b/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift index de6a908a4..693229516 100644 --- a/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift +++ b/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift @@ -9,16 +9,14 @@ @available(OpenSwiftUI_v2_0, *) extension _PreferenceWritingModifier: _SceneModifier { - @MainActor - @preconcurrency public static func _makeScene( modifier: _GraphValue, inputs: _SceneInputs, body: @escaping (_Graph, _SceneInputs) -> _SceneOutputs ) -> _SceneOutputs { - var inputs = inputs - inputs.preferences.remove(Key.self) - var outputs = body(_Graph(), inputs) + var newInputs = inputs + newInputs.preferences.remove(Key.self) + var outputs = body(_Graph(), newInputs) outputs.preferences .makePreferenceWriter( inputs: inputs.preferences, @@ -32,9 +30,7 @@ extension _PreferenceWritingModifier: _SceneModifier { @available(OpenSwiftUI_v4_0, *) extension Scene { @inlinable - @MainActor - @preconcurrency - internal func preference( + package func preference( key: K.Type = K.self, value: K.Value ) -> some Scene where K: PreferenceKey { @@ -46,8 +42,6 @@ extension Scene { @available(OpenSwiftUI_v2_0, *) extension _PreferenceTransformModifier: _SceneModifier { - @MainActor - @preconcurrency public static func _makeScene( modifier: _GraphValue, inputs: _SceneInputs, @@ -66,9 +60,7 @@ extension _PreferenceTransformModifier: _SceneModifier { @available(OpenSwiftUI_v4_0, *) extension Scene { @inlinable - @MainActor - @preconcurrency - internal func transformPreference( + func transformPreference( _ key: K.Type = K.self, _ callback: @escaping (inout K.Value) -> Void ) -> some Scene where K: PreferenceKey { diff --git a/Sources/OpenSwiftUI/Modifier/ViewModifier/PreferenceActionModifier.swift b/Sources/OpenSwiftUI/Modifier/ViewModifier/PreferenceActionModifier.swift new file mode 100644 index 000000000..90954ae8e --- /dev/null +++ b/Sources/OpenSwiftUI/Modifier/ViewModifier/PreferenceActionModifier.swift @@ -0,0 +1,21 @@ +// +// PreferenceActionModifier.swift +// OpenSwiftUI +// +// Audited for 8.0.66 +// Status: Complete + +package import OpenSwiftUICore + +@_spi(Private) +@available(OpenSwiftUI_v8_0, *) +extension View { + /// Adds an action to perform when the specified preference key's value + /// changes, including the transaction that produced the change. + nonisolated public func onPreferenceChange( + _ key: K.Type = K.self, + action: @escaping @Sendable (K.Value, Transaction) -> Void + ) -> some View where K: PreferenceKey, K.Value: Equatable { + return modifier(TransactionalPreferenceActionModifier(action: action)) + } +} diff --git a/Sources/OpenSwiftUICore/Data/Preference/PreferenceActionModifier.swift b/Sources/OpenSwiftUICore/Data/Preference/PreferenceActionModifier.swift new file mode 100644 index 000000000..b0cec83d3 --- /dev/null +++ b/Sources/OpenSwiftUICore/Data/Preference/PreferenceActionModifier.swift @@ -0,0 +1,216 @@ +// +// PreferenceActionModifier.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 264234112339315C9A664F0B7F8B50C1 (SwiftUICore) + +import OpenAttributeGraphShims + +// MARK: - View + onPreferenceChange + +@available(OpenSwiftUI_v1_0, *) +extension View { + /// Adds an action to perform when the specified preference key's value + /// changes. + /// + /// - Parameters: + /// - key: The key to monitor for value changes. + /// - action: The action to perform when the value for `key` changes. The + /// `action` closure passes the new value as its parameter. + /// + /// - Returns: A view that triggers `action` when the value for `key` + /// changes. + @inlinable + nonisolated public func onPreferenceChange( + _ key: K.Type = K.self, + perform action: @escaping (K.Value) -> Void + ) -> some View where K: PreferenceKey, K.Value: Equatable { + return modifier(_PreferenceActionModifier(action: action)) + } +} + +// MARK: - PreferenceActionModifier + +@available(OpenSwiftUI_v1_0, *) +@frozen +public struct _PreferenceActionModifier: ViewModifier, MultiViewModifier, PrimitiveViewModifier where K: PreferenceKey, K.Value: Equatable { + public var action: (_ value: K.Value) -> Void + + @inlinable + public init(action: @escaping (_ value: K.Value) -> Void) { + self.action = action + } + + nonisolated public static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + var newInputs = inputs + newInputs.preferences.add(K.self) + let outputs = body(_Graph(), newInputs) + if let keyValue = outputs[K.self] { + let binder = Attribute(PreferenceBinder( + modifier: modifier.value, + keyValue: keyValue, + phase: inputs.viewPhase, + lastResetSeed: .zero, + lastValue: nil + )) + binder.setFlags(.transactional, mask: .all) + } + return outputs + } +} + +@available(*, unavailable) +extension _PreferenceActionModifier: Sendable {} + +// MARK: - PreferenceBinder + +private struct PreferenceBinder: StatefulRule, AsyncAttribute where K: PreferenceKey, K.Value: Equatable { + @Attribute var modifier: _PreferenceActionModifier + @Attribute var keyValue: K.Value + @Attribute var phase: _GraphInputs.Phase + var cycleDetector: UpdateCycleDetector + var lastResetSeed: UInt32 + var lastValue: K.Value? + + init( + modifier: Attribute<_PreferenceActionModifier>, + keyValue: Attribute, + phase: Attribute<_GraphInputs.Phase>, + cycleDetector: UpdateCycleDetector = .init(), + lastResetSeed: UInt32, + lastValue: K.Value? + ) { + self._modifier = modifier + self._keyValue = keyValue + self._phase = phase + self.cycleDetector = cycleDetector + self.lastResetSeed = lastResetSeed + self.lastValue = lastValue + } + + typealias Value = Void + + mutating func updateValue() { + if lastResetSeed != phase.resetSeed { + lastResetSeed = phase.resetSeed + cycleDetector.reset() + lastValue = nil + } + let (newValue, valueChanged) = $keyValue.changedValue() + guard (lastValue == nil && _SemanticFeature_v6_1.isEnabled) || (valueChanged && lastValue != newValue) else { + return + } + lastValue = newValue + guard cycleDetector.dispatch( + label: "Bound preference \(K.self)", + isDebug: true + ) else { + return + } + let action = Graph.withoutUpdate { + modifier.action + } + Update.enqueueAction(reason: nil) { + action(newValue) + } + } +} + +// MARK: - TransactionalPreferenceActionModifier + +// Added in OpenSwiftUI_v6_2 (6.2.16) +package struct TransactionalPreferenceActionModifier: ViewModifier, MultiViewModifier, PrimitiveViewModifier where K: PreferenceKey, K.Value: Equatable { + var action: (_ value: K.Value, _ transaction: Transaction) -> Void + + package init(action: @escaping (_ value: K.Value, _ transaction: Transaction) -> Void) { + self.action = action + } + + nonisolated package static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + var newInputs = inputs + newInputs.preferences.add(K.self) + let outputs = body(_Graph(), newInputs) + if let keyValue = outputs[K.self] { + let binder = Attribute(TransactionalPreferenceBinder( + modifier: modifier.value, + keyValue: keyValue, + phase: inputs.viewPhase, + transaction: inputs.transaction, + lastResetSeed: .zero, + lastValue: nil + )) + binder.setFlags(.transactional, mask: .all) + } + return outputs + } +} + +// MARK: - TransactionalPreferenceBinder + +private struct TransactionalPreferenceBinder: StatefulRule, AsyncAttribute where K: PreferenceKey, K.Value: Equatable { + @Attribute var modifier: TransactionalPreferenceActionModifier + @Attribute var keyValue: K.Value + @Attribute var phase: _GraphInputs.Phase + @Attribute var transaction: Transaction + var cycleDetector: UpdateCycleDetector + var lastResetSeed: UInt32 + var lastValue: K.Value? + + init( + modifier: Attribute>, + keyValue: Attribute, + phase: Attribute<_GraphInputs.Phase>, + transaction: Attribute, + cycleDetector: UpdateCycleDetector = .init(), + lastResetSeed: UInt32, + lastValue: K.Value? + ) { + self._modifier = modifier + self._keyValue = keyValue + self._phase = phase + self._transaction = transaction + self.cycleDetector = cycleDetector + self.lastResetSeed = lastResetSeed + self.lastValue = lastValue + } + + typealias Value = Void + + mutating func updateValue() { + if lastResetSeed != phase.resetSeed { + lastResetSeed = phase.resetSeed + cycleDetector.reset() + lastValue = nil + } + let (newValue, valueChanged) = $keyValue.changedValue() + guard lastValue == nil || (valueChanged && lastValue != newValue) else { + return + } + lastValue = newValue + guard cycleDetector.dispatch( + label: "Bound preference \(K.self)", + isDebug: true + ) else { + return + } + let action = Graph.withoutUpdate { + modifier.action + } + let transaction = Graph.withoutUpdate { + self.transaction + } + Update.enqueueAction(reason: nil) { + action(newValue, transaction) + } + } +} diff --git a/Sources/OpenSwiftUICore/Data/Preference/PreferenceWritingModifier.swift b/Sources/OpenSwiftUICore/Data/Preference/PreferenceWritingModifier.swift index c0bf0206a..a5baf8080 100644 --- a/Sources/OpenSwiftUICore/Data/Preference/PreferenceWritingModifier.swift +++ b/Sources/OpenSwiftUICore/Data/Preference/PreferenceWritingModifier.swift @@ -3,7 +3,7 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: WIP +// Status: Complete // ID: 62AFEFEED1A7034F09E120B80AB01BF9 (SwiftUICore) package import OpenAttributeGraphShims @@ -13,8 +13,6 @@ package import OpenAttributeGraphShims /// A modifier that returns a value for a named preference key. @available(OpenSwiftUI_v1_0, *) @frozen -@MainActor -@preconcurrency public struct _PreferenceWritingModifier: ViewModifier, MultiViewModifier, PrimitiveViewModifier where Key: PreferenceKey { /// The value to return for `Key` @@ -25,14 +23,14 @@ public struct _PreferenceWritingModifier: ViewModifier, MultiViewModifier, self.value = value } - nonisolated public static func _makeView( + nonisolated public static func _makeView( modifier: _GraphValue, inputs: _ViewInputs, body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs ) -> _ViewOutputs { - var inputs = inputs - inputs.preferences.remove(Key.self) - var outputs = body(_Graph(), inputs) + var newInputs = inputs + newInputs.preferences.remove(Key.self) + var outputs = body(_Graph(), newInputs) outputs.preferences .makePreferenceWriter( inputs: inputs.preferences, @@ -168,8 +166,6 @@ private struct HostPreferencesWriter: StatefulRule, AsyncAttribute, CustomStr @available(OpenSwiftUI_v1_0, *) extension View { - @MainActor - @preconcurrency package func truePreference(_ key: K.Type = K.self) -> some View where K: PreferenceKey, K.Value == Bool { modifier(TruePreferenceWritingModifier()) }