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
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ let package = Package(
.package(url: "https://github.com/skiptools/skip-bridge.git", "0.17.2"..<"2.0.0"),
.package(url: "https://github.com/skiptools/skip-android-bridge.git", "0.6.4"..<"2.0.0"),
.package(url: "https://github.com/skiptools/swift-jni.git", "0.5.0"..<"2.0.0"),
.package(url: "https://github.com/skiptools/skip-ui.git", from: "1.59.0"),
.package(
url: "https://github.com/tifroz/skip-ui.git",
branch: "experimental_animation-performance"
),
],
targets: [
.target(name: "SkipFuseUI", dependencies: ["SkipSwiftUI"]),
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,46 @@ When Swift code writes to a `@State` property, the `BridgedStateBox` notifies Co

`@Observable` types require `import SkipFuse` to enable this state tracking. See the [App Development](https://skip.dev/docs/app-development/#ui) guide for details.

### Android Recomposition Controls

Both modifiers can prevent unnecessary Android updates, but they provide different behavior. In SwiftUI terms, `androidEquatable` is an update gate, while `androidCompositionBoundary` gives a subtree its own retained identity and lifecycle.

| Modifier | SwiftUI mental model | Use when |
| --- | --- | --- |
| `androidEquatable` | Similar to `EquatableView`: while the supplied value remains equal, Skip does not reevaluate the wrapped content. The subtree keeps the identity and lifecycle provided by its existing parent. | The subtree can update as a unit and reevaluating it is expensive. |
| `androidCompositionBoundary` | Similar to placing the subtree in its own retained hosting container. Its `id` defines the container's identity and lifecycle. | The subtree needs an independently retained lifecycle, such as a WebView, map, or video surface. |

Use `androidEquatable` with a value containing every external input that affects the view:

```swift
ComplexMetadataPanel(movie: movie)
.androidEquatable(recomposeOverride: movie)
```

If `movie` remains equal, the panel is not reevaluated. If it changes, the panel is reevaluated inside its existing parent view hierarchy. This modifier does not create a new identity or lifecycle.

Use `androidCompositionBoundary` when the subtree should have its own retained identity and lifecycle:

```swift
BrowserView(url: url)
.androidCompositionBoundary(
id: "browser",
inputs: url.absoluteString
)
```

Think of `id` like SwiftUI view identity and `inputs` as the values used to update that identified view:

- Same `id`, same `inputs`: reuse the existing host without reevaluating or re-bridging its content.
- Same `id`, changed `inputs`: update the content inside the existing host, preserving its identity and lifecycle.
- Changed `id`, or removal from the hierarchy: dispose the old host and its state, then create a new one if needed.

State owned inside the boundary can update independently without changing `inputs`. On Apple platforms, `androidCompositionBoundary` returns the original view unchanged.

The boundary inherits the current Compose composition locals, including bridged SwiftUI 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. Resizing native-backed content therefore does not recreate or re-bridge it, but it still changes the native view's measured bounds.

Avoid stacking both modifiers around the same subtree without a measured need. An unchanged composition boundary already prevents its child projection from being reevaluated and re-bridged.

## What SkipFuseUI Covers

SkipFuseUI mirrors the SwiftUI API surface for iOS 16+, including:
Expand All @@ -126,6 +166,8 @@ SkipFuseUI mirrors the SwiftUI API surface for iOS 16+, including:

For the full list of supported SwiftUI components, see the [SkipUI documentation](https://skip.dev/docs/modules/skip-ui/#supported-swiftui).

`simultaneousGesture` support follows SkipUI's current Android limitations: it can observe supported gestures on the same rendered view, including drag observation while scroll views continue scrolling, but only `.all` and `.none` have meaningful mask behavior. `.gesture` and `.subviews` masks are not distinguished.

### Text Selection

SkipFuseUI supports programmatic text selection for editable text controls that expose a `TextSelection` binding, such as `TextField` and `TextEditor`.
Expand Down
35 changes: 35 additions & 0 deletions Sources/SkipFuseUI/SwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,39 @@
@_exported import SwiftUI
@_exported import struct SkipSwiftUI.TextSelectionIndex
@_exported import struct SkipSwiftUI.TextSelectionRange

extension View where Self: Equatable {
/// Returns this view unchanged on Apple platforms.
///
/// On Android, the matching `SkipSwiftUI` API skips reevaluating the view while its value
/// remains equal.
nonisolated public func androidEquatable() -> some View {
self
}
}

extension View {
/// Returns this view unchanged on Apple platforms.
///
/// On Android, the matching `SkipSwiftUI` API skips reevaluating the view while
/// `recomposeOverride` remains equal.
nonisolated public func androidEquatable<RecomposeOverride: Equatable>(
recomposeOverride: RecomposeOverride
) -> some View {
self
}

/// Returns this view unchanged on Apple platforms.
///
/// On Android, the matching `SkipSwiftUI` API gives the subtree its own retained identity and
/// lifecycle. Keeping `id` stable preserves that host; changing `inputs` updates its content
/// without replacing it. This no-op counterpart lets shared source call the modifier without
/// platform conditionals.
nonisolated public func androidCompositionBoundary(
id: String,
inputs: String = ""
) -> some View {
self
}
}
#endif
128 changes: 128 additions & 0 deletions Sources/SkipSwiftUI/Fuse/AndroidCompositionBoundary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright 2026 Skip
// SPDX-License-Identifier: MPL-2.0
import Foundation
import SkipUI

/// Retains one projected child per live Compose boundary instance and input value.
internal final class AndroidCompositionBoundaryProjectionRegistry<Projection>: @unchecked Sendable {
private struct Entry {
let inputs: String
let projection: Projection
}

private let lock = NSLock()
private var entries: [String: Entry] = [:]

/// Reuses the installed projection while a boundary's explicit inputs are unchanged.
internal func prepare(
instanceID: String,
inputs: String,
create: () -> Projection
) -> Projection {
lock.lock()
if let entry = entries[instanceID], entry.inputs == inputs {
lock.unlock()
return entry.projection
}
lock.unlock()

// Projection can bridge a large view graph, so do that work outside the registry lock.
let projection = create()
lock.lock()
defer { lock.unlock() }
if let entry = entries[instanceID], entry.inputs == inputs {
return entry.projection
}
let entry = Entry(inputs: inputs, projection: projection)
entries[instanceID] = entry
return entry.projection
}

/// Releases a projection when the owning Compose boundary leaves the hierarchy.
internal func release(instanceID: String) {
lock.lock()
defer { lock.unlock() }
entries.removeValue(forKey: instanceID)
}
}

/// Owns a projection factory only until SkipUI prepares the current Compose instance.
internal final class AndroidCompositionBoundaryProjectionSource<Projection>: @unchecked Sendable {
private let lock = NSLock()
private var create: (() -> Projection)?

internal init(create: @escaping () -> Projection) {
self.create = create
}

/// Prepares the instance projection, then drops the captured native view graph.
internal func prepare(
in registry: AndroidCompositionBoundaryProjectionRegistry<Projection>,
instanceID: String,
inputs: String
) -> Projection {
lock.lock()
let create = self.create
lock.unlock()

let prepared = registry.prepare(instanceID: instanceID, inputs: inputs) {
guard let create else {
preconditionFailure("Missing projection source for changed boundary inputs")
}
return create()
}

lock.lock()
self.create = nil
lock.unlock()
return prepared
}
}

#if os(Android)
private let androidCompositionBoundaryProjectionRegistry =
AndroidCompositionBoundaryProjectionRegistry<any SkipUI.View>()
#endif

extension View {
/// Gives this 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. State owned inside the boundary can update
/// independently without changing `inputs`. The boundary inherits environment values and is
/// transparent to parent measurement: new constraints remeasure the same retained host and
/// child rather than changing their identity.
///
/// Use this for content such as a WebView, map, or video surface that needs an independently
/// retained lifecycle. To only skip reevaluating ordinary UI while a value remains equal, use
/// `androidEquatable(recomposeOverride:)`.
///
/// Non-Android platforms return the original view.
nonisolated public func androidCompositionBoundary(id: String, inputs: String = "") -> some View {
#if os(Android)
return ModifierView(target: self) { target in
let projectionSource = AndroidCompositionBoundaryProjectionSource {
target.Java_viewOrEmpty
}
return SkipUI.AndroidCompositionBoundary(
id: id,
inputs: inputs,
bridgedProjectionLifecycle: { instanceID, isDisposing in
if isDisposing {
androidCompositionBoundaryProjectionRegistry.release(instanceID: instanceID)
return SkipUI.EmptyView()
}
return projectionSource.prepare(
in: androidCompositionBoundaryProjectionRegistry,
instanceID: instanceID,
inputs: inputs
)
}
)
}
#else
return self
#endif
}
}
22 changes: 22 additions & 0 deletions Sources/SkipSwiftUI/View/AdditionalViewModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ extension View {
return $0.opacity(opacity)
}
}

}

extension View {
Expand Down Expand Up @@ -543,6 +544,27 @@ extension View {
return $0.scaleEffect(x: x, y: y, anchorX: anchor.x, anchorY: anchor.y)
}
}

/// Applies scale and translation in one Android compositor layer without relocating layout.
nonisolated public func compositorTransform(
scaleX: CGFloat,
scaleY: CGFloat,
translationX: CGFloat,
translationY: CGFloat,
anchor: UnitPoint = .center
) -> some View {
return ModifierView(animatableTarget: self) {
return $0.compositorTransform(
scaleX: scaleX,
scaleY: scaleY,
translationX: translationX,
translationY: translationY,
anchorX: anchor.x,
anchorY: anchor.y
)
}
}

}

extension View {
Expand Down
61 changes: 61 additions & 0 deletions Sources/SkipSwiftUI/View/EquatableView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright 2025–2026 Skip
// SPDX-License-Identifier: MPL-2.0
import SkipBridge
import SkipUI

@frozen @preconcurrency public struct EquatableView<Content> where Content : Equatable, Content : View {
Expand All @@ -25,3 +26,63 @@ extension View where Self : Equatable {
return EquatableView(content: self)
}
}

/// Skips reevaluating Android content while its comparison value remains equal.
///
/// Like SwiftUI's `EquatableView`, this is an update gate within the existing parent view hierarchy.
/// It does not give the subtree a new identity or lifecycle. Use
/// `androidCompositionBoundary(id:inputs:)` when the subtree needs an independently retained one.
@frozen @preconcurrency public struct AndroidEquatableView<Content, RecomposeOverride> where Content : View, RecomposeOverride : Equatable {
public var content: Content
public var recomposeOverride: RecomposeOverride

/// Creates a wrapper that reuses evaluated renderables while `recomposeOverride` remains equal.
@inlinable public init(content: Content, recomposeOverride: RecomposeOverride) {
self.content = content
self.recomposeOverride = recomposeOverride
}
}

extension AndroidEquatableView : View {
public typealias Body = Never
}

extension AndroidEquatableView : SkipUIBridging {
/// Preserves the override's Swift `Equatable` implementation across the JVM bridge.
internal var Java_recomposeOverride: SwiftEquatable {
return Java_swiftEquatable(for: recomposeOverride)
}

public var Java_view: any SkipUI.View {
#if os(Android)
return SkipUI.AndroidEquatableContent(
recomposeOverride: Java_recomposeOverride,
bridgedContentFactory: { content.Java_viewOrEmpty }
)
#else
return content.Java_viewOrEmpty
#endif
}
}

extension View where Self : Equatable {
/// On Android, reuses this view's evaluated content inside the parent composition while the
/// view value remains equal.
nonisolated public func androidEquatable() -> AndroidEquatableView<Self, Self> {
return AndroidEquatableView(content: self, recomposeOverride: self)
}
}

extension View {
/// On Android, skips reevaluating this content while `recomposeOverride` remains equal.
///
/// Include every body-affecting external value in `recomposeOverride`; unchanged values skip
/// parent-driven body evaluation for this subtree. This does not create new view identity or a
/// separate lifecycle. Use `androidCompositionBoundary(id:inputs:)` when the subtree needs an
/// independently retained identity and lifecycle.
nonisolated public func androidEquatable<RecomposeOverride : Equatable>(
recomposeOverride: RecomposeOverride
) -> AndroidEquatableView<Self, RecomposeOverride> {
return AndroidEquatableView(content: self, recomposeOverride: recomposeOverride)
}
}
26 changes: 26 additions & 0 deletions Sources/SkipSwiftUISamples/Fixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,29 @@ public struct ConditionalContentTestFixture: View {
}
}
}

/// Verifies that `androidEquatable` preserves full Swift equality when an `Identifiable` value
/// changes without changing its ID.
public struct AndroidEquatableIdentityTestFixture: View {
struct Model: Identifiable, Equatable {
let id: Int
var title: String
}

@State var model = Model(id: 1, title: "A")

public init() {
}

public var body: some View {
VStack {
Text(model.title)
.accessibilityIdentifier("android-equatable-identity-title")
.androidEquatable(recomposeOverride: model)
Button("update title") {
model.title = "B"
}
.accessibilityIdentifier("android-equatable-identity-update")
}
}
}
Loading
Loading