From d2e38e563df06b8e399c30556234b80a2792480f Mon Sep 17 00:00:00 2001 From: tifroz Date: Fri, 19 Jun 2026 16:18:56 -0700 Subject: [PATCH 1/9] Tighten bridged animation provenance --- Sources/SkipUI/SkipUI/Animation/Animation.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/SkipUI/SkipUI/Animation/Animation.swift b/Sources/SkipUI/SkipUI/Animation/Animation.swift index edce08ad..5255850b 100644 --- a/Sources/SkipUI/SkipUI/Animation/Animation.swift +++ b/Sources/SkipUI/SkipUI/Animation/Animation.swift @@ -191,6 +191,11 @@ public struct Animation : Hashable { /// not animated sources, restoring Lite-equivalent strict snap semantics in Fuse. private static var bridgedProvenance = false + /// Holds the most recent non-nil bridge prime until an animatable consumer resolves. + /// A nil bridge prime can arrive before the modifier consumes the cursor; keeping this + /// one-shot value preserves the intended bridged animation provenance for that consumer. + private static var pendingBridgedProvenanceAnimation: Animation? = nil + #endif /// Seed the read cursor for the next animatable-modifier call from a bridged (Skip Fuse) @@ -206,9 +211,12 @@ public struct Animation : Hashable { public static func primeBridgedProvenance(_ animation: Animation?) { #if SKIP bridgedProvenance = true - StateTracking.clearReadCursor() if let animation { + pendingBridgedProvenanceAnimation = animation + StateTracking.clearReadCursor() StateTracking.recordRead(Transaction(animation: animation)) + } else if pendingBridgedProvenanceAnimation == nil { + StateTracking.clearReadCursor() } #endif } @@ -236,6 +244,7 @@ public struct Animation : Hashable { recentWithAnimationGeneration += 1 bridgedComposition = false bridgedProvenance = false + pendingBridgedProvenanceAnimation = nil bridgeFrameStack.set(nil) StateTracking.resetForTesting() } @@ -277,6 +286,11 @@ public struct Animation : Hashable { var ambient = EnvironmentValues.shared._animation if ambient == nil, let tx = animTx as? Transaction, !tx.disablesAnimations { ambient = tx.animation + pendingBridgedProvenanceAnimation = nil + } + if ambient == nil, animTx == nil, bridgedProvenance, let pendingAnimation = pendingBridgedProvenanceAnimation { + ambient = pendingAnimation + pendingBridgedProvenanceAnimation = nil } if ambient == nil, animTx == nil, bridgedComposition, !bridgedProvenance { // Legacy SkipFuseUI (no native provenance): the marker is the only signal. From 88a4a3dea81ad61e707ad4d1b1c546bde10c5f87 Mon Sep 17 00:00:00 2001 From: tifroz Date: Tue, 23 Jun 2026 12:06:24 -0700 Subject: [PATCH 2/9] Prevent stale animation reuse for plain state writes --- .../SkipUI/SkipUI/Animation/Animation.swift | 50 ++++++++++++++++++- .../SkipUI/View/AdditionalViewModifiers.swift | 22 ++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/Sources/SkipUI/SkipUI/Animation/Animation.swift b/Sources/SkipUI/SkipUI/Animation/Animation.swift index 5255850b..a480fb72 100644 --- a/Sources/SkipUI/SkipUI/Animation/Animation.swift +++ b/Sources/SkipUI/SkipUI/Animation/Animation.swift @@ -662,8 +662,11 @@ public enum AnimationCompletionCriteria : Hashable { let resetValue = rememberSaveable(stateSaver: context.stateSaver as Saver) { mutableStateOf(nil) } let animatable = remember { Animatable(resetValue.value ?? value, converter) } let isAnimating = animatable.isRunning || animatable.value != animatable.targetValue + let isNewTarget = animatable.targetValue != value if isAnimating || animatable.value != value { - let animation = Animation.current(isAnimating: isAnimating, animTx: animTx) + // A new target with no provenance is a plain state write, so it must cancel any + // previous in-flight animation instead of inheriting the remembered animation. + let animation = Animation.current(isAnimating: isAnimating && !isNewTarget, animTx: animTx) LaunchedEffect(value, animation) { if let animation { if animation.isInfinite { @@ -681,6 +684,41 @@ public enum AnimationCompletionCriteria : Hashable { return animatable } +/// Return the value that should be rendered by a provenance-capturing modifier. +/// +/// New non-animated targets render immediately so live gestures do not display the stale +/// value of an in-flight Compose `Animatable` before its snap coroutine runs. +@Composable func toAnimatableValue(value: T, converter: TwoWayConverter, context: ComposeContext, animTx: StateMutationTransaction?) -> T where T: Any, VectorT: AnimationVector { + // SKIP NOWARN + let resetValue = rememberSaveable(stateSaver: context.stateSaver as Saver) { mutableStateOf(nil) } + let animatable = remember { Animatable(resetValue.value ?? value, converter) } + let isAnimating = animatable.isRunning || animatable.value != animatable.targetValue + let isNewTarget = animatable.targetValue != value + var renderValue = animatable.value + if isAnimating || animatable.value != value { + // A new target with no provenance is a plain state write, so it must cancel any + // previous in-flight animation instead of inheriting the remembered animation. + let animation = Animation.current(isAnimating: isAnimating && !isNewTarget, animTx: animTx) + if animation == nil && isNewTarget { + renderValue = value + } + LaunchedEffect(value, animation) { + if let animation { + if animation.isInfinite { + resetValue.value = animatable.value // Remember infinite animation start value + } else { + resetValue.value = nil + } + animatable.animateTo(value, animationSpec: animation.asAnimationSpec() as! AnimationSpec) + } else { + resetValue.value = nil + animatable.snapTo(value) + } + } + } + return renderValue +} + extension Float { /// Return an animatable version of this value (render-path: marker fallback allowed). @Composable func asAnimatable(context: ComposeContext) -> Animatable { @@ -691,6 +729,11 @@ extension Float { @Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) } + + /// Return the immediate render value for a provenance-capturing modifier. + @Composable func asAnimatableValue(context: ComposeContext, animTx: StateMutationTransaction?) -> Float { + return toAnimatableValue(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) + } } extension Tuple2 where E0 == Float, E1 == Float { @@ -703,6 +746,11 @@ extension Tuple2 where E0 == Float, E1 == Float { @Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable, AnimationVector2D> { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) } + + /// Return the immediate render value for a provenance-capturing modifier. + @Composable func asAnimatableValue(context: ComposeContext, animTx: StateMutationTransaction?) -> Tuple2 { + return toAnimatableValue(value: self, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) + } } extension androidx.compose.ui.graphics.Color { diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 6899f2e9..cf7e8fed 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -863,6 +863,28 @@ extension View { #endif } + /// Moves a view with a render-layer translation instead of a layout offset. + /// + /// This is useful for performance experiments where a view should move visually without + /// asking Compose to reposition it during layout. Hit testing keeps the original layout + /// bounds, so this should only be used for controlled overlay surfaces. + // SKIP @bridge + public func graphicsLayerOffset(x: CGFloat = 0.0, y: CGFloat = 0.0) -> any View { + #if SKIP + let animTx = StateTracking.captureLastReadAndClear() + return ModifiedContent(content: self, modifier: RenderModifier { context in + let density = LocalDensity.current + let value = (Float(x), Float(y)).asAnimatableValue(context: context, animTx: animTx) + return context.modifier.graphicsLayer { + translationX = with(density) { value.0.dp.toPx() } + translationY = with(density) { value.1.dp.toPx() } + } + }) + #else + return self + #endif + } + // SKIP @bridge public func onAppear(perform action: (() -> Void)? = nil) -> any View { #if SKIP From 37d64c78a80d74284c3aa055187f99db781a6049 Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 24 Jun 2026 12:36:32 -0700 Subject: [PATCH 3/9] AndroidCompositionBoundary implementation with private Composer --- .../Skip/AndroidCompositionBoundaryRoot.kt | 56 +++++++++ .../Compose/AndroidCompositionBoundary.swift | 109 ++++++++++++++++++ .../SkipUI/View/AdditionalViewModifiers.swift | 30 ++--- 3 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt create mode 100644 Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift diff --git a/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt new file mode 100644 index 00000000..7b35c66c --- /dev/null +++ b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt @@ -0,0 +1,56 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +package skip.ui + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Recomposer +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Creates a ComposeView whose composition is driven by a private Recomposer. + * + * Android's default ComposeView path resolves a parent/view-tree/window composition context. This + * helper is intentionally narrower: callers use it only for retained composition islands that + * should not be invalidated by unrelated ancestor or sibling composition work. + */ +fun AndroidCompositionBoundaryComposeView( + context: Context, + content: @Composable () -> Unit +): ComposeView { + val recomposerContext = AndroidUiDispatcher.Main + val recomposer = Recomposer(recomposerContext) + val scope = CoroutineScope(recomposerContext + SupervisorJob()) + val runner = scope.launch { + recomposer.runRecomposeAndApplyChanges() + } + + return ComposeView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setParentCompositionContext(recomposer) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent(content) + addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = Unit + + override fun onViewDetachedFromWindow(v: View) { + removeOnAttachStateChangeListener(this) + disposeComposition() + recomposer.close() + runner.cancel() + scope.cancel() + } + }) + } +} diff --git a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift new file mode 100644 index 00000000..47c11a2c --- /dev/null +++ b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift @@ -0,0 +1,109 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +#if SKIP +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.viewinterop.AndroidView +#endif + +/// Hosts a subtree in a retained Android composition root. +/// +/// Use this for heavyweight Android-only branches that should not be re-entered when an ancestor +/// recomposes for unrelated sibling motion. The boundary uses its own `ComposeView` composition and +/// updates its child content only when `inputs` changes, so callers must include every parent-driven +/// value that should refresh the subtree in that string. +// SKIP @bridge +public struct AndroidCompositionBoundary: View, Renderable { + let id: String + let inputs: String + let content: () -> any View + + /// Creates a retained Android composition boundary around `content`. + public init(id: String, inputs: String = "", @ViewBuilder content: @escaping () -> any View) { + self.id = id + self.inputs = inputs + self.content = content + } + + /// Creates a retained Android composition boundary around bridged content. + // SKIP @bridge + public init(id: String, inputs: String = "", bridgedContent: any View) { + self.id = id + self.inputs = inputs + self.content = { bridgedContent } + } + + /// Creates a retained Android composition boundary around lazily bridged content. + /// + /// Use this bridge entry point when constructing the child view is expensive. The factory is + /// evaluated only for the retained child composition's initial content and when `inputs` + /// changes. + // SKIP @bridge + public init(id: String, inputs: String = "", bridgedContentFactory: @escaping () -> any View) { + self.id = id + self.inputs = inputs + self.content = bridgedContentFactory + } + + #if SKIP + @Composable override func Render(context: ComposeContext) { + androidx.compose.runtime.key(id) { + let storage = remember(id) { + AndroidCompositionBoundaryStorage(inputs: inputs, content: content()) + } + + AndroidView( + factory: { androidContext in + return AndroidCompositionBoundaryComposeView(context: androidContext) { + storage.content.Compose(context: ComposeContext()) + } + }, + modifier: context.modifier, + update: { composeView in + guard storage.inputs != inputs else { + return + } + storage.inputs = inputs + storage.content = content() + composeView.setContent { + storage.content.Compose(context: ComposeContext()) + } + } + ) + } + } + #else + public var body: some View { + stubView() + } + #endif +} + +#if SKIP +private final class AndroidCompositionBoundaryStorage { + var inputs: String + var content: any View + + init(inputs: String, content: any View) { + self.inputs = inputs + self.content = content + } +} +#endif + +extension View { + /// Isolates this subtree in a retained Android composition root. + /// + /// Non-Android platforms return the original view. On Android, the detached root updates its + /// child content only when `inputs` changes. + public func androidCompositionBoundary(id: String, inputs: String = "") -> some View { + #if SKIP + return AndroidCompositionBoundary(id: id, inputs: inputs, content: { self }) + #else + return self + #endif + } +} + +#endif diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index cf7e8fed..0ddfa1a0 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -1030,24 +1030,22 @@ extension View { public func onGeometryChangeErased(of transform: @escaping (GeometryProxy) -> T, action: @escaping (_ oldValue: T, _ newValue: T) -> Void) -> any View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in - let globalFramePx = remember { mutableStateOf(nil) } - let previousValue = remember { mutableStateOf(nil as Any?) } + let storage = remember { GeometryChangeValueStorage() } let density = LocalDensity.current + let safeArea = EnvironmentValues.shared._safeArea - if let rect = globalFramePx.value { - let proxy = GeometryProxy(globalFramePx: rect, density: density, safeArea: EnvironmentValues.shared._safeArea) + var updatedContext = context + updatedContext.modifier = context.modifier.onGloballyPositionedInRoot { rect in + let proxy = GeometryProxy(globalFramePx: rect, density: density, safeArea: safeArea) let newValue = transform(proxy) - let oldValue = previousValue.value as? T - if oldValue == nil || oldValue != newValue { - let effectiveOldValue = oldValue ?? newValue - previousValue.value = newValue - SideEffect { action(effectiveOldValue, newValue) } + let oldValue = storage.previousValue as? T + guard oldValue == nil || oldValue != newValue else { + return } - } - var updatedContext = context - updatedContext.modifier = context.modifier.onGloballyPositionedInRoot { rect in - globalFramePx.value = rect + let effectiveOldValue = oldValue ?? newValue + storage.previousValue = newValue + action(effectiveOldValue, newValue) } renderable.Render(context: updatedContext) }) @@ -1824,6 +1822,12 @@ final class AnimatedBorderModifier: RenderModifier { } } +#if SKIP +final class GeometryChangeValueStorage { + var previousValue: Any? +} +#endif + #if SKIP final class AndroidVerticalOverscrollPullDownConnection: NestedScrollConnection { let isEnabled: () -> Bool From 68238b211b0b2de810abc6dc8caf6be6c6f455f7 Mon Sep 17 00:00:00 2001 From: tifroz Date: Thu, 25 Jun 2026 13:42:46 -0700 Subject: [PATCH 4/9] androidEquatable: initial implementation --- .../Skip/AndroidCompositionBoundaryRoot.kt | 33 +- .../SkipUI/SkipUI/Animation/Animation.swift | 3 +- .../Compose/AndroidCompositionBoundary.swift | 17 +- .../SkipUI/View/AdditionalViewModifiers.swift | 17 +- .../SkipUI/SkipUI/View/EquatableView.swift | 106 ++++ Tests/SkipUITests/AndroidEquatableTests.swift | 558 ++++++++++++++++++ Tests/SkipUITests/TransactionTests.swift | 11 + 7 files changed, 699 insertions(+), 46 deletions(-) create mode 100644 Tests/SkipUITests/AndroidEquatableTests.swift diff --git a/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt index 7b35c66c..c49ef063 100644 --- a/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt +++ b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt @@ -3,54 +3,31 @@ package skip.ui import android.content.Context -import android.view.View import android.view.ViewGroup import androidx.compose.runtime.Composable -import androidx.compose.runtime.Recomposer -import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.runtime.CompositionContext import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch /** - * Creates a ComposeView whose composition is driven by a private Recomposer. + * Creates a ComposeView whose composition inherits from the current Compose tree. * * Android's default ComposeView path resolves a parent/view-tree/window composition context. This * helper is intentionally narrower: callers use it only for retained composition islands that - * should not be invalidated by unrelated ancestor or sibling composition work. + * should inherit composition locals without being rebuilt for unrelated sibling composition work. */ fun AndroidCompositionBoundaryComposeView( context: Context, + parentCompositionContext: CompositionContext, content: @Composable () -> Unit ): ComposeView { - val recomposerContext = AndroidUiDispatcher.Main - val recomposer = Recomposer(recomposerContext) - val scope = CoroutineScope(recomposerContext + SupervisorJob()) - val runner = scope.launch { - recomposer.runRecomposeAndApplyChanges() - } - return ComposeView(context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - setParentCompositionContext(recomposer) + setParentCompositionContext(parentCompositionContext) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) setContent(content) - addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) = Unit - - override fun onViewDetachedFromWindow(v: View) { - removeOnAttachStateChangeListener(this) - disposeComposition() - recomposer.close() - runner.cancel() - scope.cancel() - } - }) } } diff --git a/Sources/SkipUI/SkipUI/Animation/Animation.swift b/Sources/SkipUI/SkipUI/Animation/Animation.swift index a480fb72..63ec3c2c 100644 --- a/Sources/SkipUI/SkipUI/Animation/Animation.swift +++ b/Sources/SkipUI/SkipUI/Animation/Animation.swift @@ -215,7 +215,8 @@ public struct Animation : Hashable { pendingBridgedProvenanceAnimation = animation StateTracking.clearReadCursor() StateTracking.recordRead(Transaction(animation: animation)) - } else if pendingBridgedProvenanceAnimation == nil { + } else { + pendingBridgedProvenanceAnimation = nil StateTracking.clearReadCursor() } #endif diff --git a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift index 47c11a2c..9a59c478 100644 --- a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift +++ b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift @@ -4,9 +4,11 @@ #if SKIP import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext import androidx.compose.ui.viewinterop.AndroidView #endif +#if SKIP /// Hosts a subtree in a retained Android composition root. /// /// Use this for heavyweight Android-only branches that should not be re-entered when an ancestor @@ -46,17 +48,18 @@ public struct AndroidCompositionBoundary: View, Renderable { self.content = bridgedContentFactory } - #if SKIP @Composable override func Render(context: ComposeContext) { androidx.compose.runtime.key(id) { + let parentCompositionContext = rememberCompositionContext() + let childContext = context.content() let storage = remember(id) { AndroidCompositionBoundaryStorage(inputs: inputs, content: content()) } AndroidView( factory: { androidContext in - return AndroidCompositionBoundaryComposeView(context: androidContext) { - storage.content.Compose(context: ComposeContext()) + return AndroidCompositionBoundaryComposeView(context: androidContext, parentCompositionContext: parentCompositionContext) { + storage.content.Compose(context: childContext) } }, modifier: context.modifier, @@ -67,20 +70,14 @@ public struct AndroidCompositionBoundary: View, Renderable { storage.inputs = inputs storage.content = content() composeView.setContent { - storage.content.Compose(context: ComposeContext()) + storage.content.Compose(context: childContext) } } ) } } - #else - public var body: some View { - stubView() - } - #endif } -#if SKIP private final class AndroidCompositionBoundaryStorage { var inputs: String var content: any View diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 0ddfa1a0..6a461938 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -1030,22 +1030,25 @@ extension View { public func onGeometryChangeErased(of transform: @escaping (GeometryProxy) -> T, action: @escaping (_ oldValue: T, _ newValue: T) -> Void) -> any View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in + let globalFramePx = remember { mutableStateOf(nil) } let storage = remember { GeometryChangeValueStorage() } let density = LocalDensity.current let safeArea = EnvironmentValues.shared._safeArea - var updatedContext = context - updatedContext.modifier = context.modifier.onGloballyPositionedInRoot { rect in + if let rect = globalFramePx.value { let proxy = GeometryProxy(globalFramePx: rect, density: density, safeArea: safeArea) let newValue = transform(proxy) let oldValue = storage.previousValue as? T - guard oldValue == nil || oldValue != newValue else { - return + if oldValue == nil || oldValue != newValue { + let effectiveOldValue = oldValue ?? newValue + storage.previousValue = newValue + SideEffect { action(effectiveOldValue, newValue) } } + } - let effectiveOldValue = oldValue ?? newValue - storage.previousValue = newValue - action(effectiveOldValue, newValue) + var updatedContext = context + updatedContext.modifier = context.modifier.onGloballyPositionedInRoot { rect in + globalFramePx.value = rect } renderable.Render(context: updatedContext) }) diff --git a/Sources/SkipUI/SkipUI/View/EquatableView.swift b/Sources/SkipUI/SkipUI/View/EquatableView.swift index 8a8bd4c7..c7858f0e 100644 --- a/Sources/SkipUI/SkipUI/View/EquatableView.swift +++ b/Sources/SkipUI/SkipUI/View/EquatableView.swift @@ -3,6 +3,7 @@ #if !SKIP_BRIDGE #if SKIP import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember #endif // SKIP @bridge @@ -25,4 +26,109 @@ public struct EquatableView : View { #endif } +#if SKIP +/// Retains evaluated Android child content while a caller-provided render identity remains equal. +struct AndroidEquatableView: View, Renderable { + let content: any View + let recomposeOverride: RecomposeOverride + + @Composable override func Evaluate(context: ComposeContext, options: Int) -> kotlin.collections.List { + return listOf(self) + } + + @Composable override func Render(context: ComposeContext) { + let storage = remember { + AndroidEquatableStorage(recomposeOverride: recomposeOverride) + } + for renderable in storage.renderables( + recomposeOverride: recomposeOverride, + content: content, + context: context, + options: 0 + ) { + renderable.Render(context: context) + } + } +} + +/// Retains bridged Android child content while a string render identity remains equal. +// SKIP @bridge +public struct AndroidEquatableContent: View, Renderable { + let recomposeOverride: String + let content: () -> any View + + /// Creates retained Android content around lazily bridged child content. + // SKIP @bridge + public init(recomposeOverride: String, bridgedContentFactory: @escaping () -> any View) { + self.recomposeOverride = recomposeOverride + self.content = bridgedContentFactory + } + + @Composable override func Evaluate(context: ComposeContext, options: Int) -> kotlin.collections.List { + return listOf(self) + } + + @Composable override func Render(context: ComposeContext) { + let storage = remember { + AndroidEquatableStorage(recomposeOverride: recomposeOverride) + } + for renderable in storage.renderables( + recomposeOverride: recomposeOverride, + content: content(), + context: context, + options: 0 + ) { + renderable.Render(context: context) + } + } +} + +private final class AndroidEquatableStorage { + var recomposeOverride: RecomposeOverride + var renderables: kotlin.collections.List? + + init(recomposeOverride: RecomposeOverride) { + self.recomposeOverride = recomposeOverride + } + + @Composable func renderables( + recomposeOverride: RecomposeOverride, + content: any View, + context: ComposeContext, + options: Int + ) -> kotlin.collections.List { + if renderables == nil || self.recomposeOverride != recomposeOverride { + self.recomposeOverride = recomposeOverride + self.renderables = content.Evaluate(context: context, options: options) + } + return renderables ?? listOf() + } +} +#endif + +extension View where Self: Equatable { + /// On Android, reuses this view's evaluated content while the view value remains equal. + public func androidEquatable() -> some View { + #if SKIP + return AndroidEquatableView(content: self, recomposeOverride: self) + #else + return self + #endif + } +} + +extension View { + /// On Android, reuses evaluated content until `recomposeOverride` changes. + /// + /// Include every body-affecting external value in `recomposeOverride`; unchanged values skip + /// parent-driven body evaluation for this subtree. + public func androidEquatable(recomposeOverride: RecomposeOverride) -> some View { + #if SKIP + return AndroidEquatableView(content: self, recomposeOverride: recomposeOverride) + #else + return self + #endif + } +} + #endif diff --git a/Tests/SkipUITests/AndroidEquatableTests.swift b/Tests/SkipUITests/AndroidEquatableTests.swift new file mode 100644 index 00000000..b94b094c --- /dev/null +++ b/Tests/SkipUITests/AndroidEquatableTests.swift @@ -0,0 +1,558 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import SwiftUI +import XCTest + +#if SKIP +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +#endif + +final class AndroidEquatableTests: SkipUITestCase { + // SKIP INSERT: @get:org.junit.Rule val composeRule = createAndroidComposeRule() + + func testUnchangedOverrideSkipsChildBodyWhenParentRecomposes() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let parentTick = State(initialValue: 0) + let childText = State(initialValue: "A") + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableOverrideHost( + parentTick: parentTick, + childText: childText, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-child").assertTextEquals("A") + XCTAssertEqual(counter.value("android-equatable-child"), 1) + + parentTick.wrappedValue = 1 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-parent").assertTextEquals("tick 1") + composeRule.onNodeWithTag("android-equatable-child").assertTextEquals("A") + XCTAssertEqual(counter.value("android-equatable-child"), 1) + #endif + } + + func testChangedOverrideRecomposesChildBodyAndUpdatesOutput() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let parentTick = State(initialValue: 0) + let childText = State(initialValue: "A") + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableOverrideHost( + parentTick: parentTick, + childText: childText, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + XCTAssertEqual(counter.value("android-equatable-child"), 1) + + childText.wrappedValue = "B" + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-child").assertTextEquals("B") + XCTAssertEqual(counter.value("android-equatable-child"), 2) + #endif + } + + func testEquatableConvenienceSkipsUnchangedValueAndUpdatesChangedValue() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let parentTick = State(initialValue: 0) + let childText = State(initialValue: "A") + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableConvenienceHost( + parentTick: parentTick, + childText: childText, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-convenience-child").assertTextEquals("A") + XCTAssertEqual(counter.value("android-equatable-convenience-child"), 1) + + parentTick.wrappedValue = 1 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-convenience-parent").assertTextEquals("tick 1") + XCTAssertEqual(counter.value("android-equatable-convenience-child"), 1) + + childText.wrappedValue = "B" + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-convenience-child").assertTextEquals("B") + XCTAssertEqual(counter.value("android-equatable-convenience-child"), 2) + #endif + } + + func testForEachRowsKeepStableBodiesAcrossParentAndCollectionChanges() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let parentTick = State(initialValue: 0) + let items = State(initialValue: [ + AndroidEquatableItem(id: 1, title: "One"), + AndroidEquatableItem(id: 2, title: "Two"), + AndroidEquatableItem(id: 3, title: "Three"), + ]) + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableForEachHost( + parentTick: parentTick, + items: items, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-row-1").assertTextEquals("One") + composeRule.onNodeWithTag("android-equatable-row-2").assertTextEquals("Two") + composeRule.onNodeWithTag("android-equatable-row-3").assertTextEquals("Three") + let row1InitialCount = counter.value("android-equatable-row-1") + let row2InitialCount = counter.value("android-equatable-row-2") + let row3InitialCount = counter.value("android-equatable-row-3") + XCTAssertGreaterThan(row1InitialCount, 0) + XCTAssertGreaterThan(row2InitialCount, 0) + XCTAssertGreaterThan(row3InitialCount, 0) + + parentTick.wrappedValue = 1 + composeRule.waitForIdle() + + XCTAssertEqual(counter.value("android-equatable-row-1"), row1InitialCount) + XCTAssertEqual(counter.value("android-equatable-row-2"), row2InitialCount) + XCTAssertEqual(counter.value("android-equatable-row-3"), row3InitialCount) + + items.wrappedValue = [ + AndroidEquatableItem(id: 0, title: "Zero"), + AndroidEquatableItem(id: 1, title: "One"), + AndroidEquatableItem(id: 3, title: "Three"), + ] + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-row-0").assertTextEquals("Zero") + composeRule.onNodeWithTag("android-equatable-row-1").assertTextEquals("One") + composeRule.onNodeWithTag("android-equatable-row-3").assertTextEquals("Three") + XCTAssertGreaterThan(counter.value("android-equatable-row-0"), 0) + XCTAssertGreaterThan(counter.value("android-equatable-row-1"), 0) + XCTAssertGreaterThan(counter.value("android-equatable-row-3"), 0) + + items.wrappedValue = [ + AndroidEquatableItem(id: 3, title: "Three"), + AndroidEquatableItem(id: 0, title: "Zero"), + AndroidEquatableItem(id: 1, title: "One"), + ] + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-row-3").assertTextEquals("Three") + composeRule.onNodeWithTag("android-equatable-row-0").assertTextEquals("Zero") + composeRule.onNodeWithTag("android-equatable-row-1").assertTextEquals("One") + XCTAssertGreaterThan(counter.value("android-equatable-row-0"), 0) + XCTAssertGreaterThan(counter.value("android-equatable-row-1"), 0) + XCTAssertGreaterThan(counter.value("android-equatable-row-3"), 0) + #endif + } + + func testLazyStackRowsRemainVisibleAndSkipParentOnlyChanges() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let parentTick = State(initialValue: 0) + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableLazyHost(parentTick: parentTick, counter: counter) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-lazy-row-0").assertIsDisplayed() + composeRule.onNodeWithTag("android-equatable-lazy-row-1").assertIsDisplayed() + let row0InitialCount = counter.value("android-equatable-lazy-row-0") + let row1InitialCount = counter.value("android-equatable-lazy-row-1") + XCTAssertGreaterThan(row0InitialCount, 0) + XCTAssertGreaterThan(row1InitialCount, 0) + + parentTick.wrappedValue = 1 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-lazy-row-0").assertIsDisplayed() + composeRule.onNodeWithTag("android-equatable-lazy-row-1").assertIsDisplayed() + XCTAssertEqual(counter.value("android-equatable-lazy-row-0"), row0InitialCount) + XCTAssertEqual(counter.value("android-equatable-lazy-row-1"), row1InitialCount) + #endif + } + + func testEnvironmentChangeUpdatesChildInsideBoundary() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let environmentValue = State(initialValue: "outer") + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableEnvironmentHost( + environmentValue: environmentValue, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-environment-child").assertTextEquals("outer") + + environmentValue.wrappedValue = "inner" + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-environment-child").assertTextEquals("inner") + XCTAssertEqual(counter.value("android-equatable-environment-child"), 2) + #endif + } + + func testBodyDrivenChildStateIsNotImplicitInvalidationInput() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let parentTick = State(initialValue: 0) + let stateOverride = State(initialValue: 0) + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableStatefulHost( + parentTick: parentTick, + stateOverride: stateOverride, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-stateful-child").assertTextEquals("count 0") + XCTAssertEqual(counter.value("android-equatable-stateful-child"), 1) + + composeRule.onNodeWithTag("android-equatable-stateful-child").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-stateful-child").assertTextEquals("count 0") + XCTAssertEqual(counter.value("android-equatable-stateful-child"), 1) + + stateOverride.wrappedValue = 1 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-stateful-child").assertTextEquals("count 0") + XCTAssertEqual(counter.value("android-equatable-stateful-child"), 2) + + parentTick.wrappedValue = 1 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-stateful-child").assertTextEquals("count 0") + XCTAssertEqual(counter.value("android-equatable-stateful-child"), 2) + #endif + } + + func testHoistedStateUpdatesWhenIncludedInOverride() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let childCount = State(initialValue: 0) + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableHoistedStateHost(childCount: childCount, counter: counter) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-hoisted-state-child").assertTextEquals("count 0") + XCTAssertEqual(counter.value("android-equatable-hoisted-state-child"), 1) + + composeRule.onNodeWithTag("android-equatable-hoisted-state-child").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-hoisted-state-child").assertTextEquals("count 1") + XCTAssertEqual(counter.value("android-equatable-hoisted-state-child"), 2) + #endif + } + + func testActionOutsideBoundaryUsesLatestParentClosure() throws { + #if !SKIP + throw XCTSkip("androidEquatable is an Android-only optimization") + #else + let selectedValue = State(initialValue: 0) + let parentValue = State(initialValue: 1) + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableActionHost( + parentValue: parentValue, + selectedValue: selectedValue, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + XCTAssertEqual(counter.value("android-equatable-action-child"), 1) + + parentValue.wrappedValue = 2 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-action-child").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-selected-value").assertTextEquals("selected 2") + XCTAssertEqual(selectedValue.wrappedValue, 2) + XCTAssertEqual(counter.value("android-equatable-action-child"), 1) + #endif + } +} + +#if SKIP +private final class AndroidEquatableBodyCounter { + private var counts: [String: Int] = [:] + + @discardableResult + func increment(_ key: String) -> Int { + let value = (counts[key] ?? 0) + 1 + counts[key] = value + return value + } + + func value(_ key: String) -> Int { + return counts[key] ?? 0 + } +} + +private struct AndroidEquatableItem: Equatable { + let id: Int + let title: String +} + +private struct AndroidEquatableCountingRow: View, Equatable { + let id: String + let text: String + let counter: AndroidEquatableBodyCounter + + var body: some View { + let _ = counter.increment(id) + Text(text) + .accessibilityIdentifier(id) + } + + static func == (lhs: AndroidEquatableCountingRow, rhs: AndroidEquatableCountingRow) -> Bool { + return lhs.id == rhs.id && lhs.text == rhs.text + } +} + +private struct AndroidEquatableOverrideHost: View { + let parentTick: State + let childText: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + let text = childText.wrappedValue + VStack { + Text("tick \(parentTick.wrappedValue)") + .accessibilityIdentifier("android-equatable-parent") + AndroidEquatableCountingRow(id: "android-equatable-child", text: text, counter: counter) + .androidEquatable(recomposeOverride: text) + } + } +} + +private struct AndroidEquatableConvenienceHost: View { + let parentTick: State + let childText: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + VStack { + Text("tick \(parentTick.wrappedValue)") + .accessibilityIdentifier("android-equatable-convenience-parent") + AndroidEquatableCountingRow(id: "android-equatable-convenience-child", text: childText.wrappedValue, counter: counter) + .androidEquatable() + } + } +} + +private struct AndroidEquatableForEachHost: View { + let parentTick: State + let items: State<[AndroidEquatableItem]> + let counter: AndroidEquatableBodyCounter + + var body: some View { + VStack { + Text("tick \(parentTick.wrappedValue)") + .accessibilityIdentifier("android-equatable-list-parent") + ForEach(items.wrappedValue, id: { $0.id }) { item in + AndroidEquatableCountingRow( + id: "android-equatable-row-\(item.id)", + text: item.title, + counter: counter + ) + .androidEquatable(recomposeOverride: item) + } + } + } +} + +private struct AndroidEquatableLazyHost: View { + let parentTick: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + LazyVStack { + Text("tick \(parentTick.wrappedValue)") + .accessibilityIdentifier("android-equatable-lazy-parent") + ForEach(0..<4) { index in + AndroidEquatableCountingRow( + id: "android-equatable-lazy-row-\(index)", + text: "Lazy \(index)", + counter: counter + ) + .androidEquatable(recomposeOverride: index) + } + } + } +} + +private struct AndroidEquatableEnvironmentHost: View { + let environmentValue: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + let value = environmentValue.wrappedValue + AndroidEquatableEnvironmentRow(counter: counter) + .androidEquatable(recomposeOverride: value) + .environment(\.testValue, value) + } +} + +private struct AndroidEquatableEnvironmentRow: View { + @Environment(\.testValue) var environmentValue: String + let counter: AndroidEquatableBodyCounter + + var body: some View { + let _ = counter.increment("android-equatable-environment-child") + Text(environmentValue) + .accessibilityIdentifier("android-equatable-environment-child") + } +} + +private struct AndroidEquatableStatefulHost: View { + let parentTick: State + let stateOverride: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + VStack { + Text("tick \(parentTick.wrappedValue)") + .accessibilityIdentifier("android-equatable-stateful-parent") + AndroidEquatableStatefulRow(counter: counter) + .androidEquatable(recomposeOverride: stateOverride.wrappedValue) + } + } +} + +private struct AndroidEquatableStatefulRow: View, Equatable { + @State var count = 0 + let counter: AndroidEquatableBodyCounter + + var body: some View { + let _ = counter.increment("android-equatable-stateful-child") + Button("count \(count)") { + count += 1 + } + .accessibilityIdentifier("android-equatable-stateful-child") + .buttonStyle(.bordered) + } + + static func == (lhs: AndroidEquatableStatefulRow, rhs: AndroidEquatableStatefulRow) -> Bool { + return true + } +} + +private struct AndroidEquatableHoistedStateHost: View { + let childCount: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + let count = childCount.wrappedValue + AndroidEquatableHoistedStateRow( + count: count, + counter: counter, + increment: { childCount.wrappedValue += 1 } + ) + .androidEquatable(recomposeOverride: count) + } +} + +private struct AndroidEquatableHoistedStateRow: View, Equatable { + let count: Int + let counter: AndroidEquatableBodyCounter + let increment: () -> Void + + var body: some View { + let _ = counter.increment("android-equatable-hoisted-state-child") + Button("count \(count)", action: increment) + .accessibilityIdentifier("android-equatable-hoisted-state-child") + .buttonStyle(.bordered) + } + + static func == (lhs: AndroidEquatableHoistedStateRow, rhs: AndroidEquatableHoistedStateRow) -> Bool { + return lhs.count == rhs.count + } +} + +private struct AndroidEquatableActionHost: View { + let parentValue: State + let selectedValue: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + let capturedValue = parentValue.wrappedValue + VStack { + Text("parent \(capturedValue)") + .accessibilityIdentifier("android-equatable-action-parent") + Text("selected \(selectedValue.wrappedValue)") + .accessibilityIdentifier("android-equatable-selected-value") + AndroidEquatableCountingRow( + id: "android-equatable-action-child", + text: "Select", + counter: counter + ) + .androidEquatable(recomposeOverride: "action") + .onTapGesture { _ in selectedValue.wrappedValue = capturedValue } + } + } +} +#endif diff --git a/Tests/SkipUITests/TransactionTests.swift b/Tests/SkipUITests/TransactionTests.swift index 8fbf00be..ebf6a594 100644 --- a/Tests/SkipUITests/TransactionTests.swift +++ b/Tests/SkipUITests/TransactionTests.swift @@ -236,6 +236,17 @@ final class TransactionTests: XCTestCase { #endif } + /// A nil prime after an animated prime still means this modifier was not animated. + func testPrimeBridgedProvenanceNilClearsPendingPrime() throws { + #if !SKIP + throw XCTSkip("primeBridgedProvenance is Android-only") + #else + Animation.primeBridgedProvenance(.linear(duration: 1)) + Animation.primeBridgedProvenance(nil) + XCTAssertNil(StateTracking.captureLastReadAndClear(), "prime(nil) must clear a pending animated prime") + #endif + } + /// Priming overwrites a stale cursor value rather than being dropped by first-read-wins. func testPrimeBridgedProvenanceOverwritesStaleCursor() throws { #if !SKIP From 9e8168ff474cb2440d06bed0cbb42dae90213049 Mon Sep 17 00:00:00 2001 From: tifroz Date: Fri, 26 Jun 2026 17:11:28 -0700 Subject: [PATCH 5/9] androidEquatable tested and working well - cleanup needed --- Sources/SkipUI/SkipUI/Components/Image.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/SkipUI/SkipUI/Components/Image.swift b/Sources/SkipUI/SkipUI/Components/Image.swift index 38fdd279..fec7db19 100644 --- a/Sources/SkipUI/SkipUI/Components/Image.swift +++ b/Sources/SkipUI/SkipUI/Components/Image.swift @@ -206,6 +206,11 @@ public struct Image : View, Renderable, Equatable { let hasValidIntrinsic = !painter.intrinsicSize.isUnspecified && !painter.intrinsicSize.width.isNaN() && painter.intrinsicSize.width > 0 && !painter.intrinsicSize.height.isNaN() && painter.intrinsicSize.height > 0 if hasValidIntrinsic { RenderPainter(painter: painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: innerContext) + } else if resizingMode == .stretch { + // Coil reports State.Empty on the first composition even when a cached image can draw + // in the first frame. Resizable images already have external constraints, so keep the + // slot filled instead of briefly replacing cached icons with a 0x0 placeholder. + RenderPainter(painter: painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: innerContext) } else { // Without a valid intrinsic, RenderPainter will try to render the painter with // fillSize, which can break layout. We're rendering a 0x0 Box as a placeholder. From 4bb647f2af18225c9bab8cd397d982ffeda87194 Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 15 Jul 2026 18:39:03 -0700 Subject: [PATCH 6/9] Remove experimental graphicsLayerOffset implementation --- .../SkipUI/SkipUI/Animation/Animation.swift | 43 ------------------- .../SkipUI/View/AdditionalViewModifiers.swift | 22 ---------- 2 files changed, 65 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Animation/Animation.swift b/Sources/SkipUI/SkipUI/Animation/Animation.swift index 63ec3c2c..5161dc98 100644 --- a/Sources/SkipUI/SkipUI/Animation/Animation.swift +++ b/Sources/SkipUI/SkipUI/Animation/Animation.swift @@ -685,41 +685,6 @@ public enum AnimationCompletionCriteria : Hashable { return animatable } -/// Return the value that should be rendered by a provenance-capturing modifier. -/// -/// New non-animated targets render immediately so live gestures do not display the stale -/// value of an in-flight Compose `Animatable` before its snap coroutine runs. -@Composable func toAnimatableValue(value: T, converter: TwoWayConverter, context: ComposeContext, animTx: StateMutationTransaction?) -> T where T: Any, VectorT: AnimationVector { - // SKIP NOWARN - let resetValue = rememberSaveable(stateSaver: context.stateSaver as Saver) { mutableStateOf(nil) } - let animatable = remember { Animatable(resetValue.value ?? value, converter) } - let isAnimating = animatable.isRunning || animatable.value != animatable.targetValue - let isNewTarget = animatable.targetValue != value - var renderValue = animatable.value - if isAnimating || animatable.value != value { - // A new target with no provenance is a plain state write, so it must cancel any - // previous in-flight animation instead of inheriting the remembered animation. - let animation = Animation.current(isAnimating: isAnimating && !isNewTarget, animTx: animTx) - if animation == nil && isNewTarget { - renderValue = value - } - LaunchedEffect(value, animation) { - if let animation { - if animation.isInfinite { - resetValue.value = animatable.value // Remember infinite animation start value - } else { - resetValue.value = nil - } - animatable.animateTo(value, animationSpec: animation.asAnimationSpec() as! AnimationSpec) - } else { - resetValue.value = nil - animatable.snapTo(value) - } - } - } - return renderValue -} - extension Float { /// Return an animatable version of this value (render-path: marker fallback allowed). @Composable func asAnimatable(context: ComposeContext) -> Animatable { @@ -731,10 +696,6 @@ extension Float { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) } - /// Return the immediate render value for a provenance-capturing modifier. - @Composable func asAnimatableValue(context: ComposeContext, animTx: StateMutationTransaction?) -> Float { - return toAnimatableValue(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) - } } extension Tuple2 where E0 == Float, E1 == Float { @@ -748,10 +709,6 @@ extension Tuple2 where E0 == Float, E1 == Float { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) } - /// Return the immediate render value for a provenance-capturing modifier. - @Composable func asAnimatableValue(context: ComposeContext, animTx: StateMutationTransaction?) -> Tuple2 { - return toAnimatableValue(value: self, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) - } } extension androidx.compose.ui.graphics.Color { diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 6a461938..c1ed21bc 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -863,28 +863,6 @@ extension View { #endif } - /// Moves a view with a render-layer translation instead of a layout offset. - /// - /// This is useful for performance experiments where a view should move visually without - /// asking Compose to reposition it during layout. Hit testing keeps the original layout - /// bounds, so this should only be used for controlled overlay surfaces. - // SKIP @bridge - public func graphicsLayerOffset(x: CGFloat = 0.0, y: CGFloat = 0.0) -> any View { - #if SKIP - let animTx = StateTracking.captureLastReadAndClear() - return ModifiedContent(content: self, modifier: RenderModifier { context in - let density = LocalDensity.current - let value = (Float(x), Float(y)).asAnimatableValue(context: context, animTx: animTx) - return context.modifier.graphicsLayer { - translationX = with(density) { value.0.dp.toPx() } - translationY = with(density) { value.1.dp.toPx() } - } - }) - #else - return self - #endif - } - // SKIP @bridge public func onAppear(perform action: (() -> Void)? = nil) -> any View { #if SKIP From a373a18b9d94b834f72c37ea4a37c56f8fad1edf Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 29 Jul 2026 12:34:25 -0700 Subject: [PATCH 7/9] Cleanup pass: bug fixes & corner cases - preserve ForEach and stack composition identity - improve equatable content caching and bridge equality - support explicit first-render animation sources - prevent animation provenance from leaking between modifiers - document Android rendering performance APIs --- README.md | 63 +++++++++ .../SkipUI/SkipUI/Animation/Animation.swift | 27 +++- .../Compose/AndroidCompositionBoundary.swift | 61 +++++--- .../SkipUI/SkipUI/Containers/ForEach.swift | 69 ++++++++- Sources/SkipUI/SkipUI/Containers/HStack.swift | 30 +++- Sources/SkipUI/SkipUI/Containers/VStack.swift | 30 +++- Sources/SkipUI/SkipUI/Containers/ZStack.swift | 3 + .../SkipUI/View/AdditionalViewModifiers.swift | 132 ++++++++++++++++++ .../SkipUI/SkipUI/View/EquatableView.swift | 47 +++++-- Tests/SkipUITests/AndroidEquatableTests.swift | 71 +++++++++- Tests/SkipUITests/AnimationTests.swift | 84 +++++++++++ 11 files changed, 562 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index fe30569b..4543b701 100644 --- a/README.md +++ b/README.md @@ -585,6 +585,65 @@ public struct Material3RippleOptions { } ``` +## Android Rendering Performance + +SkipUI provides Android-only modifiers for avoiding repeated work in expensive view subtrees. Both modifiers below return the original view on non-Android platforms. + +### Reusing Equal Content + +Use `.androidEquatable()` on an `Equatable` view to reuse its evaluated Android content while the view value remains equal: + +```swift +struct ContactRow: View, Equatable { + let contact: Contact + + var body: some View { + HStack { + Text(contact.name) + Spacer() + Text(contact.status) + } + } +} + +ContactRow(contact: contact) + .androidEquatable() +``` + +For a view that is not itself `Equatable`, pass an explicit value to `.androidEquatable(recomposeOverride:)`: + +```swift +ContactRow(contact: contact, onSelect: onSelect) + .androidEquatable( + recomposeOverride: ContactRowInputs( + contact: contact, + isSelected: isSelected + ) + ) +``` + +Think of `recomposeOverride` as a cache key. When a parent recomposes and the key is unchanged, SkipUI reuses the child's evaluated content instead of evaluating its body again. When the key changes, SkipUI evaluates the child again. Include every value that can affect the child's body, including relevant environment and hoisted state values. + +State read inside the optimized child's body is not an automatic invalidation input. If a child must update from state, hoist that state above the optimized view and include its value in `recomposeOverride`. Modifiers applied after `.androidEquatable(...)` remain outside the cached content and can continue to receive updated values and actions. + +This is an explicit Android optimization rather than the standard SwiftUI `.equatable()` modifier. Use it only after identifying repeated body evaluation as meaningful work. + +### Retained Composition Boundaries + +For a subtree that needs its own retained Compose identity and lifecycle, use `.androidCompositionBoundary(id:inputs:)`: + +```swift +PlayerSurface(player: player) + .androidCompositionBoundary( + id: player.id.uuidString, + inputs: String(player.renderRevision) + ) +``` + +Keeping `id` stable preserves the hosted composition and its state. Changing `inputs` updates the content inside the existing host. Changing `id` disposes the old host and creates a new one. Treat `inputs` as a revision token and change it whenever any value used to build the retained content changes; content remains unchanged while both `id` and `inputs` are unchanged. + +An Android composition boundary creates a separate Compose host, so it is heavier than `.androidEquatable(...)`. Prefer equality reuse for ordinary views and collection rows. Use a composition boundary when the subtree specifically needs retained hosting or lifecycle isolation. + ## Supported SwiftUI The following table summarizes SkipUI's SwiftUI support on Android. Anything not listed here is likely not supported. Note that in your iOS-only code - i.e. code within `#if !os(Android)` blocks - you can use any SwiftUI you want. @@ -2550,6 +2609,8 @@ The following properties are currently animatable: - `.scaleEffect` - `.stroke` color +Only values changed by a matching `withAnimation` or animated `Transaction` use that animation. A concurrent plain state write snaps to its new value, and a plain write to a value with an in-progress animation cancels that animation and snaps to the new target. + All of SwiftUI's built-in transitions are supported on Android. To use transitions or to animate views being added or removed in general, however, you **must** assign a unique `.id` value to every view in the parent `HStack`, `VStack`, or `ZStack`: ```swift @@ -2680,6 +2741,8 @@ ForEach([person1, person2, person3], id: \.fullName) { person in **Important**: When the body of your `ForEach` contains multiple top-level views (e.g. a full row of a `VGrid`), or any single view that expands to additional views (like a `Section` or a nested `ForEach`), SkipUI must "unroll" the loop in order to supply all its views individually to Compose. This means that the `ForEach` will be entirely iterated up front, though the views it produces won't yet be rendered. +SkipUI uses each element's `ForEach` identifier as its Android composition identity, including when an unrolled `ForEach` is rendered in an `HStack`, `VStack`, or `ZStack`. Use stable, unique identifiers so retained state and optimized content continue to follow the same element when the collection is inserted into, removed from, or reordered. + ### Gestures SkipUI currently supports tap, long press, drag, magnify, and rotate gestures. You can use the general `.gesture` modifier, `.simultaneousGesture` for supported gesture observers, or specialized modifiers like `.onTapGesture` to add gesture support to your views. The following limitations apply: diff --git a/Sources/SkipUI/SkipUI/Animation/Animation.swift b/Sources/SkipUI/SkipUI/Animation/Animation.swift index 5161dc98..4443a7da 100644 --- a/Sources/SkipUI/SkipUI/Animation/Animation.swift +++ b/Sources/SkipUI/SkipUI/Animation/Animation.swift @@ -284,14 +284,18 @@ public struct Animation : Hashable { /// The explicit `.animation(_:)` environment override still wins over the transaction, /// matching SwiftUI's modifier-overrides-ambient-transaction semantics. @Composable static func current(isAnimating: Bool, animTx: StateMutationTransaction?) -> Animation? { + // A bridge prime belongs to exactly one animatable consumer. Consume it even when an + // environment animation or an explicit transaction wins, so it cannot leak to a later + // unrelated modifier. + let pendingAnimation = pendingBridgedProvenanceAnimation + pendingBridgedProvenanceAnimation = nil + var ambient = EnvironmentValues.shared._animation if ambient == nil, let tx = animTx as? Transaction, !tx.disablesAnimations { ambient = tx.animation - pendingBridgedProvenanceAnimation = nil } - if ambient == nil, animTx == nil, bridgedProvenance, let pendingAnimation = pendingBridgedProvenanceAnimation { + if ambient == nil, animTx == nil, bridgedProvenance, let pendingAnimation { ambient = pendingAnimation - pendingBridgedProvenanceAnimation = nil } if ambient == nil, animTx == nil, bridgedComposition, !bridgedProvenance { // Legacy SkipFuseUI (no native provenance): the marker is the only signal. @@ -658,10 +662,13 @@ public enum AnimationCompletionCriteria : Hashable { /// Animatable plumbing for modifiers that capture provenance at entry: `animTx` carries the /// per-slot transaction (or nil → snap); the marker fallback is NOT consulted. -@Composable func toAnimatable(value: T, converter: TwoWayConverter, context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable where T: Any, VectorT: AnimationVector { +@Composable func toAnimatable(value: T, initialValue: T? = nil, converter: TwoWayConverter, context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable where T: Any, VectorT: AnimationVector { // SKIP NOWARN let resetValue = rememberSaveable(stateSaver: context.stateSaver as Saver) { mutableStateOf(nil) } - let animatable = remember { Animatable(resetValue.value ?? value, converter) } + // A newly composed modifier normally has only its target value, so Compose has no source + // to interpolate from. Experimental first-render modifier APIs can provide that source + // explicitly; existing callers continue to initialize from `value`. + let animatable = remember { Animatable(resetValue.value ?? initialValue ?? value, converter) } let isAnimating = animatable.isRunning || animatable.value != animatable.targetValue let isNewTarget = animatable.targetValue != value if isAnimating || animatable.value != value { @@ -696,6 +703,11 @@ extension Float { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) } + /// Return an animatable value initialized from an explicit first-render source. + @Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?, initialValue: Float) -> Animatable { + return toAnimatable(value: self, initialValue: initialValue, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) + } + } extension Tuple2 where E0 == Float, E1 == Float { @@ -709,6 +721,11 @@ extension Tuple2 where E0 == Float, E1 == Float { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) } + /// Return an animatable pair initialized from an explicit first-render source. + @Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?, initialValue: Tuple2) -> Animatable, AnimationVector2D> { + return toAnimatable(value: self, initialValue: initialValue, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) + } + } extension androidx.compose.ui.graphics.Color { diff --git a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift index 9a59c478..90c3cb56 100644 --- a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift +++ b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift @@ -3,29 +3,34 @@ #if !SKIP_BRIDGE #if SKIP import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.viewinterop.AndroidView +import java.util.UUID #endif #if SKIP -/// Hosts a subtree in a retained Android composition root. +/// Gives a subtree its own retained identity and lifecycle on Android. /// -/// Use this for heavyweight Android-only branches that should not be re-entered when an ancestor -/// recomposes for unrelated sibling motion. The boundary uses its own `ComposeView` composition and -/// updates its child content only when `inputs` changes, so callers must include every parent-driven -/// value that should refresh the subtree in that string. +/// Think of the boundary as a separate hosting container. Keeping `id` stable preserves that +/// container and its state. Changing `inputs` updates content inside the existing container; +/// changing `id` disposes it and creates a new one. Change `inputs` whenever a value used to +/// build `content` changes. // SKIP @bridge public struct AndroidCompositionBoundary: View, Renderable { let id: String let inputs: String let content: () -> any View + let bridgedProjectionLifecycle: ((String, Bool) -> any View)? /// Creates a retained Android composition boundary around `content`. public init(id: String, inputs: String = "", @ViewBuilder content: @escaping () -> any View) { self.id = id self.inputs = inputs self.content = content + self.bridgedProjectionLifecycle = nil } /// Creates a retained Android composition boundary around bridged content. @@ -34,26 +39,48 @@ public struct AndroidCompositionBoundary: View, Renderable { self.id = id self.inputs = inputs self.content = { bridgedContent } + self.bridgedProjectionLifecycle = nil } - /// Creates a retained Android composition boundary around lazily bridged content. + /// Creates a lazy bridged boundary whose projection is scoped to one Compose instance. /// - /// Use this bridge entry point when constructing the child view is expensive. The factory is - /// evaluated only for the retained child composition's initial content and when `inputs` - /// changes. + /// The lifecycle callback is called with `isDisposing == false` on every parent render so + /// native callers can release temporary projection sources. It is called with + /// `isDisposing == true` when the Compose instance leaves the hierarchy. Prepared projections + /// are installed only initially and when `inputs` changes. // SKIP @bridge - public init(id: String, inputs: String = "", bridgedContentFactory: @escaping () -> any View) { + public init( + id: String, + inputs: String = "", + bridgedProjectionLifecycle: @escaping (String, Bool) -> any View + ) { self.id = id self.inputs = inputs - self.content = bridgedContentFactory + self.content = { EmptyView() } + self.bridgedProjectionLifecycle = bridgedProjectionLifecycle } @Composable override func Render(context: ComposeContext) { androidx.compose.runtime.key(id) { let parentCompositionContext = rememberCompositionContext() let childContext = context.content() + let projectionInstanceID = remember { UUID.randomUUID().toString() } + let currentProjectionLifecycle = rememberUpdatedState(bridgedProjectionLifecycle) + let preparedProjection = bridgedProjectionLifecycle?(projectionInstanceID, false) let storage = remember(id) { - AndroidCompositionBoundaryStorage(inputs: inputs, content: content()) + AndroidCompositionBoundaryStorage( + inputs: inputs, + content: preparedProjection ?? content() + ) + } + + // Dispose the native projection when this boundary actually leaves the Compose tree. + DisposableEffect(projectionInstanceID) { + onDispose { + if let projectionLifecycle = currentProjectionLifecycle.value { + _ = projectionLifecycle(projectionInstanceID, true) + } + } } AndroidView( @@ -68,7 +95,7 @@ public struct AndroidCompositionBoundary: View, Renderable { return } storage.inputs = inputs - storage.content = content() + storage.content = preparedProjection ?? content() composeView.setContent { storage.content.Compose(context: childContext) } @@ -90,10 +117,12 @@ private final class AndroidCompositionBoundaryStorage { #endif extension View { - /// Isolates this subtree in a retained Android composition root. + /// Gives this subtree its own retained identity and lifecycle on Android. /// - /// Non-Android platforms return the original view. On Android, the detached root updates its - /// child content only when `inputs` changes. + /// Keeping `id` stable preserves the host and its state. Changing `inputs` updates content + /// inside the existing host; changing `id` disposes it and creates a new one. Non-Android + /// platforms return the original view. Change `inputs` whenever a value used to build this + /// view changes; the retained content remains unchanged while both arguments are unchanged. public func androidCompositionBoundary(id: String, inputs: String = "") -> some View { #if SKIP return AndroidCompositionBoundary(id: id, inputs: inputs, content: { self }) diff --git a/Sources/SkipUI/SkipUI/Containers/ForEach.swift b/Sources/SkipUI/SkipUI/Containers/ForEach.swift index b9029b41..d1ed4d00 100644 --- a/Sources/SkipUI/SkipUI/Containers/ForEach.swift +++ b/Sources/SkipUI/SkipUI/Containers/ForEach.swift @@ -4,6 +4,7 @@ import Foundation #if SKIP import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember #endif // SKIP @bridge @@ -81,6 +82,7 @@ public final class ForEach : View, Renderable, LazyItemFactory { guard !EvaluateOptions(options).isKeepForEach else { return listOf(self) } + let identityNamespace = remember { ForEachIdentityNamespace() } let isLazy = EvaluateOptions(options).lazyItemLevel != nil // ForEach views might contain nested lazy item factories such as Sections or other ForEach instances. They also @@ -105,7 +107,9 @@ public final class ForEach : View, Renderable, LazyItemFactory { } else { defaultTag = index } - renderables = renderables.map { taggedRenderable(for: $0, defaultTag: defaultTag) } + renderables = renderables.map { + taggedRenderable(for: $0, defaultTag: defaultTag, identityNamespace: identityNamespace) + } collected.addAll(renderables) } } else if let objects { @@ -118,7 +122,9 @@ public final class ForEach : View, Renderable, LazyItemFactory { isFirst = false } if let identifier { - renderables = renderables.map { taggedRenderable(for: $0, defaultTag: identifier(object)) } + renderables = renderables.map { + taggedRenderable(for: $0, defaultTag: identifier(object), identityNamespace: identityNamespace) + } } collected.addAll(renderables) } @@ -133,7 +139,9 @@ public final class ForEach : View, Renderable, LazyItemFactory { isFirst = false } if let identifier { - renderables = renderables.map { taggedRenderable(for: $0, defaultTag: identifier(objects[i])) } + renderables = renderables.map { + taggedRenderable(for: $0, defaultTag: identifier(objects[i]), identityNamespace: identityNamespace) + } } collected.addAll(renderables) } @@ -239,12 +247,32 @@ public final class ForEach : View, Renderable, LazyItemFactory { } } - private func taggedRenderable(for renderable: Renderable, defaultTag: Any?) -> Renderable { - if let defaultTag, TagModifier.on(content: renderable, role: .tag) == nil { - return ModifiedContent(content: renderable, modifier: TagModifier(value: defaultTag, role: .tag)) - } else { + private func taggedRenderable( + for renderable: Renderable, + defaultTag: Any?, + identityNamespace: ForEachIdentityNamespace? = nil + ) -> Renderable { + guard let defaultTag else { return renderable } + + let taggedRenderable: Renderable + if TagModifier.on(content: renderable, role: .tag) == nil { + taggedRenderable = ModifiedContent(content: renderable, modifier: TagModifier(value: defaultTag, role: .tag)) + } else { + taggedRenderable = renderable + } + + guard let identityNamespace else { + return taggedRenderable + } + + // Keep Compose state attached to the ForEach element rather than its current position. + // Namespace the key because sibling ForEach blocks may legally contain the same IDs. + return ModifiedContent( + content: taggedRenderable, + modifier: ForEachIdentityModifier(namespace: identityNamespace, identity: defaultTag) + ) } #else public var body: some View { @@ -254,6 +282,33 @@ public final class ForEach : View, Renderable, LazyItemFactory { } #if SKIP +final class ForEachIdentityNamespace { +} + +final class ForEachIdentityModifier: RenderModifier { + let namespace: ForEachIdentityNamespace + let identity: Any + + init(namespace: ForEachIdentityNamespace, identity: Any) { + self.namespace = namespace + self.identity = identity + super.init(action: { renderable, context in + androidx.compose.runtime.key(namespace, identity) { + renderable.Render(context: context) + } + }) + } + + static func key(for renderable: Renderable) -> Any? { + return renderable.forEachModifier { + guard let identityModifier = $0 as? ForEachIdentityModifier else { + return nil + } + return listOf(identityModifier.namespace, identityModifier.identity) + } + } +} + // Kotlin does not support generic constructor parameters, so we have to model many ForEach constructors as functions //extension ForEach where ID == Data.Element.ID, Content : AccessibilityRotorContent, Data.Element : Identifiable { diff --git a/Sources/SkipUI/SkipUI/Containers/HStack.swift b/Sources/SkipUI/SkipUI/Containers/HStack.swift index 4dffd218..6b2c432d 100644 --- a/Sources/SkipUI/SkipUI/Containers/HStack.swift +++ b/Sources/SkipUI/SkipUI/Containers/HStack.swift @@ -106,8 +106,13 @@ public struct HStack : View, Renderable { return ComposeResult.ok } in: { var lastWasSpacer: Bool? = nil - for renderable in renderables { - lastWasSpacer = RenderSpaced(renderable: renderable, adaptiveSpacing: adaptiveSpacing, lastWasSpacer: lastWasSpacer, layoutImplementationVersion: layoutImplementationVersion, context: contentContext) + let occurrences = mutableMapOf() + for index in 0..() + for index in 0..) -> Any { + if let forEachKey = ForEachIdentityModifier.key(for: renderable) { + return forEachKey + } + let identity: Any = TagModifier.on(content: renderable, role: .id)?.value + ?? TagModifier.on(content: renderable, role: .tag)?.value + ?? index + let occurrence = occurrences[identity] ?? 0 + occurrences[identity] = occurrence + 1 + return listOf(identity, occurrence) + } #else public var body: some View { stubView() diff --git a/Sources/SkipUI/SkipUI/Containers/VStack.swift b/Sources/SkipUI/SkipUI/Containers/VStack.swift index 398f6718..7432cf91 100644 --- a/Sources/SkipUI/SkipUI/Containers/VStack.swift +++ b/Sources/SkipUI/SkipUI/Containers/VStack.swift @@ -109,8 +109,13 @@ public struct VStack : View, Renderable { } in: { var lastWasText: Bool? = nil var lastWasSpacer: Bool? = nil - for renderable in renderables { - (lastWasText, lastWasSpacer) = RenderSpaced(renderable: renderable, adaptiveSpacing: adaptiveSpacing, lastWasText: lastWasText, lastWasSpacer: lastWasSpacer, context: contentContext, layoutImplementationVersion: layoutImplementationVersion) + let occurrences = mutableMapOf() + for index in 0..() + for index in 0..) -> Any { + if let forEachKey = ForEachIdentityModifier.key(for: renderable) { + return forEachKey + } + let identity: Any = TagModifier.on(content: renderable, role: .id)?.value + ?? TagModifier.on(content: renderable, role: .tag)?.value + ?? index + let occurrence = occurrences[identity] ?? 0 + occurrences[identity] = occurrence + 1 + return listOf(identity, occurrence) + } #else public var body: some View { stubView() diff --git a/Sources/SkipUI/SkipUI/Containers/ZStack.swift b/Sources/SkipUI/SkipUI/Containers/ZStack.swift index 0376d19c..c11d0fbf 100644 --- a/Sources/SkipUI/SkipUI/Containers/ZStack.swift +++ b/Sources/SkipUI/SkipUI/Containers/ZStack.swift @@ -135,6 +135,9 @@ public struct ZStack : View, Renderable { /// unique within a single composition (no Compose "key already used" crash) yet stable /// across recompositions whenever the child set is stable. private func childKey(for renderable: Renderable, index: Int, occurrences: MutableMap) -> Any { + if let forEachKey = ForEachIdentityModifier.key(for: renderable) { + return forEachKey + } let identity: Any = TagModifier.on(content: renderable, role: .id)?.value ?? TagModifier.on(content: renderable, role: .tag)?.value ?? index diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index c1ed21bc..c0f2764a 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -622,6 +622,33 @@ extension View { return frame(width: width, height: height, alignment: Alignment(horizontal: HorizontalAlignment(key: horizontalAlignmentKey), vertical: VerticalAlignment(key: verticalAlignmentKey))) } + /// Experimental bridge API that gives a newly composed frame explicit source dimensions. + // SKIP @bridge + public func experimentalFirstRenderFrame(width: CGFloat, height: CGFloat, sourceWidth: CGFloat, sourceHeight: CGFloat, horizontalAlignmentKey: String, verticalAlignmentKey: String) -> any View { + #if SKIP + let animTx = StateTracking.captureLastReadAndClear() + return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in + let animatable = (Float(width), Float(height)).asAnimatable( + context: context, + animTx: animTx, + initialValue: (Float(sourceWidth), Float(sourceHeight)) + ) + FrameLayout( + content: renderable, + context: context, + width: Double(animatable.value.0), + height: Double(animatable.value.1), + alignment: Alignment( + horizontal: HorizontalAlignment(key: horizontalAlignmentKey), + vertical: VerticalAlignment(key: verticalAlignmentKey) + ) + ) + }) + #else + return self + #endif + } + public func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in @@ -863,6 +890,28 @@ extension View { #endif } + /// Experimental bridge API that gives a newly composed offset an explicit source value. + // SKIP @bridge + public func experimentalFirstRenderOffset(x: CGFloat, y: CGFloat, sourceX: CGFloat, sourceY: CGFloat) -> any View { + #if SKIP + let animTx = StateTracking.captureLastReadAndClear() + return ModifiedContent(content: self, modifier: RenderModifier { + let density = LocalDensity.current + let animatable = (Float(x), Float(y)).asAnimatable( + context: $0, + animTx: animTx, + initialValue: (Float(sourceX), Float(sourceY)) + ) + let offsetPx = with(density) { + IntOffset(animatable.value.0.dp.roundToPx(), animatable.value.1.dp.roundToPx()) + } + return $0.modifier.offset { offsetPx } + }) + #else + return self + #endif + } + // SKIP @bridge public func onAppear(perform action: (() -> Void)? = nil) -> any View { #if SKIP @@ -1094,6 +1143,24 @@ extension View { #endif } + /// Experimental bridge API that gives a newly composed opacity an explicit source value. + // SKIP @bridge + public func experimentalFirstRenderOpacity(_ opacity: Double, sourceOpacity: Double) -> any View { + #if SKIP + let animTx = StateTracking.captureLastReadAndClear() + return ModifiedContent(content: self, modifier: RenderModifier { context in + let animatable = Float(opacity).asAnimatable( + context: context, + animTx: animTx, + initialValue: Float(sourceOpacity) + ) + return context.modifier.graphicsLayer { alpha = animatable.value } + }) + #else + return self + #endif + } + public func overlay(_ overlay: any View, alignment: Alignment = .center) -> any View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in @@ -1346,6 +1413,71 @@ extension View { return scaleEffect(x: x, y: y, anchor: UnitPoint(x: anchorX, y: anchorY)) } + /// Applies scale and translation in one compositor layer without changing layout position. + /// + /// Use this for native-backed content whose pixels should move without relocating its + /// Compose layout node. Translation values use SwiftUI points and are converted to pixels. + // SKIP @bridge + public func compositorTransform( + scaleX: CGFloat, + scaleY: CGFloat, + translationX: CGFloat, + translationY: CGFloat, + anchorX: CGFloat, + anchorY: CGFloat + ) -> any View { + #if SKIP + let animTx = StateTracking.captureLastReadAndClear() + return ModifiedContent(content: self, modifier: RenderModifier { context in + let animatedScale = (Float(scaleX), Float(scaleY)).asAnimatable( + context: context, + animTx: animTx + ) + let animatedTranslation = (Float(translationX), Float(translationY)).asAnimatable( + context: context, + animTx: animTx + ) + let density = LocalDensity.current + let translationXPixels = with(density) { animatedTranslation.value.0.dp.toPx() } + let translationYPixels = with(density) { animatedTranslation.value.1.dp.toPx() } + return context.modifier.graphicsLayer( + transformOrigin: TransformOrigin( + pivotFractionX: Float(anchorX), + pivotFractionY: Float(anchorY) + ), + scaleX: animatedScale.value.0, + scaleY: animatedScale.value.1, + translationX: translationXPixels, + translationY: translationYPixels + ) + }) + #else + return self + #endif + } + + /// Experimental bridge API that gives a newly composed scale an explicit source value. + // SKIP @bridge + public func experimentalFirstRenderScaleEffect(x: CGFloat, y: CGFloat, sourceX: CGFloat, sourceY: CGFloat, anchorX: CGFloat, anchorY: CGFloat) -> any View { + #if SKIP + let animTx = StateTracking.captureLastReadAndClear() + return ModifiedContent(content: self, modifier: RenderModifier { context in + let animatable = (Float(x), Float(y)).asAnimatable( + context: context, + animTx: animTx, + initialValue: (Float(sourceX), Float(sourceY)) + ) + return context.modifier.graphicsLayer( + transformOrigin: TransformOrigin(pivotFractionX: Float(anchorX), pivotFractionY: Float(anchorY)), + scaleX: animatable.value.0, + scaleY: animatable.value.1 + ) + }) + #else + return self + #endif + } + @available(*, unavailable) public func sectionActions(@ViewBuilder content: () -> any View) -> some View { return self diff --git a/Sources/SkipUI/SkipUI/View/EquatableView.swift b/Sources/SkipUI/SkipUI/View/EquatableView.swift index c7858f0e..55c3c694 100644 --- a/Sources/SkipUI/SkipUI/View/EquatableView.swift +++ b/Sources/SkipUI/SkipUI/View/EquatableView.swift @@ -38,11 +38,14 @@ struct AndroidEquatableView: View, Renderable { @Composable override func Render(context: ComposeContext) { let storage = remember { - AndroidEquatableStorage(recomposeOverride: recomposeOverride) + AndroidEquatableStorage( + recomposeOverride: recomposeOverride, + areEqual: { lhs, rhs in lhs == rhs } + ) } for renderable in storage.renderables( recomposeOverride: recomposeOverride, - content: content, + contentFactory: { content }, context: context, options: 0 ) { @@ -51,15 +54,19 @@ struct AndroidEquatableView: View, Renderable { } } -/// Retains bridged Android child content while a string render identity remains equal. +/// Retains bridged Android child content while its equality-preserving override remains equal. // SKIP @bridge public struct AndroidEquatableContent: View, Renderable { - let recomposeOverride: String + let recomposeOverride: Any let content: () -> any View /// Creates retained Android content around lazily bridged child content. + /// + /// `recomposeOverride` must provide meaningful JVM `equals` behavior. Native Swift callers + /// use SkipBridge's `SwiftEquatable` wrapper to preserve the source value's `Equatable` + /// implementation. // SKIP @bridge - public init(recomposeOverride: String, bridgedContentFactory: @escaping () -> any View) { + public init(recomposeOverride: Any, bridgedContentFactory: @escaping () -> any View) { self.recomposeOverride = recomposeOverride self.content = bridgedContentFactory } @@ -70,11 +77,14 @@ public struct AndroidEquatableContent: View, Renderable { @Composable override func Render(context: ComposeContext) { let storage = remember { - AndroidEquatableStorage(recomposeOverride: recomposeOverride) + AndroidEquatableStorage( + recomposeOverride: recomposeOverride, + areEqual: { lhs, rhs in lhs.equals(other: rhs) } + ) } for renderable in storage.renderables( recomposeOverride: recomposeOverride, - content: content(), + contentFactory: content, context: context, options: 0 ) { @@ -83,23 +93,28 @@ public struct AndroidEquatableContent: View, Renderable { } } -private final class AndroidEquatableStorage { +private final class AndroidEquatableStorage { var recomposeOverride: RecomposeOverride var renderables: kotlin.collections.List? + let areEqual: (RecomposeOverride, RecomposeOverride) -> Bool - init(recomposeOverride: RecomposeOverride) { + init( + recomposeOverride: RecomposeOverride, + areEqual: @escaping (RecomposeOverride, RecomposeOverride) -> Bool + ) { self.recomposeOverride = recomposeOverride + self.areEqual = areEqual } @Composable func renderables( recomposeOverride: RecomposeOverride, - content: any View, + contentFactory: () -> any View, context: ComposeContext, options: Int ) -> kotlin.collections.List { - if renderables == nil || self.recomposeOverride != recomposeOverride { + if renderables == nil || !areEqual(self.recomposeOverride, recomposeOverride) { self.recomposeOverride = recomposeOverride - self.renderables = content.Evaluate(context: context, options: options) + self.renderables = contentFactory().Evaluate(context: context, options: options) } return renderables ?? listOf() } @@ -108,6 +123,10 @@ private final class AndroidEquatableStorage { extension View where Self: Equatable { /// On Android, reuses this view's evaluated content while the view value remains equal. + /// + /// State read inside this view's body is not an automatic invalidation input. Hoist any + /// state that must update the body and include it in this view's `Equatable` implementation. + /// Non-Android platforms return the original view. public func androidEquatable() -> some View { #if SKIP return AndroidEquatableView(content: self, recomposeOverride: self) @@ -121,7 +140,9 @@ extension View { /// On Android, reuses evaluated content until `recomposeOverride` changes. /// /// Include every body-affecting external value in `recomposeOverride`; unchanged values skip - /// parent-driven body evaluation for this subtree. + /// parent-driven body evaluation for this subtree. State read inside this view's body is not + /// an automatic invalidation input, so hoist state that must update the body and include its + /// value in `recomposeOverride`. Non-Android platforms return the original view. public func androidEquatable(recomposeOverride: RecomposeOverride) -> some View { #if SKIP return AndroidEquatableView(content: self, recomposeOverride: recomposeOverride) diff --git a/Tests/SkipUITests/AndroidEquatableTests.swift b/Tests/SkipUITests/AndroidEquatableTests.swift index b94b094c..321c89a4 100644 --- a/Tests/SkipUITests/AndroidEquatableTests.swift +++ b/Tests/SkipUITests/AndroidEquatableTests.swift @@ -162,9 +162,10 @@ final class AndroidEquatableTests: SkipUITestCase { composeRule.onNodeWithTag("android-equatable-row-0").assertTextEquals("Zero") composeRule.onNodeWithTag("android-equatable-row-1").assertTextEquals("One") composeRule.onNodeWithTag("android-equatable-row-3").assertTextEquals("Three") - XCTAssertGreaterThan(counter.value("android-equatable-row-0"), 0) - XCTAssertGreaterThan(counter.value("android-equatable-row-1"), 0) - XCTAssertGreaterThan(counter.value("android-equatable-row-3"), 0) + let row0InsertedCount = counter.value("android-equatable-row-0") + XCTAssertGreaterThan(row0InsertedCount, 0) + XCTAssertEqual(counter.value("android-equatable-row-1"), row1InitialCount) + XCTAssertEqual(counter.value("android-equatable-row-3"), row3InitialCount) items.wrappedValue = [ AndroidEquatableItem(id: 3, title: "Three"), @@ -176,9 +177,9 @@ final class AndroidEquatableTests: SkipUITestCase { composeRule.onNodeWithTag("android-equatable-row-3").assertTextEquals("Three") composeRule.onNodeWithTag("android-equatable-row-0").assertTextEquals("Zero") composeRule.onNodeWithTag("android-equatable-row-1").assertTextEquals("One") - XCTAssertGreaterThan(counter.value("android-equatable-row-0"), 0) - XCTAssertGreaterThan(counter.value("android-equatable-row-1"), 0) - XCTAssertGreaterThan(counter.value("android-equatable-row-3"), 0) + XCTAssertEqual(counter.value("android-equatable-row-0"), row0InsertedCount) + XCTAssertEqual(counter.value("android-equatable-row-1"), row1InitialCount) + XCTAssertEqual(counter.value("android-equatable-row-3"), row3InitialCount) #endif } @@ -334,6 +335,42 @@ final class AndroidEquatableTests: SkipUITestCase { XCTAssertEqual(counter.value("android-equatable-action-child"), 1) #endif } + + func testBridgedFactoryRunsOnlyWhenOverrideChanges() throws { + #if !SKIP + throw XCTSkip("AndroidEquatableContent is Android-only") + #else + let parentTick = State(initialValue: 0) + let childText = State(initialValue: "A") + let counter = AndroidEquatableBodyCounter() + + composeRule.setContent { + AndroidEquatableFactoryHost( + parentTick: parentTick, + childText: childText, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-factory-child").assertTextEquals("A") + XCTAssertEqual(counter.value("android-equatable-factory"), 1) + + parentTick.wrappedValue = 1 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-factory-parent").assertTextEquals("tick 1") + composeRule.onNodeWithTag("android-equatable-factory-child").assertTextEquals("A") + XCTAssertEqual(counter.value("android-equatable-factory"), 1) + + childText.wrappedValue = "B" + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-factory-child").assertTextEquals("B") + XCTAssertEqual(counter.value("android-equatable-factory"), 2) + #endif + } } #if SKIP @@ -555,4 +592,26 @@ private struct AndroidEquatableActionHost: View { } } } + +private struct AndroidEquatableFactoryHost: View { + let parentTick: State + let childText: State + let counter: AndroidEquatableBodyCounter + + var body: some View { + let text = childText.wrappedValue + VStack { + Text("tick \(parentTick.wrappedValue)") + .accessibilityIdentifier("android-equatable-factory-parent") + AndroidEquatableContent( + recomposeOverride: text, + bridgedContentFactory: { + counter.increment("android-equatable-factory") + return Text(text) + .accessibilityIdentifier("android-equatable-factory-child") + } + ) + } + } +} #endif diff --git a/Tests/SkipUITests/AnimationTests.swift b/Tests/SkipUITests/AnimationTests.swift index 39d43ffa..138171ee 100644 --- a/Tests/SkipUITests/AnimationTests.swift +++ b/Tests/SkipUITests/AnimationTests.swift @@ -7,6 +7,7 @@ import XCTest #if SKIP import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -81,6 +82,69 @@ final class AnimationTests: SkipUITestCase { #endif } + func testEnvironmentOverrideConsumesPendingBridgedProvenance() throws { + #if !SKIP + throw XCTSkip("bridged provenance resolution is Android-only") + #else + let firstResult = PendingAnimationResult() + let nextResult = PendingAnimationResult() + + Animation.primeBridgedProvenance(.linear(duration: 1)) + let capturedTransaction = StateTracking.captureLastReadAndClear() + + composeRule.setContent { + VStack { + PendingAnimationProbe( + animTx: capturedTransaction, + result: firstResult + ) + .animation(.easeIn(duration: 0.5)) + + PendingAnimationProbe( + animTx: nil, + result: nextResult + ) + } + .Compose() + } + composeRule.waitForIdle() + + XCTAssertNotNil(firstResult.animation, "the environment animation should win for the primed consumer") + XCTAssertNil(nextResult.animation, "the overridden bridge prime must not leak to the next consumer") + #endif + } + + func testDisabledTransactionConsumesPendingBridgedProvenance() throws { + #if !SKIP + throw XCTSkip("bridged provenance resolution is Android-only") + #else + let firstResult = PendingAnimationResult() + let nextResult = PendingAnimationResult() + + Animation.primeBridgedProvenance(.linear(duration: 1)) + let capturedTransaction = StateTracking.captureLastReadAndClear() as? Transaction + capturedTransaction?.disablesAnimations = true + + composeRule.setContent { + VStack { + PendingAnimationProbe( + animTx: capturedTransaction, + result: firstResult + ) + PendingAnimationProbe( + animTx: nil, + result: nextResult + ) + } + .Compose() + } + composeRule.waitForIdle() + + XCTAssertNil(firstResult.animation, "a disabled transaction should suppress its animation") + XCTAssertNil(nextResult.animation, "the suppressed bridge prime must not leak to the next consumer") + #endif + } + // NOTE: A "probe" test that calls `Animation.current` from inside a Composable and checks // the value after `withAnimation` exits would directly verify the `.fill` regression fix, // but under Robolectric the awaitFrame-based clear races with the recompose triggered by @@ -325,6 +389,26 @@ final class AnimationTests: SkipUITestCase { } #if SKIP +private final class PendingAnimationResult { + var animation: Animation? +} + +private struct PendingAnimationProbe: View, Renderable { + let animTx: StateMutationTransaction? + let result: PendingAnimationResult + + @Composable override func Evaluate(context: ComposeContext, options: Int) -> kotlin.collections.List { + return listOf(self) + } + + @Composable override func Render(context: ComposeContext) { + let animation = Animation.current(isAnimating: false, animTx: animTx) + SideEffect { + result.animation = animation + } + } +} + /// Small test view whose frame width is driven by a shared `skip.ui.State` instance so the test /// can mutate it externally while still exercising the real `@State` plumbing — including the /// per-slot transaction stamping that animatable modifiers use to decide animate-vs-snap. From e8d68f4ba1978ef778304d91868435aa88c23e14 Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 29 Jul 2026 14:46:42 -0700 Subject: [PATCH 8/9] cleaned up left over experimental APIs --- .../SkipUI/SkipUI/Animation/Animation.swift | 17 +--- .../SkipUI/View/AdditionalViewModifiers.swift | 89 ------------------- 2 files changed, 2 insertions(+), 104 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Animation/Animation.swift b/Sources/SkipUI/SkipUI/Animation/Animation.swift index 4443a7da..d74a0cdf 100644 --- a/Sources/SkipUI/SkipUI/Animation/Animation.swift +++ b/Sources/SkipUI/SkipUI/Animation/Animation.swift @@ -662,13 +662,10 @@ public enum AnimationCompletionCriteria : Hashable { /// Animatable plumbing for modifiers that capture provenance at entry: `animTx` carries the /// per-slot transaction (or nil → snap); the marker fallback is NOT consulted. -@Composable func toAnimatable(value: T, initialValue: T? = nil, converter: TwoWayConverter, context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable where T: Any, VectorT: AnimationVector { +@Composable func toAnimatable(value: T, converter: TwoWayConverter, context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable where T: Any, VectorT: AnimationVector { // SKIP NOWARN let resetValue = rememberSaveable(stateSaver: context.stateSaver as Saver) { mutableStateOf(nil) } - // A newly composed modifier normally has only its target value, so Compose has no source - // to interpolate from. Experimental first-render modifier APIs can provide that source - // explicitly; existing callers continue to initialize from `value`. - let animatable = remember { Animatable(resetValue.value ?? initialValue ?? value, converter) } + let animatable = remember { Animatable(resetValue.value ?? value, converter) } let isAnimating = animatable.isRunning || animatable.value != animatable.targetValue let isNewTarget = animatable.targetValue != value if isAnimating || animatable.value != value { @@ -703,11 +700,6 @@ extension Float { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) } - /// Return an animatable value initialized from an explicit first-render source. - @Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?, initialValue: Float) -> Animatable { - return toAnimatable(value: self, initialValue: initialValue, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) - } - } extension Tuple2 where E0 == Float, E1 == Float { @@ -721,11 +713,6 @@ extension Tuple2 where E0 == Float, E1 == Float { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) } - /// Return an animatable pair initialized from an explicit first-render source. - @Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?, initialValue: Tuple2) -> Animatable, AnimationVector2D> { - return toAnimatable(value: self, initialValue: initialValue, converter: TwoWayConverter({ AnimationVector2D($0.0, $0.1) }, { Tuple2($0.v1, $0.v2) }), context: context, animTx: animTx) - } - } extension androidx.compose.ui.graphics.Color { diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index c0f2764a..d8f831fb 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -622,33 +622,6 @@ extension View { return frame(width: width, height: height, alignment: Alignment(horizontal: HorizontalAlignment(key: horizontalAlignmentKey), vertical: VerticalAlignment(key: verticalAlignmentKey))) } - /// Experimental bridge API that gives a newly composed frame explicit source dimensions. - // SKIP @bridge - public func experimentalFirstRenderFrame(width: CGFloat, height: CGFloat, sourceWidth: CGFloat, sourceHeight: CGFloat, horizontalAlignmentKey: String, verticalAlignmentKey: String) -> any View { - #if SKIP - let animTx = StateTracking.captureLastReadAndClear() - return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in - let animatable = (Float(width), Float(height)).asAnimatable( - context: context, - animTx: animTx, - initialValue: (Float(sourceWidth), Float(sourceHeight)) - ) - FrameLayout( - content: renderable, - context: context, - width: Double(animatable.value.0), - height: Double(animatable.value.1), - alignment: Alignment( - horizontal: HorizontalAlignment(key: horizontalAlignmentKey), - vertical: VerticalAlignment(key: verticalAlignmentKey) - ) - ) - }) - #else - return self - #endif - } - public func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in @@ -890,28 +863,6 @@ extension View { #endif } - /// Experimental bridge API that gives a newly composed offset an explicit source value. - // SKIP @bridge - public func experimentalFirstRenderOffset(x: CGFloat, y: CGFloat, sourceX: CGFloat, sourceY: CGFloat) -> any View { - #if SKIP - let animTx = StateTracking.captureLastReadAndClear() - return ModifiedContent(content: self, modifier: RenderModifier { - let density = LocalDensity.current - let animatable = (Float(x), Float(y)).asAnimatable( - context: $0, - animTx: animTx, - initialValue: (Float(sourceX), Float(sourceY)) - ) - let offsetPx = with(density) { - IntOffset(animatable.value.0.dp.roundToPx(), animatable.value.1.dp.roundToPx()) - } - return $0.modifier.offset { offsetPx } - }) - #else - return self - #endif - } - // SKIP @bridge public func onAppear(perform action: (() -> Void)? = nil) -> any View { #if SKIP @@ -1143,24 +1094,6 @@ extension View { #endif } - /// Experimental bridge API that gives a newly composed opacity an explicit source value. - // SKIP @bridge - public func experimentalFirstRenderOpacity(_ opacity: Double, sourceOpacity: Double) -> any View { - #if SKIP - let animTx = StateTracking.captureLastReadAndClear() - return ModifiedContent(content: self, modifier: RenderModifier { context in - let animatable = Float(opacity).asAnimatable( - context: context, - animTx: animTx, - initialValue: Float(sourceOpacity) - ) - return context.modifier.graphicsLayer { alpha = animatable.value } - }) - #else - return self - #endif - } - public func overlay(_ overlay: any View, alignment: Alignment = .center) -> any View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in @@ -1456,28 +1389,6 @@ extension View { #endif } - /// Experimental bridge API that gives a newly composed scale an explicit source value. - // SKIP @bridge - public func experimentalFirstRenderScaleEffect(x: CGFloat, y: CGFloat, sourceX: CGFloat, sourceY: CGFloat, anchorX: CGFloat, anchorY: CGFloat) -> any View { - #if SKIP - let animTx = StateTracking.captureLastReadAndClear() - return ModifiedContent(content: self, modifier: RenderModifier { context in - let animatable = (Float(x), Float(y)).asAnimatable( - context: context, - animTx: animTx, - initialValue: (Float(sourceX), Float(sourceY)) - ) - return context.modifier.graphicsLayer( - transformOrigin: TransformOrigin(pivotFractionX: Float(anchorX), pivotFractionY: Float(anchorY)), - scaleX: animatable.value.0, - scaleY: animatable.value.1 - ) - }) - #else - return self - #endif - } - @available(*, unavailable) public func sectionActions(@ViewBuilder content: () -> any View) -> some View { return self From 3af2f36a809a42bedd70e4a7e2901b405b7d71a7 Mon Sep 17 00:00:00 2001 From: tifroz Date: Sat, 22 Aug 2026 13:34:46 -0700 Subject: [PATCH 9/9] Test that Android composition boundaries retain their ComposeView - verify state updates refresh content without replacing the native host - verify parent size changes remeasure the same host - verify environment values remain available and update inside the boundary - document that fixed sizing belongs outside the boundary --- README.md | 2 + .../Skip/AndroidCompositionBoundaryRoot.kt | 2 + .../Compose/AndroidCompositionBoundary.swift | 8 +- .../AndroidCompositionBoundaryTests.swift | 264 ++++++++++++++++++ .../AndroidCompositionBoundaryTestSupport.kt | 28 ++ 5 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 Tests/SkipUITests/AndroidCompositionBoundaryTests.swift create mode 100644 Tests/SkipUITests/Skip/AndroidCompositionBoundaryTestSupport.kt diff --git a/README.md b/README.md index 4543b701..02081797 100644 --- a/README.md +++ b/README.md @@ -642,6 +642,8 @@ PlayerSurface(player: player) Keeping `id` stable preserves the hosted composition and its state. Changing `inputs` updates the content inside the existing host. Changing `id` disposes the old host and creates a new one. Treat `inputs` as a revision token and change it whenever any value used to build the retained content changes; content remains unchanged while both `id` and `inputs` are unchanged. +The boundary inherits the current Compose composition locals, including SkipUI environment values. It is a composition and lifecycle boundary, not a layout boundary: modifiers and constraints outside the boundary continue to measure the same retained host and child. A size change therefore remeasures native-backed content without recreating or re-bridging it. If native content must keep fixed bounds, make that an explicit surrounding-layout decision rather than relying on the composition boundary. + An Android composition boundary creates a separate Compose host, so it is heavier than `.androidEquatable(...)`. Prefer equality reuse for ordinary views and collection rows. Use a composition boundary when the subtree specifically needs retained hosting or lifecycle isolation. ## Supported SwiftUI diff --git a/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt index c49ef063..987ca266 100644 --- a/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt +++ b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.platform.ViewCompositionStrategy * Android's default ComposeView path resolves a parent/view-tree/window composition context. This * helper is intentionally narrower: callers use it only for retained composition islands that * should inherit composition locals without being rebuilt for unrelated sibling composition work. + * The host remains layout-transparent: parent constraints continue to measure this ComposeView and + * its child without replacing the composition. */ fun AndroidCompositionBoundaryComposeView( context: Context, diff --git a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift index 90c3cb56..a3d61320 100644 --- a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift +++ b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift @@ -17,7 +17,11 @@ import java.util.UUID /// Think of the boundary as a separate hosting container. Keeping `id` stable preserves that /// container and its state. Changing `inputs` updates content inside the existing container; /// changing `id` disposes it and creates a new one. Change `inputs` whenever a value used to -/// build `content` changes. +/// build `content` changes. The boundary inherits the current composition locals. +/// +/// This is a composition and lifecycle boundary, not a layout boundary. Constraints from the +/// surrounding hierarchy continue to measure the retained host and child without changing their +/// identity or rebuilding unchanged content. // SKIP @bridge public struct AndroidCompositionBoundary: View, Renderable { let id: String @@ -123,6 +127,8 @@ extension View { /// inside the existing host; changing `id` disposes it and creates a new one. Non-Android /// platforms return the original view. Change `inputs` whenever a value used to build this /// view changes; the retained content remains unchanged while both arguments are unchanged. + /// The boundary inherits composition locals and remains transparent to parent measurement: + /// new constraints remeasure the same retained host and child. public func androidCompositionBoundary(id: String, inputs: String = "") -> some View { #if SKIP return AndroidCompositionBoundary(id: id, inputs: inputs, content: { self }) diff --git a/Tests/SkipUITests/AndroidCompositionBoundaryTests.swift b/Tests/SkipUITests/AndroidCompositionBoundaryTests.swift new file mode 100644 index 00000000..cb191fd5 --- /dev/null +++ b/Tests/SkipUITests/AndroidCompositionBoundaryTests.swift @@ -0,0 +1,264 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import SwiftUI +import XCTest + +#if SKIP +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +#endif + +/// Verifies the retained-host, inherited-environment, and layout-transparent contract of +/// `androidCompositionBoundary(id:inputs:)` on Android. +final class AndroidCompositionBoundaryTests: SkipUITestCase { + // SKIP INSERT: @get:org.junit.Rule val composeRule = createAndroidComposeRule() + + func testUnchangedInputsRetainContentAcrossParentRecomposition() throws { + #if !SKIP + throw XCTSkip("androidCompositionBoundary is Android-only") + #else + let parentTick = State(initialValue: 0) + let counter = AndroidCompositionBoundaryBodyCounter() + + composeRule.setContent { + AndroidCompositionBoundaryStableHost( + parentTick: parentTick, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("composition-boundary-parent").assertTextEquals("tick 0") + composeRule.onNodeWithTag("composition-boundary-stable-child").assertTextEquals("retained") + let initialCount = counter.value("stable-child") + XCTAssertGreaterThan(initialCount, 0) + + parentTick.wrappedValue = 1 + composeRule.waitForIdle() + + composeRule.onNodeWithTag("composition-boundary-parent").assertTextEquals("tick 1") + composeRule.onNodeWithTag("composition-boundary-stable-child").assertTextEquals("retained") + XCTAssertEqual(counter.value("stable-child"), initialCount) + #endif + } + + func testChangedInputsUpdateContentInsideExistingComposition() throws { + #if !SKIP + throw XCTSkip("androidCompositionBoundary is Android-only") + #else + let childText = State(initialValue: "A") + let counter = AndroidCompositionBoundaryBodyCounter() + + composeRule.setContent { + AndroidCompositionBoundaryInputHost( + childText: childText, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + let initialHost = composeRule.runOnIdle { + androidCompositionBoundaryHostView( + root: composeRule.activity.findViewById(android.R.id.content) + ) + } + let initialCount = counter.value("input-child") + composeRule.onNodeWithTag("composition-boundary-input-child").assertTextEquals("A") + + childText.wrappedValue = "B" + composeRule.waitForIdle() + + let updatedHost = composeRule.runOnIdle { + androidCompositionBoundaryHostView( + root: composeRule.activity.findViewById(android.R.id.content) + ) + } + composeRule.onNodeWithTag("composition-boundary-input-child").assertTextEquals("B") + XCTAssertTrue(updatedHost === initialHost) + XCTAssertGreaterThan(counter.value("input-child"), initialCount) + #endif + } + + func testCompositionLocalsAreInheritedAndRefreshWithInputs() throws { + #if !SKIP + throw XCTSkip("androidCompositionBoundary is Android-only") + #else + let environmentValue = State(initialValue: "outer") + + composeRule.setContent { + AndroidCompositionBoundaryEnvironmentHost( + environmentValue: environmentValue + ) + .Compose() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("composition-boundary-environment-child") + .assertTextEquals("outer") + + environmentValue.wrappedValue = "inner" + composeRule.waitForIdle() + + composeRule.onNodeWithTag("composition-boundary-environment-child") + .assertTextEquals("inner") + #endif + } + + func testParentConstraintChangeRemeasuresRetainedContent() throws { + #if !SKIP + throw XCTSkip("androidCompositionBoundary is Android-only") + #else + let height = State(initialValue: 80.0) + let counter = AndroidCompositionBoundaryBodyCounter() + + composeRule.setContent { + AndroidCompositionBoundaryLayoutHost( + height: height, + counter: counter + ) + .Compose() + } + composeRule.waitForIdle() + + let initialHost = composeRule.runOnIdle { + androidCompositionBoundaryHostView( + root: composeRule.activity.findViewById(android.R.id.content) + ) + } + let initialCount = counter.value("layout-child") + composeRule.onNodeWithTag("composition-boundary-layout-child") + .assertHeightIsEqualTo(80.0.dp) + + height.wrappedValue = 140.0 + composeRule.waitForIdle() + + let updatedHost = composeRule.runOnIdle { + androidCompositionBoundaryHostView( + root: composeRule.activity.findViewById(android.R.id.content) + ) + } + composeRule.onNodeWithTag("composition-boundary-layout-child") + .assertHeightIsEqualTo(140.0.dp) + XCTAssertTrue(updatedHost === initialHost) + XCTAssertEqual(counter.value("layout-child"), initialCount) + #endif + } +} + +#if SKIP +private final class AndroidCompositionBoundaryBodyCounter { + private var counts: [String: Int] = [:] + + @discardableResult + func increment(_ key: String) -> Int { + let next = (counts[key] ?? 0) + 1 + counts[key] = next + return next + } + + func value(_ key: String) -> Int { + counts[key] ?? 0 + } +} + +private struct AndroidCompositionBoundaryCountingContent: View { + let counterKey: String + let text: String + let tag: String + let counter: AndroidCompositionBoundaryBodyCounter + let fillsAvailableHeight: Bool + + var body: some View { + let _ = counter.increment(counterKey) + if fillsAvailableHeight { + Text(text) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier(tag) + } else { + Text(text) + .accessibilityIdentifier(tag) + } + } +} + +private struct AndroidCompositionBoundaryStableHost: View { + let parentTick: State + let counter: AndroidCompositionBoundaryBodyCounter + + var body: some View { + VStack { + Text("tick \(parentTick.wrappedValue)") + .accessibilityIdentifier("composition-boundary-parent") + AndroidCompositionBoundaryCountingContent( + counterKey: "stable-child", + text: "retained", + tag: "composition-boundary-stable-child", + counter: counter, + fillsAvailableHeight: false + ) + .androidCompositionBoundary(id: "stable-boundary") + } + } +} + +private struct AndroidCompositionBoundaryInputHost: View { + let childText: State + let counter: AndroidCompositionBoundaryBodyCounter + + var body: some View { + let text = childText.wrappedValue + AndroidCompositionBoundaryCountingContent( + counterKey: "input-child", + text: text, + tag: "composition-boundary-input-child", + counter: counter, + fillsAvailableHeight: false + ) + .androidCompositionBoundary(id: "input-boundary", inputs: text) + } +} + +private struct AndroidCompositionBoundaryEnvironmentHost: View { + let environmentValue: State + + var body: some View { + let value = environmentValue.wrappedValue + AndroidCompositionBoundaryEnvironmentContent() + .androidCompositionBoundary(id: "environment-boundary", inputs: value) + .environment(\.testValue, value) + } +} + +private struct AndroidCompositionBoundaryEnvironmentContent: View { + @Environment(\.testValue) var value: String + + var body: some View { + Text(value) + .accessibilityIdentifier("composition-boundary-environment-child") + } +} + +private struct AndroidCompositionBoundaryLayoutHost: View { + let height: State + let counter: AndroidCompositionBoundaryBodyCounter + + var body: some View { + AndroidCompositionBoundaryCountingContent( + counterKey: "layout-child", + text: "layout", + tag: "composition-boundary-layout-child", + counter: counter, + fillsAvailableHeight: true + ) + .androidCompositionBoundary(id: "layout-boundary") + .frame(width: 120.0, height: height.wrappedValue) + } +} +#endif diff --git a/Tests/SkipUITests/Skip/AndroidCompositionBoundaryTestSupport.kt b/Tests/SkipUITests/Skip/AndroidCompositionBoundaryTestSupport.kt new file mode 100644 index 00000000..45cbf854 --- /dev/null +++ b/Tests/SkipUITests/Skip/AndroidCompositionBoundaryTestSupport.kt @@ -0,0 +1,28 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +package skip.ui + +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView + +/** Returns the deepest ComposeView, which is the boundary host in these single-boundary tests. */ +internal fun androidCompositionBoundaryHostView(root: View): ComposeView { + var deepestHost: ComposeView? = null + var deepestLevel = -1 + + fun visit(view: View, level: Int) { + if (view is ComposeView && level > deepestLevel) { + deepestHost = view + deepestLevel = level + } + if (view is ViewGroup) { + for (index in 0 until view.childCount) { + visit(view.getChildAt(index), level + 1) + } + } + } + + visit(root, 0) + return requireNotNull(deepestHost) { "No ComposeView found in the activity hierarchy" } +}