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/Skip/AndroidCompositionBoundaryRoot.kt b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt new file mode 100644 index 00000000..c49ef063 --- /dev/null +++ b/Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt @@ -0,0 +1,33 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +package skip.ui + +import android.content.Context +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy + +/** + * 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 inherit composition locals without being rebuilt for unrelated sibling composition work. + */ +fun AndroidCompositionBoundaryComposeView( + context: Context, + parentCompositionContext: CompositionContext, + content: @Composable () -> Unit +): ComposeView { + return ComposeView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setParentCompositionContext(parentCompositionContext) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent(content) + } +} diff --git a/Sources/SkipUI/SkipUI/Animation/Animation.swift b/Sources/SkipUI/SkipUI/Animation/Animation.swift index edce08ad..d74a0cdf 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,13 @@ 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 { + pendingBridgedProvenanceAnimation = nil + StateTracking.clearReadCursor() } #endif } @@ -236,6 +245,7 @@ public struct Animation : Hashable { recentWithAnimationGeneration += 1 bridgedComposition = false bridgedProvenance = false + pendingBridgedProvenanceAnimation = nil bridgeFrameStack.set(nil) StateTracking.resetForTesting() } @@ -274,10 +284,19 @@ 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 } + if ambient == nil, animTx == nil, bridgedProvenance, let pendingAnimation { + ambient = pendingAnimation + } if ambient == nil, animTx == nil, bridgedComposition, !bridgedProvenance { // Legacy SkipFuseUI (no native provenance): the marker is the only signal. ambient = recentWithAnimationAnimation @@ -648,8 +667,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 { @@ -677,6 +699,7 @@ extension Float { @Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable { return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx) } + } extension Tuple2 where E0 == Float, E1 == Float { @@ -689,6 +712,7 @@ 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) } + } extension androidx.compose.ui.graphics.Color { 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. diff --git a/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift new file mode 100644 index 00000000..90c3cb56 --- /dev/null +++ b/Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift @@ -0,0 +1,135 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#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 +/// Gives a subtree its own retained identity and lifecycle on Android. +/// +/// 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. + // SKIP @bridge + public init(id: String, inputs: String = "", bridgedContent: any View) { + self.id = id + self.inputs = inputs + self.content = { bridgedContent } + self.bridgedProjectionLifecycle = nil + } + + /// Creates a lazy bridged boundary whose projection is scoped to one Compose instance. + /// + /// 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 = "", + bridgedProjectionLifecycle: @escaping (String, Bool) -> any View + ) { + self.id = id + self.inputs = inputs + 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: 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( + factory: { androidContext in + return AndroidCompositionBoundaryComposeView(context: androidContext, parentCompositionContext: parentCompositionContext) { + storage.content.Compose(context: childContext) + } + }, + modifier: context.modifier, + update: { composeView in + guard storage.inputs != inputs else { + return + } + storage.inputs = inputs + storage.content = preparedProjection ?? content() + composeView.setContent { + storage.content.Compose(context: childContext) + } + } + ) + } + } +} + +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 { + /// Gives this subtree its own retained identity and lifecycle on Android. + /// + /// 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 }) + #else + return self + #endif + } +} + +#endif 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 6899f2e9..d8f831fb 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -1009,16 +1009,17 @@ extension 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) + let proxy = GeometryProxy(globalFramePx: rect, density: density, safeArea: safeArea) let newValue = transform(proxy) - let oldValue = previousValue.value as? T + let oldValue = storage.previousValue as? T if oldValue == nil || oldValue != newValue { let effectiveOldValue = oldValue ?? newValue - previousValue.value = newValue + storage.previousValue = newValue SideEffect { action(effectiveOldValue, newValue) } } } @@ -1345,6 +1346,49 @@ 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 + } + @available(*, unavailable) public func sectionActions(@ViewBuilder content: () -> any View) -> some View { return self @@ -1802,6 +1846,12 @@ final class AnimatedBorderModifier: RenderModifier { } } +#if SKIP +final class GeometryChangeValueStorage { + var previousValue: Any? +} +#endif + #if SKIP final class AndroidVerticalOverscrollPullDownConnection: NestedScrollConnection { let isEnabled: () -> Bool diff --git a/Sources/SkipUI/SkipUI/View/EquatableView.swift b/Sources/SkipUI/SkipUI/View/EquatableView.swift index 8a8bd4c7..55c3c694 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,130 @@ 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, + areEqual: { lhs, rhs in lhs == rhs } + ) + } + for renderable in storage.renderables( + recomposeOverride: recomposeOverride, + contentFactory: { content }, + context: context, + options: 0 + ) { + renderable.Render(context: context) + } + } +} + +/// Retains bridged Android child content while its equality-preserving override remains equal. +// SKIP @bridge +public struct AndroidEquatableContent: View, Renderable { + 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: Any, 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, + areEqual: { lhs, rhs in lhs.equals(other: rhs) } + ) + } + for renderable in storage.renderables( + recomposeOverride: recomposeOverride, + contentFactory: content, + context: context, + options: 0 + ) { + renderable.Render(context: context) + } + } +} + +private final class AndroidEquatableStorage { + var recomposeOverride: RecomposeOverride + var renderables: kotlin.collections.List? + let areEqual: (RecomposeOverride, RecomposeOverride) -> Bool + + init( + recomposeOverride: RecomposeOverride, + areEqual: @escaping (RecomposeOverride, RecomposeOverride) -> Bool + ) { + self.recomposeOverride = recomposeOverride + self.areEqual = areEqual + } + + @Composable func renderables( + recomposeOverride: RecomposeOverride, + contentFactory: () -> any View, + context: ComposeContext, + options: Int + ) -> kotlin.collections.List { + if renderables == nil || !areEqual(self.recomposeOverride, recomposeOverride) { + self.recomposeOverride = recomposeOverride + self.renderables = contentFactory().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. + /// + /// 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) + #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. 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) + #else + return self + #endif + } +} + #endif diff --git a/Tests/SkipUITests/AndroidEquatableTests.swift b/Tests/SkipUITests/AndroidEquatableTests.swift new file mode 100644 index 00000000..321c89a4 --- /dev/null +++ b/Tests/SkipUITests/AndroidEquatableTests.swift @@ -0,0 +1,617 @@ +// 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") + 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"), + 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") + 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 + } + + 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 + } + + 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 +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 } + } + } +} + +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. 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