Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions Sources/SkipUI/Skip/AndroidCompositionBoundaryRoot.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
28 changes: 26 additions & 2 deletions Sources/SkipUI/SkipUI/Animation/Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -236,6 +245,7 @@ public struct Animation : Hashable {
recentWithAnimationGeneration += 1
bridgedComposition = false
bridgedProvenance = false
pendingBridgedProvenanceAnimation = nil
bridgeFrameStack.set(nil)
StateTracking.resetForTesting()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -648,8 +667,11 @@ public enum AnimationCompletionCriteria : Hashable {
let resetValue = rememberSaveable(stateSaver: context.stateSaver as Saver<T?, Any>) { mutableStateOf<T?>(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 {
Expand Down Expand Up @@ -677,6 +699,7 @@ extension Float {
@Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable<Float, AnimationVector1D> {
return toAnimatable(value: self, converter: TwoWayConverter({ AnimationVector1D($0) }, { $0.value }), context: context, animTx: animTx)
}

}

extension Tuple2 where E0 == Float, E1 == Float {
Expand All @@ -689,6 +712,7 @@ extension Tuple2 where E0 == Float, E1 == Float {
@Composable func asAnimatable(context: ComposeContext, animTx: StateMutationTransaction?) -> Animatable<Tuple2<Float, Float>, 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 {
Expand Down
5 changes: 5 additions & 0 deletions Sources/SkipUI/SkipUI/Components/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
135 changes: 135 additions & 0 deletions Sources/SkipUI/SkipUI/Compose/AndroidCompositionBoundary.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading