From 8c321fb64c48ad46a550492278a1c7837a4f53c9 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 21 Jun 2026 23:15:10 +0800 Subject: [PATCH 1/3] Add GeometryActionModifier API --- .../GeometryActionModifierExample.swift | 31 ++ .../ViewModifier/GeometryActionModifier.swift | 402 ++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 Example/Shared/Layout/Geometry/GeometryActionModifierExample.swift create mode 100644 Sources/OpenSwiftUI/Modifier/ViewModifier/GeometryActionModifier.swift diff --git a/Example/Shared/Layout/Geometry/GeometryActionModifierExample.swift b/Example/Shared/Layout/Geometry/GeometryActionModifierExample.swift new file mode 100644 index 000000000..643454ce5 --- /dev/null +++ b/Example/Shared/Layout/Geometry/GeometryActionModifierExample.swift @@ -0,0 +1,31 @@ +// +// GeometryActionModifierExample.swift +// Shared + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif +import Dispatch + +struct GeometryActionModifierExample: View { + @State private var measuredSize: CGSize = .zero + @State private var isExpanded = false + + var body: some View { + VStack(spacing: 12) { + Color.blue + .frame(height: isExpanded ? 180 : 120) + .onGeometryChange(for: CGSize.self) { geometry in + geometry.size + } action: { newSize in + measuredSize = newSize + } + .onTapGesture { + isExpanded.toggle() + } + } + .padding() + } +} diff --git a/Sources/OpenSwiftUI/Modifier/ViewModifier/GeometryActionModifier.swift b/Sources/OpenSwiftUI/Modifier/ViewModifier/GeometryActionModifier.swift new file mode 100644 index 000000000..e26657f7c --- /dev/null +++ b/Sources/OpenSwiftUI/Modifier/ViewModifier/GeometryActionModifier.swift @@ -0,0 +1,402 @@ +// +// GeometryActionModifier.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: C54F0E3D6B140990A91B914A8FD7209B (SwiftUI) + +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +// MARK: - GeometryActionModifier + +@available(OpenSwiftUI_v4_0, *) +@frozen +@preconcurrency +public struct _GeometryActionModifier: UnaryViewModifier, PrimitiveViewModifier where Value: Equatable, Value: Sendable { + @preconcurrency public var value: @Sendable (GeometryProxy) -> Value + + public var action: (Value) -> Void + + @preconcurrency + @inlinable + public init( + value: @escaping @Sendable (GeometryProxy) -> Value, + action: @escaping (Value) -> Void + ) { + self.value = value + self.action = action + } + + // @_silgen_name("$s7SwiftUI23_GeometryActionModifierV5valueyxAA0C5ProxyVYbcvi") + @_silgen_name("$s11OpenSwiftUI23_GeometryActionModifierV5valueyxAA0D5ProxyVYbcvi") + @usableFromInline + internal mutating func valueInitAccessorABIShim(value: @escaping @Sendable (GeometryProxy) -> Value) { + self.value = value + } + + nonisolated public static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + let binder = Attribute(GeometryActionBinder( + provider: modifier.value, + position: inputs.position, + size: inputs.size, + transform: inputs.transform, + environment: inputs.environment, + safeAreaInsets: inputs.safeAreaInsets, + phase: inputs.viewPhase + )) + binder.flags = .transactional + return body(_Graph(), inputs) + } +} + +@available(*, unavailable) +extension _GeometryActionModifier: Sendable {} + +@available(OpenSwiftUI_v4_0, *) +extension _GeometryActionModifier: GeometryActionProvider { + func value(geometry: GeometryProxy) -> Value { + value(geometry) + } + + func action(oldValue _: Value, newValue: Value) { + action(newValue) + } +} + +// MARK: - GeometryActionModifier2 + +@available(OpenSwiftUI_v6_0, *) +@frozen +@preconcurrency +public struct _GeometryActionModifier2: ViewModifier, UnaryViewModifier, PrimitiveViewModifier where Value: Equatable, Value: Sendable { + private var _value: @Sendable (GeometryProxy) -> Value + + public var value: @Sendable (GeometryProxy) -> Value { + @usableFromInline + @storageRestrictions(initializes: _value) + init(initialValue) { + _value = initialValue + } + + @_silgen_name("$s11OpenSwiftUI24_GeometryActionModifier2V5valueyxAA0D5ProxyVcvg") + get { _value } + + @_silgen_name("$s11OpenSwiftUI24_GeometryActionModifier2V5valueyxAA0D5ProxyVcvs") + set { _value = newValue } + + @_silgen_name("$s11OpenSwiftUI24_GeometryActionModifier2V5valueyxAA0D5ProxyVcvM") + _modify { + var value: @Sendable (GeometryProxy) -> Value = _value + defer { _value = value } + yield &value + } + } + + public var action: (Value, Value) -> Void + + @inlinable + public init( + value: @escaping @Sendable (GeometryProxy) -> Value, + action: @escaping (Value, Value) -> Void + ) { + self.action = action + self.value = value + } + + nonisolated public static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + let binder = Attribute(GeometryActionBinder( + provider: modifier.value, + position: inputs.position, + size: inputs.size, + transform: inputs.transform, + environment: inputs.environment, + safeAreaInsets: inputs.safeAreaInsets, + phase: inputs.viewPhase + )) + binder.flags = .transactional + return body(_Graph(), inputs) + } +} + +@available(*, unavailable) +extension _GeometryActionModifier2: Sendable {} + +@available(OpenSwiftUI_v6_0, *) +extension _GeometryActionModifier2: GeometryActionProvider { + func value(geometry: GeometryProxy) -> Value { + value(geometry) + } + + func action(oldValue: Value, newValue: Value) { + action(oldValue, newValue) + } +} + +// MARK: - GeometryActionProvider + +private protocol GeometryActionProvider { + associatedtype Value: Equatable + + func value(geometry: GeometryProxy) -> Value + + func action(oldValue: Value, newValue: Value) +} + +// MARK: - GeometryActionBinder + +private struct GeometryActionBinder: StatefulRule, AsyncAttribute where Provider: GeometryActionProvider { + @Attribute var provider: Provider + @Attribute var position: ViewOrigin + @Attribute var size: ViewSize + @Attribute var transform: ViewTransform + @Attribute var environment: EnvironmentValues + @OptionalAttribute var safeAreaInsets: SafeAreaInsets? + @Attribute var phase: _GraphInputs.Phase + var cycleDetector: ValueCycleDetector + var legacyCycleDetector: UpdateCycleDetector? + var lastResetSeed: UInt32 + var proxySeed: UInt32 + var lastValue: Provider.Value? + + init( + provider: Attribute, + position: Attribute, + size: Attribute, + transform: Attribute, + environment: Attribute, + safeAreaInsets: OptionalAttribute, + phase: Attribute<_GraphInputs.Phase>, + cycleDetector: ValueCycleDetector = .init(), + legacyCycleDetector: UpdateCycleDetector? = .init(if: !isLinkedOnOrAfter(.v6), then: .init()), + lastResetSeed: UInt32 = 0, + proxySeed: UInt32 = 0, + lastValue: Provider.Value? = nil + ) { + self._provider = provider + self._position = position + self._size = size + self._transform = transform + self._environment = environment + self._safeAreaInsets = safeAreaInsets + self._phase = phase + self.cycleDetector = cycleDetector + self.legacyCycleDetector = legacyCycleDetector + self.lastResetSeed = lastResetSeed + self.proxySeed = proxySeed + self.lastValue = lastValue + } + + typealias Value = Void + + mutating func updateValue() { + if phase.resetSeed != lastResetSeed { + reset(seed: phase.resetSeed) + } + proxySeed &+= 1 + let proxy = GeometryProxy( + owner: attribute.identifier, + size: $size, + environment: $environment, + transform: $transform, + position: $position, + safeAreaInsets: $safeAreaInsets, + seed: proxySeed + ) + let provider = provider + let newValue = withObservation { + proxy.asCurrent { + provider.value(geometry: proxy) + } + } + let oldValue = lastValue ?? newValue + guard lastValue != newValue, dispatch(value: newValue) else { + return + } + Update.enqueueAction(reason: nil) { + provider.action(oldValue: oldValue, newValue: newValue) + } + } + + mutating func reset(seed _: UInt32) { + lastResetSeed = phase.resetSeed + legacyCycleDetector?.reset() + cycleDetector.reset() + lastValue = nil + } + + mutating func dispatch(value: Provider.Value) -> Bool { + defer { lastValue = value } + if legacyCycleDetector != nil { + return legacyCycleDetector!.dispatch( + label: "Geometry action", + ) + } else { + return cycleDetector.dispatch( + value: value, + label: "Geometry action", + ) + } + } +} + + +// MARK: - View + onGeometryChange + +extension View { + /// Adds an action to be performed when a value, created from a + /// geometry proxy, changes. + /// + /// The geometry of a view can change frequently, especially if + /// the view is contained within a ``ScrollView`` and that scroll view + /// is scrolling. + /// + /// You should avoid updating large parts of your app whenever + /// the scroll geometry changes. To aid in this, you provide two + /// closures to this modifier: + /// * transform: This converts a value of ``GeometryProxy`` to + /// your own data type. + /// * action: This provides the data type you created in `of` + /// and is called whenever the data type changes. + /// + /// For example, you can use this modifier to know how much of a view + /// is visible on screen. In the following example, + /// the data type you convert to is a ``Bool`` and the action is called + /// whenever the ``Bool`` changes. + /// + /// ScrollView(.horizontal) { + /// LazyHStack { + /// ForEach(videos) { video in + /// VideoView(video) + /// } + /// } + /// } + /// + /// struct VideoView: View { + /// var video: VideoModel + /// + /// var body: some View { + /// VideoPlayer(video) + /// .onGeometryChange(for: Bool.self) { proxy in + /// let frame = proxy.frame(in: .scrollView) + /// let bounds = proxy.bounds(of: .scrollView) ?? .zero + /// let intersection = frame.intersection( + /// CGRect(origin: .zero, size: bounds.size)) + /// let visibleHeight = intersection.size.height + /// return (visibleHeight / frame.size.height) > 0.75 + /// } action: { isVisible in + /// video.updateAutoplayingState( + /// isVisible: isVisible) + /// } + /// } + /// } + /// + /// For easily responding to geometry changes of a scroll view, see the + /// ``View/onScrollGeometryChange(for:of:action:)`` modifier. + /// + /// - Parameters: + /// - type: The type of value transformed from a ``GeometryProxy``. + /// - transform: A closure that transforms a ``GeometryProxy`` + /// to your type. + /// - action: A closure to run when the transformed data changes. + /// - newValue: The new value that failed the comparison check. + @available(OpenSwiftUI_v4_0, *) + @_alwaysEmitIntoClient + @preconcurrency + nonisolated public func onGeometryChange( + for type: T.Type, + of transform: @escaping @Sendable (GeometryProxy) -> T, + action: @escaping (_ newValue: T) -> Void + ) -> some View where T: Equatable, T: Sendable { + modifier(_GeometryActionModifier(value: transform, action: action)) + } +} + +extension View { + /// Adds an action to be performed when a value, created from a + /// geometry proxy, changes. + /// + /// The geometry of a view can change frequently, especially if + /// the view is contained within a ``ScrollView`` and that scroll view + /// is scrolling. + /// + /// You should avoid updating large parts of your app whenever + /// the scroll geometry changes. To aid in this, you provide two + /// closures to this modifier: + /// * transform: This converts a value of ``GeometryProxy`` to your + /// own data type. + /// * action: This provides the data type you created in `of` + /// and is called whenever the data type changes. + /// + /// For example, you can use this modifier to know how much of a view + /// is visible on screen. In the following example, + /// the data type you convert to is a ``Bool`` and the action is called + /// whenever the ``Bool`` changes. + /// + /// ScrollView(.horizontal) { + /// LazyHStack { + /// ForEach(videos) { video in + /// VideoView(video) + /// } + /// } + /// } + /// + /// struct VideoView: View { + /// var video: VideoModel + /// + /// var body: some View { + /// VideoPlayer(video) + /// .onGeometryChange(for: Bool.self) { proxy in + /// let frame = proxy.frame(in: .scrollView) + /// let bounds = proxy.bounds(of: .scrollView) ?? .zero + /// let intersection = frame.intersection( + /// CGRect(origin: .zero, size: bounds.size)) + /// let visibleHeight = intersection.size.height + /// return (visibleHeight / frame.size.height) > 0.75 + /// } action: { isVisible in + /// video.updateAutoplayingState( + /// isVisible: isVisible) + /// } + /// } + /// } + /// + /// - Parameters: + /// - type: The type of value transformed from a geometry proxy. + /// - transform: A closure that transforms a ``GeometryProxy`` + /// to your type. + /// - action: A closure to run when the transformed data changes. + /// - oldValue: The old value that failed the comparison check. + /// - newValue: The new value that failed the comparison check. + @available(OpenSwiftUI_v6_0, *) + @preconcurrency + nonisolated public func onGeometryChange( + for type: T.Type, + of transform: @escaping @Sendable (GeometryProxy) -> T, + action: @escaping (_ oldValue: T, _ newValue: T) -> Void + ) -> some View where T: Equatable, T: Sendable { + modifier(_GeometryActionModifier2(value: transform, action: action)) + } +} + +@_spi(Private) +@available(OpenSwiftUI_v4_0, *) +extension View { + @preconcurrency + @inlinable + nonisolated public func onGeometryChange( + of value: @escaping @Sendable (GeometryProxy) -> T, + do action: @escaping (T) -> Void + ) -> some View where T: Equatable, T: Sendable { + modifier(_GeometryActionModifier(value: value, action: action)) + } +} From 9277749ffb600f49b28d89e53e4946b22a2fc954 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 21 Jun 2026 23:34:14 +0800 Subject: [PATCH 2/3] Expose unary view modifier defaults --- Sources/OpenSwiftUICore/Modifier/ViewModifier.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/OpenSwiftUICore/Modifier/ViewModifier.swift b/Sources/OpenSwiftUICore/Modifier/ViewModifier.swift index 5379937f0..d7ca47c52 100644 --- a/Sources/OpenSwiftUICore/Modifier/ViewModifier.swift +++ b/Sources/OpenSwiftUICore/Modifier/ViewModifier.swift @@ -93,6 +93,7 @@ public protocol ViewModifier { package protocol PrimitiveViewModifier: ViewModifier where Body == Never {} +@available(OpenSwiftUI_v1_0, *) extension ViewModifier where Body == Never { public func body(content _: Content) -> Never { bodyError() @@ -116,8 +117,9 @@ extension ViewModifier { package protocol UnaryViewModifier: ViewModifier {} +@available(OpenSwiftUI_v1_0, *) extension UnaryViewModifier { - nonisolated static func _makeViewList( + nonisolated public static func _makeViewList( modifier: _GraphValue, inputs: _ViewListInputs, body: @escaping (_Graph, _ViewListInputs) -> _ViewListOutputs @@ -125,7 +127,7 @@ extension UnaryViewModifier { makeUnaryViewList(modifier: modifier, inputs: inputs, body: body) } - nonisolated static func _viewListCount( + nonisolated public static func _viewListCount( inputs: _ViewListCountInputs, body: (_ViewListCountInputs) -> Int? ) -> Int? { @@ -137,6 +139,7 @@ extension UnaryViewModifier { package protocol MultiViewModifier: ViewModifier {} +@available(OpenSwiftUI_v1_0, *) extension MultiViewModifier { nonisolated public static func _makeViewList( modifier: _GraphValue, @@ -149,6 +152,7 @@ extension MultiViewModifier { // MARK: - ViewModifier + _GraphInputsModifier +@available(OpenSwiftUI_v1_0, *) extension ViewModifier where Self: _GraphInputsModifier, Body == Never { nonisolated public static func _makeView( modifier: _GraphValue, @@ -185,6 +189,7 @@ package protocol ViewInputsModifier: ViewModifier where Body == Never { nonisolated static func _makeViewInputs(modifier: _GraphValue, inputs: inout _ViewInputs) } +@available(OpenSwiftUI_v1_0, *) extension ViewInputsModifier { package static var graphInputsSemantics: Semantics? { nil From 44aef80484298d24aae431264ae321b63d05e15c Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 21 Jun 2026 23:46:28 +0800 Subject: [PATCH 3/3] Add GeometryActionModifierUI test case --- .../Geometry/GeometryActionModifierUITests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Example/OpenSwiftUIUITests/Layout/Geometry/GeometryActionModifierUITests.swift diff --git a/Example/OpenSwiftUIUITests/Layout/Geometry/GeometryActionModifierUITests.swift b/Example/OpenSwiftUIUITests/Layout/Geometry/GeometryActionModifierUITests.swift new file mode 100644 index 000000000..26be61938 --- /dev/null +++ b/Example/OpenSwiftUIUITests/Layout/Geometry/GeometryActionModifierUITests.swift @@ -0,0 +1,14 @@ +// +// GeometryActionModifierUITests.swift +// OpenSwiftUIUITests + +import Testing +@testable import TestingHost + +@MainActor +struct GeometryActionModifierUITests { + @Test + func example() { + openSwiftUIAssertSnapshot(of: GeometryActionModifierExample()) + } +}