diff --git a/.github/workflows/flare-ui.yml b/.github/workflows/flare-ui.yml new file mode 100644 index 0000000000..e108dceaa5 --- /dev/null +++ b/.github/workflows/flare-ui.yml @@ -0,0 +1,97 @@ +name: Flare UI CI + +on: + push: + branches: + - master + - release + - develop + paths: + - "flareUI/**" + - ".github/workflows/flare-ui.yml" + pull_request: + branches: + - master + - release + - develop + paths: + - "flareUI/**" + - ".github/workflows/flare-ui.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + apple: + runs-on: [macos-26] + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + distribution: "jetbrains" + java-version: 25 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Run Flare UI checks + run: ./gradlew -p flareUI check + + - name: Generate demo project + run: xcodegen generate --spec flareUI/demo/appleApp/project.yml + + - name: Test AppKit demo layout + run: | + xcodebuild test \ + -project flareUI/demo/appleApp/FlareUIDemo.xcodeproj \ + -scheme FlareUIDemo-macOS-Tests \ + -destination "platform=macOS,arch=arm64" \ + -derivedDataPath "$RUNNER_TEMP/flare-ui-macos-tests" \ + CODE_SIGNING_ALLOWED=NO + + - name: Test AppKit demo launch + run: | + xcodebuild test \ + -project flareUI/demo/appleApp/FlareUIDemo.xcodeproj \ + -scheme FlareUIDemo-macOS-UITests \ + -destination "platform=macOS,arch=arm64" \ + -derivedDataPath "$RUNNER_TEMP/flare-ui-macos-ui-tests" + + - name: Test UIKit demo navigation + run: | + destination_id="$( + xcrun simctl list devices available | \ + sed -nE 's/.*iPhone[^\(]*\(([0-9A-F-]{36})\).*/\1/p' | \ + head -n 1 + )" + test -n "$destination_id" + xcodebuild test \ + -project flareUI/demo/appleApp/FlareUIDemo.xcodeproj \ + -scheme FlareUIDemo-iOS \ + -destination "id=$destination_id" \ + -derivedDataPath "$RUNNER_TEMP/flare-ui-ios-demo" \ + -only-testing:iOSDemoUITests/FlareUIDemoNavigationTests + + - name: Build AppKit demo + run: | + xcodebuild build \ + -project flareUI/demo/appleApp/FlareUIDemo.xcodeproj \ + -scheme FlareUIDemo-macOS \ + -destination "platform=macOS,arch=arm64" \ + -derivedDataPath "$RUNNER_TEMP/flare-ui-macos-demo" + + codesign --verify --deep --strict --verbose=2 \ + "$RUNNER_TEMP/flare-ui-macos-demo/Build/Products/Debug/Flare UI Demo.app" diff --git a/.gitignore b/.gitignore index 8090a4fb91..19e482e3e5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,14 @@ local.properties signing.properties build/ +.build/ +.swiftpm/ */Podfile.lock */Pods/* */Flare.xcworkspace/* */Flare.xcodeproj/* +/flareUI/demo/appleApp/FlareUIDemo.xcodeproj/ +/flareUI/benchmark/apple/FlareUIAppleBenchmark.xcodeproj/ shared/shared.podspec .kotlin .history diff --git a/apple-shared/build.gradle.kts b/apple-shared/build.gradle.kts index 118b8e2541..1978d6a22d 100644 --- a/apple-shared/build.gradle.kts +++ b/apple-shared/build.gradle.kts @@ -51,7 +51,6 @@ kotlin { commonExportedProjects.forEach { exportedProject -> export(exportedProject) } - if (appleTarget.name.startsWith("ios")) { export(projects.social.nostr) } diff --git a/flareUI/ARCHITECTURE.md b/flareUI/ARCHITECTURE.md new file mode 100644 index 0000000000..a111e4f28d --- /dev/null +++ b/flareUI/ARCHITECTURE.md @@ -0,0 +1,198 @@ +# Flare UI runtime architecture + +## Product definition + +Flare UI is a Kotlin Multiplatform runtime with a Compose Runtime authoring frontend and four +backends: Android View, Jetpack Compose UI, UIKit, and AppKit. + +```text +shared @Composable content + | + v +Compose Runtime + FlareApplier + | + v +typed FlareWidgetSystem + | + +-- Android View + +-- Jetpack Compose UI + +-- UIKit + +-- AppKit +``` + +Compose Runtime owns reconciliation. Native backends apply structural operations directly to their +platform hierarchy. The Compose backend applies them to observable widget state whose `Render` +functions emit Compose UI nodes. + +## Runtime invariants + +### Bottom-up insertion and disposal + +A primitive receives its initial properties and descendants before entering its backend parent. +Removal detaches the child, disposes descendants, and then disposes the widget. + +```text +create -> update -> create descendants -> insert +remove -> detach -> dispose descendants -> dispose widget +``` + +### One child container + +A widget is either a leaf or exposes one `FlareChildren` container through `FlareWidget.children`. +This matches every current primitive and keeps the applier tree identical to the backend widget +tree. Multiple named slots should be introduced only when a real primitive requires them. + +### Typed updates and identity + +Each primitive has a widget interface. Its Kotlin `KClass` connects the composable emitter, +renderer registration, and native factory. Generated schemas, string IDs, and custom component +tokens are absent. + +```kotlin +interface TextWidget : FlareWidget { + fun setText(value: String) +} + +@Composable +@FlareUiComposable +fun Text(text: String) { + EmitFlareWidget( + componentType = TextWidget::class, + update = { set(text, TextWidget::setText) }, + ) +} +``` + +### Renderer registration + +`FlareWidgetSystem` is an immutable map of `KClass` keys to `(B) -> FlareWidget` factories. +`FlareRendererPlugin` groups registrations while keeping backend mismatches as compile errors. +The host supplies its backend when creating a widget, so a reusable widget system cannot retain an +Activity or native view hierarchy. + +Registration is handwritten. This keeps the build free of KSP and makes primitive API changes +ordinary source edits while the component set is small. + +### Backend hierarchy operations + +Android View, UIKit, and AppKit apply insert, move, and remove operations directly with platform +APIs. Android suppresses root layout during a Compose apply transaction where supported. UIKit and +AppKit rely on their native stack-view operations and do not keep shadow child lists. + +Foundation's stacks have one backend-independent contract. Children wrap content, main-axis +placement begins at the start, `Column` controls horizontal alignment, and `Row` controls vertical +alignment. Spacing uses dp on Android and points on Apple platforms. Android View explicitly avoids +`LinearLayout`'s vertical-container `MATCH_PARENT` child default so alignment matches Compose, +UIKit, and AppKit. Both Android renderers use Material 3 text and button primitives; native Apple +renderers retain UIKit/AppKit controls. + +Jetpack Compose stores renderer widgets in a `mutableStateListOf`. Each widget exposes a +`@UiComposable Render` function and keeps changed properties in snapshot state. `AndroidCompose` +is the escape hatch for Android-only components which already provide a Compose API. + +### Scheduling + +Android View uses the Choreographer-backed `MonotonicFrameClock` supplied by +`AndroidUiDispatcher.Main`. + +The Compose host inherits the surrounding composition through `rememberCompositionContext()`, so +it does not create another Recomposer, snapshot observer, or frame clock. + +UIKit and AppKit share the Apple recomposer lifecycle but keep platform display clocks: + +- iOS requests an on-demand `CADisplayLink` frame. +- macOS requests an on-demand `CVDisplayLink` frame. +- Darwin frame timestamps use `CLOCK_MONOTONIC_RAW`. + +The display clocks are process-scoped and sleep without frame awaiters. Apple hosts share a pooled +recomposer and its one snapshot observer. + +### Host lifecycle + +Android View, UIKit, and AppKit hosts create a composition only while attached to a window. They +retain declarative content for reattachment. The Compose host owns its child Flare composition with +`DisposableEffect`. Every host disposes composition nodes and callbacks when it leaves its owner. + +`FlareWidget.dispose` is the single widget cleanup hook. + +### Deferred item compositions and lazy collections + +`rememberFlareSubcompositionFactory` creates independently disposable child compositions which +inherit the active widget system and parent composition context. The factory owns every child and +disposes it with the parent; a native lazy cell owns one child composition while realized. + +`flare-lazy-layout` records interval providers without composing item content. Stable keys drive +identity, dataset diffing, saveable item state, and visible-anchor restoration; `contentType` +drives native reuse compatibility. `layoutVersion` explicitly invalidates a stable key's cached +measurement when off-screen layout-affecting data changes. One shared coordinator serializes item +binding, disposal, viewport reports, and programmatic scroll commands. + +Android uses RecyclerView or Compose LazyList. UIKit and AppKit keep native scrolling but use a +shared sparse variable-extent index: unknown items have an internal estimate, realized items are +measured from intrinsic content, and one correction updates prefix geometry in O(log n). Exact +measurements follow stable keys across model updates, content-type medians improve cold estimates, +and child-composition apply transactions invalidate visible geometry. Apple adapters realize only +the viewport plus bounded overscan and recycle item hosts by `contentType`. `LazyColumn` and +`LazyRow` differ only by orientation. + +## Modules + +| Module | Responsibility | +| --- | --- | +| `flare-runtime` | Runtime contracts, applier, lifecycle, Android View/Compose/UIKit/AppKit hosts, frame clocks | +| `foundation` | Four common primitives and their four renderer sets | +| `flare-lazy-layout` | Lazy DSL/state/coordinator and four native virtual-list adapters | +| `flare-resources-moko` | Optional Moko resource environment, backend-neutral image value, and image renderers | +| `demo/shared` | Shared composition and framework exports | +| `demo/androidApp`, `demo/appleApp` | Native application shells | + +`flare-runtime` does not depend on Foundation. Foundation depends one-way on runtime, and lazy +layout depends on Foundation for shared alignment vocabulary. A new host belongs in runtime; a +renderer for a Foundation primitive belongs in Foundation. Moko integration is separate because +it is an optional dependency and resource-generation boundary. + +## Resources + +The runtime and Foundation do not own localization or assets. The optional `flare-resources-moko` +module provides one composition-local resolver plus Compose-style `stringResource`, +`pluralStringResource`, and `imageResource` functions. Strings become ordinary `String` values, so +components need no resource overloads. Images become an opaque `FlareImage`; the optional module's +`ResourceImage` primitive demonstrates all four renderer plugins. + +The consuming application owns the generated resource catalog. Android resolvers use the host +`Context`; UIKit/AppKit resolvers use Moko's localized bundle lookup. Static Apple frameworks copy +their generated bundle into the application during an Xcode build phase. + +## Verification gates + +- Common tests cover direct native-tree construction, updates, duplicate registration, identity, + recomposition, and bottom-up disposal. +- Robolectric smoke coverage verifies Android View rendering and in-place recomposition. +- A Compose UI smoke test covers Foundation rendering, events, and `AndroidCompose` content. +- Native UIKit and AppKit tests cover modifiers and direct hierarchy operations. +- macOS tests request real display-link frames and verify monotonic timestamps. +- Resource tests cover resolver injection, Android View/Compose rendering, Apple localization, + plurals, image loading, and static-framework bundle packaging. +- Lazy tests cover large logical datasets with bounded realization, both orientations on all four + renderers, stable-key updates/anchors, heterogeneous dimensions, saveable state, multi-root + items, viewport reporting, programmatic scrolling, and child-composition disposal. +- The shared framework and UIKit/AppKit demo applications compile through Xcode. + +Performance benchmark matrices were removed until a real product screen and regression budget +exist. + +## Deliberately deferred + +Flare UI is not production complete. The next gates are: + +1. Constraints/measure/place layout beyond native stack containers. +2. Density, layout direction, safe area, and theme environments. +3. Accessibility semantics and focus. +4. Text input with selection and IME composition synchronization. +5. Screen-specific macOS display-link selection and multi-display validation. +6. Intel macOS support if a compatible Compose Runtime artifact is available. +7. Publication and Kotlin API/binary compatibility validation. +8. Product-screen validation of the Compose backend's extra state invalidation hop. +9. Product-screen migration, stability hardening, and performance budgets. + +Navigation, networking, and application state management remain outside the runtime. diff --git a/flareUI/README.md b/flareUI/README.md new file mode 100644 index 0000000000..8c3ad8d981 --- /dev/null +++ b/flareUI/README.md @@ -0,0 +1,241 @@ +# Flare UI + +Flare UI is a small Kotlin Multiplatform UI runtime. Shared `@Composable` functions use Compose +Runtime for reconciliation and render through a selected platform backend. + +- Android renders Android Views or Jetpack Compose UI. +- iOS renders UIKit views. +- macOS renders AppKit views. +- There is no SwiftUI backend, schema, or reflection. Core UI modules use no code generation. + +See [`ARCHITECTURE.md`](ARCHITECTURE.md) for runtime invariants and the remaining production work. + +## Modules + +| Module | Responsibility | +| --- | --- | +| `flare-runtime` | Composition, applier, modifiers, renderer registry, Android hosts, and Apple frame clocks | +| `foundation` | `Column`, `Row`, `Text`, and `NativeButton` plus Android View/Compose/UIKit/AppKit renderers | +| `flare-lazy-layout` | Stable-key `LazyColumn`/`LazyRow`, state, diffing, and four native virtual-list renderers | +| `flare-resources-moko` | Optional Moko `stringResource`, `pluralStringResource`, `imageResource`, and image renderers | +| `demo/shared` | One shared demo composition and native host factories | +| `demo/androidApp`, `demo/appleApp` | Thin Android, UIKit, and AppKit application shells | + +The Apple applications link the Kotlin/Native framework directly. The current Compose Runtime +dependency publishes the required macOS artifact for arm64, so AppKit currently targets Apple +Silicon Macs. + +## Host usage + +Android View: + +```kotlin +FlareAndroidViewHost( + context = context, + widgetSystem = createAndroidWidgetSystem(), +).apply { + setContent { + Text("Hello") + } +} +``` + +Jetpack Compose: + +```kotlin +FlareComposeHost(createAndroidComposeWidgetSystem()) { + Column { + Text("Hello") + AndroidCompose { + ExistingComposeOnlyComponent() + } + } +} +``` + +UIKit: + +```kotlin +FlareUIKitHost(createUIKitWidgetSystem()).setContent { + Text("Hello") +} +``` + +AppKit: + +```kotlin +FlareAppKitHost(createAppKitWidgetSystem()).setContent { + Text("Hello") +} +``` + +The Compose backend keeps lightweight Flare widget state and emits real Compose UI nodes. It uses +the surrounding Compose composition's Recomposer and frame clock. Both Android renderer sets use +Material 3 controls: the View host must receive a Material 3-themed `Context`, and the Compose host +must run below `androidx.compose.material3.MaterialTheme`. + +## Layout semantics + +`Column` and `Row` use one shared stack-layout contract on every backend. Children wrap their +content by default, main-axis placement starts at the beginning, and `spacing` is expressed in +logical platform units (dp on Android and points on Apple platforms). Cross-axis alignment is +explicit: + +```kotlin +Column( + spacing = 12f, + horizontalAlignment = HorizontalAlignment.Start, +) { + Text("Title") + Row( + spacing = 8f, + verticalAlignment = VerticalAlignment.Center, + ) { + NativeButton(label = "Cancel", onClick = ::cancel) + NativeButton(label = "Save", onClick = ::save) + } +} +``` + +`Column` supports `Start`, `Center`, `End`, and `Stretch`; `Row` supports `Top`, `Center`, `Bottom`, +and `Stretch`. `FlareModifier` supports wrap (the default), fill, and fixed width/height in dp on +Android or points on Apple. Text wraps on all four renderer paths. Padding and main-axis +arrangement remain outside the current Foundation API. + +## Lazy collections + +`flare-lazy-layout` keeps item declarations lightweight. Android delegates virtualization to +`RecyclerView` or Compose `LazyColumn`/`LazyRow`; Apple uses native `UIScrollView`/`NSScrollView` +adapters backed by a shared variable-extent index and viewport-bound item recycling: + +```kotlin +val state = rememberLazyListState() + +LazyColumn( + modifier = FlareModifier.None.fillMaxWidth().height(320f), + state = state, + spacing = 8f, +) { + item(key = "header", contentType = "header") { + Text("Timeline") + } + items( + items = posts, + key = Post::id, + contentType = Post::kind, + layoutVersion = Post::layoutRevision, + ) { post -> + PostRow(post) + } +} +``` + +Keys are required, unique, and stable across updates. They preserve item identity, keyed +`rememberSaveable` state, and the visible anchor during prepend/reorder. `contentType` selects a +compatible native reuse pool and improves the estimate for not-yet-measured items; it is not +identity. Item size is measured from content and may vary freely. A visible item's geometry is +invalidated after its child composition changes. Use `layoutVersion` when layout-affecting data can +change under the same key while the item is off-screen; it invalidates only that key's cached +measurement and does not impose a fixed size. + +A lazy list needs a bounded main-axis viewport, normally supplied by its parent or a fixed/fill +modifier. `LazyListState` exposes visible-item layout information plus immediate and animated +index/offset scrolling. + +Install the matching optional renderer plugin in the host widget system, for example +`AndroidViewLazyLayoutRendererPlugin`, `AndroidComposeLazyLayoutRendererPlugin`, +`UIKitLazyLayoutRendererPlugin`, or `AppKitLazyLayoutRendererPlugin`. + +## Define a primitive + +Primitive APIs and renderer contracts are ordinary Kotlin: + +```kotlin +interface StatusWidget : FlareWidget { + fun setText(value: String) +} + +@OptIn(LowLevelFlareApi::class) +@Composable +@FlareUiComposable +fun Status(text: String) { + EmitFlareWidget( + componentType = StatusWidget::class, + update = { + set(text, StatusWidget::setText) + }, + ) +} +``` + +Each platform implements `StatusWidget` and registers its factory in a typed plugin: + +```kotlin +object AndroidStatusPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(StatusWidget::class) { backend -> + AndroidStatusWidget(backend.context) + } + } +} +``` + +Applications pass optional plugins to `createAndroidWidgetSystem`, +`createAndroidComposeWidgetSystem`, `createUIKitWidgetSystem`, or `createAppKitWidgetSystem`. +Duplicate registrations fail when the widget system is created. + +## Resources and localization + +Foundation stays resource-agnostic: its APIs continue to accept plain `String` values. Applications +that use Moko Resources can add `flare-resources-moko`, apply Moko's generator plugin in the module +which owns the resource catalog, and resolve values at the call site: + +```kotlin +ProvideMokoResources(platformResolver) { + Text(stringResource(AppRes.strings.title)) + Text(pluralStringResource(AppRes.plurals.items, itemCount, itemCount)) + ResourceImage( + image = imageResource(AppRes.images.logo), + contentDescription = stringResource(AppRes.strings.logo_description), + ) +} +``` + +The host installs the matching optional image renderer plugin, for example +`AndroidViewMokoResourcesRendererPlugin` or `UIKitMokoResourcesRendererPlugin`. Android uses +`AndroidMokoResourceResolver(context)`; UIKit and AppKit use `AppleMokoResourceResolver`. + +For Moko itself, the adapter uses only the base `resources` artifact (not +`resources-compose`) and does not depend on Flare's `foundation` module. Generated `AppRes`/`MR` +classes and localization files +belong to the consuming application. Static Apple frameworks must run Moko's +`copyFrameworkResourcesToApp` build phase; `demo/appleApp/project.yml` contains a working example. + +## Verify + +Run all checks: + +```shell +./gradlew -p flareUI check +``` + +Build the Android demo: + +```shell +./gradlew -p flareUI :demo:androidApp:assembleDebug +``` + +Generate and build the UIKit/AppKit demo project: + +```shell +xcodegen generate --spec flareUI/demo/appleApp/project.yml +xcodebuild -project flareUI/demo/appleApp/FlareUIDemo.xcodeproj \ + -scheme FlareUIDemo-macOS-Tests -destination 'platform=macOS,arch=arm64' \ + CODE_SIGNING_ALLOWED=NO test +xcodebuild -project flareUI/demo/appleApp/FlareUIDemo.xcodeproj \ + -scheme FlareUIDemo-iOS -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project flareUI/demo/appleApp/FlareUIDemo.xcodeproj \ + -scheme FlareUIDemo-macOS -destination 'platform=macOS,arch=arm64' \ + CODE_SIGNING_ALLOWED=NO build +``` diff --git a/flareUI/build-logic/build.gradle.kts b/flareUI/build-logic/build.gradle.kts new file mode 100644 index 0000000000..1b35c93284 --- /dev/null +++ b/flareUI/build-logic/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + `kotlin-dsl` + `java-gradle-plugin` +} + +repositories { + google() + mavenCentral() + gradlePluginPortal() +} + +dependencies { + compileOnly("com.android.tools.build:gradle:9.3.0") + compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10") + implementation("org.jlleitschuh.gradle:ktlint-gradle:14.2.0") +} + +kotlin { + jvmToolchain(25) +} + +gradlePlugin { + plugins { + create("flareUiMultiplatformLibrary") { + id = "dev.dimension.flareui.multiplatform-library" + implementationClass = "dev.dimension.flareui.buildlogic.FlareUiMultiplatformLibraryPlugin" + } + create("flareUiRootConventions") { + id = "dev.dimension.flareui.root-conventions" + implementationClass = "dev.dimension.flareui.buildlogic.FlareUiRootConventionsPlugin" + } + } +} diff --git a/flareUI/build-logic/settings.gradle.kts b/flareUI/build-logic/settings.gradle.kts new file mode 100644 index 0000000000..d659256e80 --- /dev/null +++ b/flareUI/build-logic/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "flare-ui-build-logic" diff --git a/flareUI/build-logic/src/main/kotlin/dev/dimension/flareui/buildlogic/FlareUiConventionSupport.kt b/flareUI/build-logic/src/main/kotlin/dev/dimension/flareui/buildlogic/FlareUiConventionSupport.kt new file mode 100644 index 0000000000..2e9ad9d8f1 --- /dev/null +++ b/flareUI/build-logic/src/main/kotlin/dev/dimension/flareui/buildlogic/FlareUiConventionSupport.kt @@ -0,0 +1,117 @@ +package dev.dimension.flareui.buildlogic + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByName +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jlleitschuh.gradle.ktlint.KtlintExtension +import org.jlleitschuh.gradle.ktlint.KtlintPlugin + +public enum class FlareUiPlatform { + ANDROID, + JVM, + IOS, + MACOS, +} + +public class FlareUiMultiplatformLibraryPlugin : Plugin { + override fun apply(target: Project): Unit = Unit +} + +public class FlareUiRootConventionsPlugin : Plugin { + override fun apply(target: Project) { + target.subprojects.forEach { subproject -> + subproject.pluginManager.apply(KtlintPlugin::class.java) + subproject.extensions.configure { + version.set("1.8.0") + filter { + exclude { element -> + element.file.path.contains("build", ignoreCase = true) + } + } + } + } + } +} + +public class FlareUiModuleSpec internal constructor( + private val kotlin: KotlinMultiplatformExtension, +) { + public var namespace: String? = null + + private val platforms = linkedSetOf() + public fun platforms(vararg values: FlareUiPlatform) { + platforms.clear() + platforms.addAll(values) + } + + internal fun apply() { + require(platforms.isNotEmpty()) { + "flareUi { } requires at least one platform." + } + + kotlin.explicitApi() + + kotlin.applyDefaultHierarchyTemplate() + + if (FlareUiPlatform.ANDROID in platforms) { + kotlin.targets.getByName("android") { + compileSdk { + version = release(project.intVersion("compileSdk")) { + minorApiLevel = 0 + } + } + this.namespace = this@FlareUiModuleSpec.namespace + minSdk { + version = release(project.intVersion("minSdk")) + } + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(project.intVersion("java").toString())) + } + } + } + if (FlareUiPlatform.JVM in platforms) kotlin.jvm() + if (FlareUiPlatform.IOS in platforms) { + kotlin.iosArm64() + kotlin.iosSimulatorArm64() + } + if (FlareUiPlatform.MACOS in platforms) { + kotlin.macosArm64() + } + kotlin.compilerOptions { + allWarningsAsErrors.set(true) + freeCompilerArgs.addAll( + "-Xexpect-actual-classes", + "-Xconsistent-data-class-copy-visibility", + ) + optIn.addAll( + "kotlin.time.ExperimentalTime", + "kotlin.experimental.ExperimentalObjCRefinement", + ) + } + kotlin.jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(kotlin.project.intVersion("java"))) + } + } +} + +public fun KotlinMultiplatformExtension.flareUi( + configure: FlareUiModuleSpec.() -> Unit, +) { + FlareUiModuleSpec(this).apply(configure).apply() +} + +private fun Project.intVersion(name: String): Int = + extensions + .getByType() + .named("libs") + .findVersion(name) + .get() + .requiredVersion + .toInt() diff --git a/flareUI/build.gradle.kts b/flareUI/build.gradle.kts new file mode 100644 index 0000000000..b351ef49d0 --- /dev/null +++ b/flareUI/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("dev.dimension.flareui.root-conventions") + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.moko.resources) apply false +} + +allprojects { + group = "dev.dimension.flareui" + version = "0.1.0-SNAPSHOT" +} diff --git a/flareUI/demo/androidApp/build.gradle.kts b/flareUI/demo/androidApp/build.gradle.kts new file mode 100644 index 0000000000..c7908ffe64 --- /dev/null +++ b/flareUI/demo/androidApp/build.gradle.kts @@ -0,0 +1,57 @@ +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion + +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "dev.dimension.flare.flareui.demo.android" + compileSdk { + version = release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = 0 + } + } + defaultConfig { + applicationId = "dev.dimension.flare.flareui.demo" + minSdk { + version = release(libs.versions.minSdk.get().toInt()) + } + targetSdk { + version = release(libs.versions.compileSdk.get().toInt()) + } + versionCode = 1 + versionName = "1.0" + } + testOptions { + unitTests.isIncludeAndroidResources = true + } + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + //"proguard-rules.pro", + ) + signingConfig = signingConfigs.getByName("debug") + } + } +} + +dependencies { + implementation(project(":demo:shared")) + implementation(libs.androidx.activity) + implementation(libs.androidx.fragment.ktx) + implementation(libs.material.components) + testImplementation(libs.junit) + testImplementation(libs.robolectric) +} + +tasks.withType().configureEach { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(libs.versions.java.get())) + }, + ) +} diff --git a/flareUI/demo/androidApp/src/main/AndroidManifest.xml b/flareUI/demo/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..69e73bee2c --- /dev/null +++ b/flareUI/demo/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/DemoActivity.kt b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/DemoActivity.kt new file mode 100644 index 0000000000..93825b4d37 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/DemoActivity.kt @@ -0,0 +1,52 @@ +package dev.dimension.flare.flareui.demo.android + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentActivity +import dev.dimension.flare.ui.demo.createAndroidComposeDemoView +import dev.dimension.flare.ui.demo.createAndroidViewDemoView + +public enum class DemoBackend( + public val intentValue: String, +) { + ANDROID_VIEW("android-view"), + COMPOSE("compose"), + ; + + public companion object { + internal fun fromIntent(intent: Intent): DemoBackend { + val intentValue = intent.getStringExtra(DemoActivity.EXTRA_BACKEND) + return entries.firstOrNull { it.intentValue == intentValue } + ?: error("Missing or invalid demo backend: $intentValue") + } + } +} + +public class DemoActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + when (DemoBackend.fromIntent(intent)) { + DemoBackend.ANDROID_VIEW -> { + title = getString(R.string.demo_title_android_view) + setContentView(createAndroidViewDemoView(this)) + } + + DemoBackend.COMPOSE -> { + title = getString(R.string.demo_title_android_compose) + setContentView(createAndroidComposeDemoView(this)) + } + } + } + + public companion object { + internal const val EXTRA_BACKEND: String = + "dev.dimension.flare.flareui.demo.android.extra.BACKEND" + + public fun createIntent( + context: Context, + backend: DemoBackend, + ): Intent = Intent(context, DemoActivity::class.java).putExtra(EXTRA_BACKEND, backend.intentValue) + } +} diff --git a/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/MainActivity.kt b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/MainActivity.kt new file mode 100644 index 0000000000..54a4a239c0 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/MainActivity.kt @@ -0,0 +1,64 @@ +package dev.dimension.flare.flareui.demo.android + +import android.os.Bundle +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.ComponentActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.android.material.button.MaterialButton + +public class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + title = getString(R.string.app_name) + val content = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView( + backendButton( + id = R.id.open_android_view, + label = getString(R.string.android_view_backend), + backend = DemoBackend.ANDROID_VIEW, + ), + wrapContentParams(), + ) + addView( + backendButton( + id = R.id.open_android_compose, + label = getString(R.string.android_compose_backend), + backend = DemoBackend.COMPOSE, + ), + wrapContentParams(), + ) + } + ViewCompat.setOnApplyWindowInsetsListener(content) { view, windowInsets -> + val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + windowInsets + } + setContentView(content) + } + + private fun backendButton( + id: Int, + label: String, + backend: DemoBackend, + ): MaterialButton = + MaterialButton(this).apply { + this.id = id + val horizontalPadding = (24 * resources.displayMetrics.density).toInt() + val verticalPadding = (16 * resources.displayMetrics.density).toInt() + text = label + setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding) + setOnClickListener { + startActivity(DemoActivity.createIntent(this@MainActivity, backend)) + } + } + + private fun wrapContentParams(): LinearLayout.LayoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) +} diff --git a/flareUI/demo/androidApp/src/main/res/values-night/themes.xml b/flareUI/demo/androidApp/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..08475ea2c6 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/flareUI/demo/androidApp/src/main/res/values-zh/strings.xml b/flareUI/demo/androidApp/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000000..bdabcdd478 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/res/values-zh/strings.xml @@ -0,0 +1,8 @@ + + + Flare UI 演示 + Android View 后端 + Android Compose 后端 + Flare UI 演示:Android View + Flare UI 演示:Android Compose + diff --git a/flareUI/demo/androidApp/src/main/res/values/ids.xml b/flareUI/demo/androidApp/src/main/res/values/ids.xml new file mode 100644 index 0000000000..329b16ac99 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/flareUI/demo/androidApp/src/main/res/values/strings.xml b/flareUI/demo/androidApp/src/main/res/values/strings.xml new file mode 100644 index 0000000000..08da58fa45 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Flare UI Demo + Android View backend + Android Compose backend + Flare UI Demo: Android View + Flare UI Demo: Android Compose + diff --git a/flareUI/demo/androidApp/src/main/res/values/themes.xml b/flareUI/demo/androidApp/src/main/res/values/themes.xml new file mode 100644 index 0000000000..e45975e97b --- /dev/null +++ b/flareUI/demo/androidApp/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/flareUI/demo/androidApp/src/test/kotlin/dev/dimension/flare/flareui/demo/android/MainActivityTest.kt b/flareUI/demo/androidApp/src/test/kotlin/dev/dimension/flare/flareui/demo/android/MainActivityTest.kt new file mode 100644 index 0000000000..ae2f68ab6b --- /dev/null +++ b/flareUI/demo/androidApp/src/test/kotlin/dev/dimension/flare/flareui/demo/android/MainActivityTest.kt @@ -0,0 +1,107 @@ +package dev.dimension.flare.flareui.demo.android + +import android.content.Intent +import android.view.View +import android.view.ViewGroup +import com.google.android.material.button.MaterialButton +import dev.dimension.flare.ui.android.FlareAndroidViewHost +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +public class MainActivityTest { + @Test + public fun selectorStartsAndroidViewDemo() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + + activity.findViewById(R.id.open_android_view).performClick() + + assertDemoIntent(shadowOf(activity).nextStartedActivity, DemoBackend.ANDROID_VIEW) + } + + @Test + public fun selectorStartsComposeDemo() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + + activity.findViewById(R.id.open_android_compose).performClick() + + assertDemoIntent(shadowOf(activity).nextStartedActivity, DemoBackend.COMPOSE) + } + + @Test + @Config(qualifiers = "zh") + public fun selectorUsesChineseBackendLabels() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + + assertEquals( + "Android View 后端", + activity.findViewById(R.id.open_android_view).text.toString(), + ) + assertEquals( + "Android Compose 后端", + activity.findViewById(R.id.open_android_compose).text.toString(), + ) + } + + @Test + public fun androidViewDemoInstallsOnlyAndroidViewHost() { + val activity = buildDemoActivity(DemoBackend.ANDROID_VIEW) + + val host = contentChild(activity) as FlareAndroidViewHost + assertEquals(0, host.paddingLeft) + assertEquals(0, host.paddingTop) + assertEquals(0, host.paddingRight) + assertEquals(0, host.paddingBottom) + } + + @Test + public fun composeDemoInstallsOnlyComposeHostWithLifecycleOwner() { + val activity = + try { + buildDemoActivity(DemoBackend.COMPOSE) + } catch (error: IllegalStateException) { + if (error.message?.contains("ViewTreeLifecycleOwner not found") == true) { + fail("DemoActivity did not install a ViewTreeLifecycleOwner before attaching ComposeView") + } + throw error + } + + val host = contentChild(activity) + assertEquals( + "androidx.compose.ui.platform.ComposeView", + host.javaClass.name, + ) + } + + private fun buildDemoActivity(backend: DemoBackend): DemoActivity = + Robolectric + .buildActivity( + DemoActivity::class.java, + DemoActivity.createIntent(RuntimeEnvironment.getApplication(), backend), + ).setup() + .get() + + private fun contentChild(activity: DemoActivity): View { + val content = activity.findViewById(android.R.id.content) + assertNotNull(content) + assertEquals(1, content.childCount) + return content.getChildAt(0) + } + + private fun assertDemoIntent( + intent: Intent, + backend: DemoBackend, + ) { + assertEquals(DemoActivity::class.java.name, intent.component?.className) + assertEquals(backend.intentValue, intent.getStringExtra(DemoActivity.EXTRA_BACKEND)) + } +} diff --git a/flareUI/demo/appleApp/Info-iOS.plist b/flareUI/demo/appleApp/Info-iOS.plist new file mode 100644 index 0000000000..eb8c4420bd --- /dev/null +++ b/flareUI/demo/appleApp/Info-iOS.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Flare UI Demo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + zh + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/flareUI/demo/appleApp/Info-macOS.plist b/flareUI/demo/appleApp/Info-macOS.plist new file mode 100644 index 0000000000..0da975768d --- /dev/null +++ b/flareUI/demo/appleApp/Info-macOS.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Flare UI Demo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + zh + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.developer-tools + + diff --git a/flareUI/demo/appleApp/Sources/iOS/FlareUIDemoIOSApp.swift b/flareUI/demo/appleApp/Sources/iOS/FlareUIDemoIOSApp.swift new file mode 100644 index 0000000000..8bbbf3f063 --- /dev/null +++ b/flareUI/demo/appleApp/Sources/iOS/FlareUIDemoIOSApp.swift @@ -0,0 +1,60 @@ +@preconcurrency import FlareUI +import UIKit + +@main +final class FlareUIDemoIOSApp: UIResponder, UIApplicationDelegate { + private var host: FlareDemoHost? + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let host = FlareDemoHost() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = FlareUIKitDemoViewController( + contentViewController: host.viewController + ) + window.makeKeyAndVisible() + + self.host = host + self.window = window + return true + } + + func applicationWillTerminate(_ application: UIApplication) { + host?.dispose() + host = nil + } +} + +private final class FlareUIKitDemoViewController: UIViewController { + private let contentViewController: UIViewController + + init(contentViewController: UIViewController) { + self.contentViewController = contentViewController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + addChild(contentViewController) + let contentView = contentViewController.view! + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + contentView.topAnchor.constraint(equalTo: view.topAnchor), + contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + contentViewController.didMove(toParent: self) + } +} diff --git a/flareUI/demo/appleApp/Sources/macOS/FlareAppKitDemoLayout.swift b/flareUI/demo/appleApp/Sources/macOS/FlareAppKitDemoLayout.swift new file mode 100644 index 0000000000..7744b4325b --- /dev/null +++ b/flareUI/demo/appleApp/Sources/macOS/FlareAppKitDemoLayout.swift @@ -0,0 +1,17 @@ +import AppKit + +@MainActor +func installFlareDemoContentView( + _ contentView: NSView, + in rootView: NSView +) { + contentView.translatesAutoresizingMaskIntoConstraints = false + rootView.addSubview(contentView) + + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + contentView.topAnchor.constraint(equalTo: rootView.topAnchor), + contentView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + ]) +} diff --git a/flareUI/demo/appleApp/Sources/macOS/FlareUIDemoMacApp.swift b/flareUI/demo/appleApp/Sources/macOS/FlareUIDemoMacApp.swift new file mode 100644 index 0000000000..fccff8976e --- /dev/null +++ b/flareUI/demo/appleApp/Sources/macOS/FlareUIDemoMacApp.swift @@ -0,0 +1,64 @@ +@preconcurrency import FlareUI +import AppKit + +@main +@MainActor +final class FlareUIDemoMacApp: NSObject, NSApplicationDelegate { + private static var retainedDelegate: FlareUIDemoMacApp? + + private var host: FlareDemoHost? + private var window: NSWindow? + + static func main() { + let application = NSApplication.shared + let delegate = FlareUIDemoMacApp() + retainedDelegate = delegate + application.delegate = delegate + application.setActivationPolicy(.regular) + application.run() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + let host = FlareDemoHost() + let viewController = FlareAppKitDemoViewController(contentViewController: host.viewController) + let window = NSWindow(contentViewController: viewController) + window.title = "Flare UI · AppKit" + window.setContentSize(NSSize(width: 640, height: 480)) + window.center() + window.makeKeyAndOrderFront(nil) + + self.host = host + self.window = window + NSApp.activate(ignoringOtherApps: true) + } + + func applicationWillTerminate(_ notification: Notification) { + host?.dispose() + host = nil + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } +} + +private final class FlareAppKitDemoViewController: NSViewController { + private let contentViewController: NSViewController + + init(contentViewController: NSViewController) { + self.contentViewController = contentViewController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("Use init(contentViewController:) instead") + } + + override func loadView() { + let rootView = NSView() + addChild(contentViewController) + installFlareDemoContentView(contentViewController.view, in: rootView) + view = rootView + } +} diff --git a/flareUI/demo/appleApp/Tests/iOSUITests/FlareUIDemoNavigationTests.swift b/flareUI/demo/appleApp/Tests/iOSUITests/FlareUIDemoNavigationTests.swift new file mode 100644 index 0000000000..ce34901cd9 --- /dev/null +++ b/flareUI/demo/appleApp/Tests/iOSUITests/FlareUIDemoNavigationTests.swift @@ -0,0 +1,53 @@ +import XCTest + +final class FlareUIDemoNavigationTests: XCTestCase { + @MainActor + func testCatalogImageUsesExplicit64PointFrame() { + continueAfterFailure = false + + let app = XCUIApplication() + app.launch() + + let catalogImage = app.descendants(matching: .any)["demo-catalog-image"] + XCTAssertTrue(catalogImage.waitForExistence(timeout: 5)) + XCTAssertEqual(catalogImage.frame.width, 64, accuracy: 1) + XCTAssertEqual(catalogImage.frame.height, 64, accuracy: 1) + } + + @MainActor + func testLeadingEdgeSwipeReturnsToCatalog() throws { + continueAfterFailure = false + + let app = XCUIApplication() + app.launch() + + let resourcesEntry = app.buttons["demo-open-resources"] + XCTAssertTrue(resourcesEntry.waitForExistence(timeout: 5)) + resourcesEntry.tap() + + let resourcesPage = app.otherElements["demo-resources"] + XCTAssertTrue(resourcesPage.waitForExistence(timeout: 2)) + + let window = app.windows.firstMatch + let navigation = app.descendants(matching: .any)["demo-navigation"] + XCTAssertTrue(navigation.waitForExistence(timeout: 1)) + XCTAssertEqual(navigation.frame.minX, window.frame.minX, accuracy: 1) + XCTAssertEqual(navigation.frame.maxX, window.frame.maxX, accuracy: 1) + + let leadingEdge = navigation.coordinate(withNormalizedOffset: CGVector(dx: 0.001, dy: 0.5)) + let destination = navigation.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.5)) + leadingEdge.press(forDuration: 0.05, thenDragTo: destination) + + XCTAssertTrue( + resourcesEntry.waitForExistence(timeout: 2), + "Expected a leading-edge swipe to return to the feature catalog" + ) + XCTAssertFalse(resourcesPage.exists, "Resources page should be popped after edge swipe") + + resourcesEntry.tap() + XCTAssertTrue( + resourcesPage.waitForExistence(timeout: 2), + "The declarative back stack should accept the same destination again after native pop" + ) + } +} diff --git a/flareUI/demo/appleApp/Tests/macOS/FlareAppKitDemoLayoutTests.swift b/flareUI/demo/appleApp/Tests/macOS/FlareAppKitDemoLayoutTests.swift new file mode 100644 index 0000000000..6500d7f7ea --- /dev/null +++ b/flareUI/demo/appleApp/Tests/macOS/FlareAppKitDemoLayoutTests.swift @@ -0,0 +1,21 @@ +import AppKit +import XCTest + +final class FlareAppKitDemoLayoutTests: XCTestCase { + @MainActor + func testNavigationContentFillsTheRootView() { + let rootView = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 200)) + let contentView = IntrinsicContentView() + + installFlareDemoContentView(contentView, in: rootView) + rootView.layoutSubtreeIfNeeded() + + XCTAssertEqual(contentView.frame, rootView.bounds) + } +} + +private final class IntrinsicContentView: NSView { + override var intrinsicContentSize: NSSize { + NSSize(width: 100, height: 100) + } +} diff --git a/flareUI/demo/appleApp/Tests/macOSUITests/FlareUIDemoLaunchTests.swift b/flareUI/demo/appleApp/Tests/macOSUITests/FlareUIDemoLaunchTests.swift new file mode 100644 index 0000000000..d429e6a7b8 --- /dev/null +++ b/flareUI/demo/appleApp/Tests/macOSUITests/FlareUIDemoLaunchTests.swift @@ -0,0 +1,19 @@ +import XCTest + +final class FlareUIDemoLaunchTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunchShowsWindow() { + let app = XCUIApplication() + app.launch() + defer { app.terminate() } + + XCTAssertTrue( + app.windows.firstMatch.waitForExistence(timeout: 5), + "The AppKit demo should show its main window after launch" + ) + } +} diff --git a/flareUI/demo/appleApp/project.yml b/flareUI/demo/appleApp/project.yml new file mode 100644 index 0000000000..f1f17dfcca --- /dev/null +++ b/flareUI/demo/appleApp/project.yml @@ -0,0 +1,191 @@ +name: FlareUIDemo + +options: + minimumXcodeGenVersion: 2.45.4 + developmentLanguage: en + defaultConfig: Debug + deploymentTarget: + iOS: "17.0" + macOS: "13.0" + +configs: + Debug: debug + Release: release + +settings: + base: + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: NO + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 + SWIFT_VERSION: "6.0" + +targets: + iOSDemo: + type: application + platform: iOS + sources: + - path: Sources/iOS + settings: + base: + ASSETCATALOG_COMPILER_APPICON_NAME: "" + FRAMEWORK_SEARCH_PATHS: + - "$(inherited)" + - "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)" + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: Info-iOS.plist + OTHER_LDFLAGS: "$(inherited) -framework FlareUI" + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.demo.ios + PRODUCT_NAME: Flare UI Demo + TARGETED_DEVICE_FAMILY: "1,2" + postBuildScripts: + - name: Copy Moko resources + basedOnDependencyAnalysis: false + script: | + cd "$SRCROOT/../../.." + ./gradlew -p flareUI :demo:shared:copyFrameworkResourcesToApp \ + -Pmoko.resources.PLATFORM_NAME="$PLATFORM_NAME" \ + -Pmoko.resources.CONFIGURATION="${KOTLIN_FRAMEWORK_BUILD_TYPE:-$CONFIGURATION}" \ + -Pmoko.resources.ARCHS="$ARCHS" \ + -Pmoko.resources.BUILT_PRODUCTS_DIR="$BUILT_PRODUCTS_DIR" \ + -Pmoko.resources.CONTENTS_FOLDER_PATH="$UNLOCALIZED_RESOURCES_FOLDER_PATH" + + iOSDemoUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: Tests/iOSUITests + dependencies: + - target: iOSDemo + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.demo.ios.uitests + TEST_TARGET_NAME: iOSDemo + + macOSDemo: + type: application + platform: macOS + sources: + - path: Sources/macOS + settings: + base: + ASSETCATALOG_COMPILER_APPICON_NAME: "" + FRAMEWORK_SEARCH_PATHS: + - "$(inherited)" + - "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)" + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: Info-macOS.plist + OTHER_LDFLAGS: "$(inherited) -framework FlareUI" + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.demo.macos + PRODUCT_NAME: Flare UI Demo + ARCHS: arm64 + postBuildScripts: + - name: Copy Moko resources + basedOnDependencyAnalysis: false + script: | + cd "$SRCROOT/../../.." + ./gradlew -p flareUI :demo:shared:copyFrameworkResourcesToApp \ + -Pmoko.resources.PLATFORM_NAME="$PLATFORM_NAME" \ + -Pmoko.resources.CONFIGURATION="${KOTLIN_FRAMEWORK_BUILD_TYPE:-$CONFIGURATION}" \ + -Pmoko.resources.ARCHS="$ARCHS" \ + -Pmoko.resources.BUILT_PRODUCTS_DIR="$BUILT_PRODUCTS_DIR" \ + -Pmoko.resources.CONTENTS_FOLDER_PATH="$UNLOCALIZED_RESOURCES_FOLDER_PATH" + + macOSDemoTests: + type: bundle.unit-test + platform: macOS + sources: + - path: Tests/macOS + - path: Sources/macOS/FlareAppKitDemoLayout.swift + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.demo.macos.tests + ARCHS: arm64 + + macOSDemoUITests: + type: bundle.ui-testing + platform: macOS + sources: + - path: Tests/macOSUITests + dependencies: + - target: macOSDemo + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.demo.macos.uitests + TEST_TARGET_NAME: macOSDemo + ARCHS: arm64 + +schemes: + FlareUIDemo-iOS: + build: + preActions: + - name: Compile Kotlin + settingsTarget: iOSDemo + script: | + cd "$SRCROOT/../../.." + export ARCHS=arm64 + if [ -n "${GRADLE_JVM_ARGS:-}" ]; then + ./gradlew -p flareUI "-Dorg.gradle.jvmargs=$GRADLE_JVM_ARGS" :demo:shared:embedAndSignAppleFrameworkForXcode + else + ./gradlew -p flareUI :demo:shared:embedAndSignAppleFrameworkForXcode + fi + targets: + iOSDemo: all + iOSDemoUITests: test + test: + config: Debug + targets: + - iOSDemoUITests + run: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release + + FlareUIDemo-macOS: + build: + preActions: + - name: Compile Kotlin + settingsTarget: macOSDemo + script: | + cd "$SRCROOT/../../.." + export ARCHS=arm64 + if [ -n "${GRADLE_JVM_ARGS:-}" ]; then + ./gradlew -p flareUI "-Dorg.gradle.jvmargs=$GRADLE_JVM_ARGS" :demo:shared:embedAndSignAppleFrameworkForXcode + else + ./gradlew -p flareUI :demo:shared:embedAndSignAppleFrameworkForXcode + fi + targets: + macOSDemo: all + run: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release + + FlareUIDemo-macOS-Tests: + build: + targets: + macOSDemoTests: test + test: + config: Debug + targets: + - macOSDemoTests + + FlareUIDemo-macOS-UITests: + build: + targets: + macOSDemo: all + macOSDemoUITests: test + test: + config: Debug + targets: + - macOSDemoUITests diff --git a/flareUI/demo/shared/build.gradle.kts b/flareUI/demo/shared/build.gradle.kts new file mode 100644 index 0000000000..02fceb2d2a --- /dev/null +++ b/flareUI/demo/shared/build.gradle.kts @@ -0,0 +1,81 @@ +import dev.dimension.flareui.buildlogic.FlareUiPlatform +import dev.dimension.flareui.buildlogic.flareUi +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +plugins { + id("dev.dimension.flareui.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.moko.resources) +} + +kotlin { + flareUi { + namespace = "dev.dimension.flare.ui.demo.shared" + platforms( + FlareUiPlatform.ANDROID, + FlareUiPlatform.IOS, + FlareUiPlatform.MACOS, + ) + } + android { + withHostTest { + isIncludeAndroidResources = true + } + } + + listOf("iosArm64", "iosSimulatorArm64", "macosArm64") + .map { targetName -> targets.getByName(targetName) as KotlinNativeTarget } + .forEach { appleTarget -> + appleTarget.binaries.framework { + baseName = "FlareUI" + isStatic = true + export(project(":flare-runtime")) + export(project(":foundation")) + export(project(":flare-lazy-layout")) + export(project(":flare-navigation")) + export(project(":flare-resources-moko")) + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":foundation")) + api(project(":flare-lazy-layout")) + api(project(":flare-navigation")) + api(project(":flare-resources-moko")) + } + } + val androidMain by getting { + dependencies { + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.androidx.fragment.ktx) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + } + } + val androidHostTest by getting { + dependencies { + implementation(libs.material.components) + implementation(libs.compose.ui.test.junit4) + implementation(libs.compose.ui.test.manifest) + implementation(libs.junit) + implementation(libs.robolectric) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +multiplatformResources { + resourcesPackage.set("dev.dimension.flare.ui.demo.resources") + resourcesClassName.set("DemoRes") + iosBaseLocalizationRegion.set("en") + iosMinimalDeploymentTarget.set("12.0") +} diff --git a/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareComposeNavigationLifecycleTest.kt b/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareComposeNavigationLifecycleTest.kt new file mode 100644 index 0000000000..864d708426 --- /dev/null +++ b/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareComposeNavigationLifecycleTest.kt @@ -0,0 +1,199 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, +) + +package dev.dimension.flare.ui.demo + +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.findViewTreeOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.navigation3.runtime.entryProvider +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.compose.AndroidComposeNavigationRendererPlugin +import dev.dimension.flare.ui.compose.FlareComposeHost +import dev.dimension.flare.ui.compose.createAndroidComposeWidgetSystem +import dev.dimension.flare.ui.foundation.Text +import dev.dimension.flare.ui.navigation.NavigationBackRequest +import dev.dimension.flare.ui.navigation.NavigationDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +public class FlareComposeNavigationLifecycleTest { + @get:Rule + public val composeRule = createComposeRule() + + @Test + public fun keepsPredictiveSnapshotWithoutKeepingItsEffectsActive() { + val activeEntries = mutableStateListOf() + val realizationCounts = mutableMapOf() + val disposalCounts = mutableMapOf() + val widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeNavigationRendererPlugin) + lateinit var backStack: SnapshotStateList + lateinit var refreshModel: () -> Unit + + composeRule.setContent { + var modelVersion by remember { mutableIntStateOf(0) } + val appliedModelVersion = modelVersion + refreshModel = { modelVersion += 1 } + backStack = + remember { + mutableStateListOf( + LifecycleHome, + LifecycleDetail, + ) + } + val provider = + remember { + entryProvider { + entry { + TrackedPage( + label = "home", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + entry { + TrackedPage( + label = "detail", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + } + } + FlareComposeHost( + widgetSystem = widgetSystem, + ) { + NavigationDisplay( + backStack = backStack, + onBack = { request -> + check(appliedModelVersion >= 0) + request.applyTo(backStack) + }, + entryProvider = provider, + ) + } + } + + composeRule.waitUntil(timeoutMillis = 5_000) { + activeEntries.toList() == listOf("detail") + } + composeRule.runOnIdle { + assertEquals(mapOf("home" to 1, "detail" to 1), realizationCounts) + assertEquals(mapOf("home" to 1), disposalCounts) + refreshModel() + } + composeRule.runOnIdle { + assertEquals(mapOf("home" to 1, "detail" to 1), realizationCounts) + assertEquals(mapOf("home" to 1), disposalCounts) + backStack.removeAt(backStack.lastIndex) + } + composeRule.mainClock.advanceTimeBy(1_000) + composeRule.runOnIdle { + assertEquals(listOf("home"), activeEntries.toList()) + assertEquals(mapOf("home" to 2, "detail" to 1), realizationCounts) + assertEquals(mapOf("home" to 1, "detail" to 1), disposalCounts) + } + } + + @Test + public fun acceptedComposeBackRequestIsTerminalAndLateApplyIsNoOp() { + val widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeNavigationRendererPlugin) + lateinit var backStack: SnapshotStateList + lateinit var backDispatcher: OnBackPressedDispatcher + var receivedRequest: NavigationBackRequest? = null + + composeRule.setContent { + backDispatcher = + checkNotNull(LocalView.current.findViewTreeOnBackPressedDispatcherOwner()) + .onBackPressedDispatcher + backStack = + remember { + mutableStateListOf( + LifecycleHome, + LifecycleDetail, + ) + } + val provider = + remember { + entryProvider { + entry { + Text("home") + } + entry { + Text("detail") + } + } + } + FlareComposeHost(widgetSystem = widgetSystem) { + NavigationDisplay( + backStack = backStack, + onBack = { receivedRequest = it }, + entryProvider = provider, + ) + } + } + + composeRule.waitForIdle() + composeRule.runOnIdle { + backDispatcher.onBackPressed() + } + composeRule.waitUntil(timeoutMillis = 5_000) { receivedRequest != null } + composeRule.runOnIdle { + val request = checkNotNull(receivedRequest) + assertEquals(listOf(LifecycleHome, LifecycleDetail), request.base) + assertEquals(listOf(LifecycleHome), request.target) + assertTrue(request.isActive) + assertTrue(request.accept()) + assertFalse(request.isActive) + assertFalse(request.reject()) + assertFalse(request.applyTo(backStack)) + assertEquals(listOf(LifecycleHome, LifecycleDetail), backStack.toList()) + } + } +} + +private sealed interface LifecycleRoute + +private data object LifecycleHome : LifecycleRoute + +private data object LifecycleDetail : LifecycleRoute + +@Composable +@FlareUiComposable +private fun TrackedPage( + label: String, + activeEntries: MutableList, + realizationCounts: MutableMap, + disposalCounts: MutableMap, +) { + DisposableEffect(label) { + activeEntries += label + realizationCounts[label] = realizationCounts.getOrElse(label) { 0 } + 1 + onDispose { + activeEntries -= label + disposalCounts[label] = disposalCounts.getOrElse(label) { 0 } + 1 + } + } + Text(label) +} diff --git a/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareDemoAndroidViewResourcesTest.kt b/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareDemoAndroidViewResourcesTest.kt new file mode 100644 index 0000000000..2fac9f687f --- /dev/null +++ b/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareDemoAndroidViewResourcesTest.kt @@ -0,0 +1,646 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, +) + +package dev.dimension.flare.ui.demo + +import android.os.Looper +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.navigation3.runtime.entryProvider +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.textview.MaterialTextView +import com.google.android.material.transition.MaterialSharedAxis +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.android.AndroidViewNavigationOwner +import dev.dimension.flare.ui.android.AndroidViewNavigationRendererPlugin +import dev.dimension.flare.ui.android.FlareAndroidViewHost +import dev.dimension.flare.ui.android.createAndroidWidgetSystem +import dev.dimension.flare.ui.foundation.Text +import dev.dimension.flare.ui.navigation.NavigationDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.time.Duration +import kotlin.math.roundToInt +import com.google.android.material.R as MaterialR + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "en") +@LooperMode(LooperMode.Mode.PAUSED) +public class FlareDemoAndroidViewResourcesTest { + @Test + public fun navigatesTheCatalogAndUpdatesEveryFeaturePage() { + withAttachedHost { host -> + val catalog = host.requireTaggedView("demo-catalog") + val catalogImage = catalog.requireTaggedView("demo-catalog-image") + assertEquals(64.dp(catalog), catalogImage.width) + assertEquals(64.dp(catalog), catalogImage.height) + assertEquals( + "Flare UI Catalog", + catalog.requireTaggedView("demo-catalog-title").text.toString(), + ) + + catalog.requireTaggedView("demo-open-resources").performClick() + idleMainThread() + + val resources = host.requireTaggedView("demo-resources") + val image = resources.requireTaggedView("demo-image") + val count = resources.requireTaggedView("demo-count") + val updates = resources.requireTaggedView("demo-updates") + val actions = resources.requireTaggedView("demo-actions") + val increment = resources.requireTaggedView("demo-increment") + + assertNotNull(image.drawable) + assertEquals("Flare resource image", image.contentDescription) + assertEquals("Count: 0", count.text.toString()) + assertEquals("0 updates", updates.text.toString()) + assertEquals(Gravity.TOP or Gravity.START, resources.gravity) + assertEquals(Gravity.START or Gravity.CENTER_VERTICAL, actions.gravity) + assertEquals(12.dp(resources), resources.dividerDrawable.intrinsicHeight) + assertEquals(12.dp(actions), actions.dividerDrawable.intrinsicWidth) + + increment.performClick() + idleMainThread() + + assertSame(count, resources.requireTaggedView("demo-count")) + assertEquals("Count: 1", count.text.toString()) + assertEquals("1 update", updates.text.toString()) + + resources.requireTaggedView("demo-back").performClick() + idleMainThread() + host.requireTaggedView("demo-open-lazy-layouts").performClick() + idleMainThread() + + val lazyRow = host.requireTaggedView("demo-lazy-row") + val lazyColumn = host.requireTaggedView("demo-lazy-column") + assertEquals(50, checkNotNull(lazyRow.adapter).itemCount) + assertEquals(10_000, checkNotNull(lazyColumn.adapter).itemCount) + } + } + + @Test + @Config(qualifiers = "zh") + public fun rendersChineseCatalogAndResources() { + withAttachedHost { host -> + assertEquals( + "Flare UI 功能目录", + host.requireTaggedView("demo-catalog-title").text.toString(), + ) + val openResources = host.requireTaggedView("demo-open-resources") + assertEquals("基础组件与资源", openResources.text.toString()) + + openResources.performClick() + idleMainThread() + + assertEquals( + "Flare UI 渲染运行时", + host.requireTaggedView("demo-title").text.toString(), + ) + assertEquals( + "计数:0", + host.requireTaggedView("demo-count").text.toString(), + ) + assertEquals( + "已更新 0 次", + host.requireTaggedView("demo-updates").text.toString(), + ) + assertEquals( + "增加", + host.requireTaggedView("demo-increment").text.toString(), + ) + } + } + + @Test + public fun rapidCatalogClicksCommitOnlyTheFirstDestination() { + withAttachedHost { host -> + val catalog = host.requireTaggedView("demo-catalog") + val resources = catalog.requireTaggedView("demo-open-resources") + val lazyLayouts = catalog.requireTaggedView("demo-open-lazy-layouts") + + resources.performClick() + resources.performClick() + lazyLayouts.performClick() + idleMainThread() + + host.requireTaggedView("demo-resources") + host.requireTaggedView("demo-back").performClick() + idleMainThread() + host.requireTaggedView("demo-catalog") + } + } + + @Test + public fun programmaticPushUsesAForwardPageTransition() { + withAttachedHost { activity, host -> + val outgoing = activity.supportFragmentManager.fragments.single() + host.requireTaggedView("demo-open-resources").performClick() + + idleMainThread() + + val incoming = activity.supportFragmentManager.fragments.single { it !== outgoing } + val outgoingTransition = outgoing.exitTransition as MaterialSharedAxis + val incomingTransition = incoming.enterTransition as MaterialSharedAxis + assertEquals(MaterialSharedAxis.X, outgoingTransition.axis) + assertEquals(MaterialSharedAxis.X, incomingTransition.axis) + assertTrue(outgoingTransition.isForward) + assertTrue(incomingTransition.isForward) + assertNotNull(host.findViewWithTag("demo-resources")) + } + } + + @Test + public fun programmaticPopUsesABackwardPageTransition() { + withAttachedHost { activity, host -> + val incoming = activity.supportFragmentManager.fragments.single() + host.requireTaggedView("demo-open-resources").performClick() + idleMainThread() + val outgoing = activity.supportFragmentManager.fragments.single { it !== incoming } + + host.requireTaggedView("demo-back").performClick() + idleMainThread() + + val outgoingTransition = outgoing.exitTransition as MaterialSharedAxis + val incomingTransition = incoming.enterTransition as MaterialSharedAxis + assertEquals(MaterialSharedAxis.X, outgoingTransition.axis) + assertEquals(MaterialSharedAxis.X, incomingTransition.axis) + assertFalse(outgoingTransition.isForward) + assertFalse(incomingTransition.isForward) + assertNotNull(host.findViewWithTag("demo-catalog")) + } + } + + @Test + public fun systemBackReturnsToCatalog() { + withAttachedHost { activity, host -> + val incoming = activity.supportFragmentManager.fragments.single() + host.requireTaggedView("demo-open-resources").performClick() + idleMainThread() + host.requireTaggedView("demo-resources") + val outgoing = activity.supportFragmentManager.fragments.single { it !== incoming } + + activity.onBackPressedDispatcher.onBackPressed() + activity.onBackPressedDispatcher.onBackPressed() + idleMainThread() + + assertFalse((outgoing.exitTransition as MaterialSharedAxis).isForward) + assertFalse((incoming.enterTransition as MaterialSharedAxis).isForward) + assertFalse(activity.isFinishing) + host.requireTaggedView("demo-catalog") + } + } + + @Test + public fun reconstructionRebindsRetainedPagesToTheirNewFragmentViews() { + val backStack = mutableStateListOf(ReconstructionHome, ReconstructionDetail("first")) + withAttachedHost( + createHost = { activity -> + FlareAndroidViewHost( + context = activity, + widgetSystem = createAndroidWidgetSystem(AndroidViewNavigationRendererPlugin), + nativeControllerOwner = AndroidViewNavigationOwner(activity), + ).apply { + setContent { + val provider = + remember { + entryProvider { + entry { + Text( + "Home", + FlareModifier(testTag = "reconstruction-home"), + ) + } + entry { route -> + Text( + route.label, + FlareModifier(testTag = "reconstruction-${route.label}"), + ) + } + } + } + NavigationDisplay( + backStack = backStack, + onBack = { request -> request.applyTo(backStack) }, + entryProvider = provider, + ) + } + } + }, + ) { _, host -> + host.requireTaggedView("reconstruction-first") + + backStack[1] = ReconstructionDetail("replacement") + idleMainThread() + host.requireTaggedView("reconstruction-replacement") + + backStack.removeAt(backStack.lastIndex) + idleMainThread() + assertEquals( + "Home", + host.requireTaggedView("reconstruction-home").text.toString(), + ) + } + } + + @Test + public fun disposingTheHostRemovesItsNavigationFragments() { + withAttachedHost { activity, host -> + host.requireTaggedView("demo-open-resources").performClick() + idleMainThread() + assertFalse(activity.supportFragmentManager.fragments.isEmpty()) + + host.disposeComposition() + idleMainThread() + + assertTrue(activity.supportFragmentManager.fragments.isEmpty()) + } + } + + @Test + public fun pushAndPopDeactivateAndReactivateTheFrozenPredecessor() { + val activeEntries = mutableSetOf() + val realizationCounts = mutableMapOf() + val disposalCounts = mutableMapOf() + val backStack = mutableStateListOf(ViewLifecycleHome) + withAttachedHost( + createHost = { activity -> + FlareAndroidViewHost( + context = activity, + widgetSystem = createAndroidWidgetSystem(AndroidViewNavigationRendererPlugin), + nativeControllerOwner = AndroidViewNavigationOwner(activity), + ).apply { + setContent { + val provider = + remember { + entryProvider { + entry { + TrackedAndroidViewNavigationPage( + label = "home", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + entry { + TrackedAndroidViewNavigationPage( + label = "middle", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + } + } + NavigationDisplay( + backStack = backStack, + onBack = { request -> request.applyTo(backStack) }, + entryProvider = provider, + ) + } + } + }, + ) { _, _ -> + backStack += ViewLifecycleMiddle + idleMainThread() + assertEquals(setOf("middle"), activeEntries) + assertEquals(mapOf("home" to 1, "middle" to 1), realizationCounts) + assertEquals(mapOf("home" to 1), disposalCounts) + + backStack.removeAt(backStack.lastIndex) + idleMainThread() + assertEquals(setOf("home"), activeEntries) + assertEquals(mapOf("home" to 2, "middle" to 1), realizationCounts) + assertEquals(mapOf("home" to 1, "middle" to 1), disposalCounts) + } + } + + @Test + public fun sameTopologyModelDeliveryKeepsTransitionParticipantsActive() { + val activeEntries = mutableSetOf() + val realizationCounts = mutableMapOf() + val disposalCounts = mutableMapOf() + val backStack = mutableStateListOf(ViewLifecycleHome) + val modelEpoch = mutableIntStateOf(0) + var requestedRedelivery = false + withAttachedHost( + createHost = { activity -> + FlareAndroidViewHost( + context = activity, + widgetSystem = createAndroidWidgetSystem(AndroidViewNavigationRendererPlugin), + nativeControllerOwner = AndroidViewNavigationOwner(activity), + ).apply { + setContent { + val appliedEpoch = modelEpoch.intValue + val provider = + remember { + entryProvider { + entry { + TrackedAndroidViewNavigationPage( + label = "home", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + entry { + TrackedAndroidViewNavigationPage( + label = "middle", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + onRealized = { + if (!requestedRedelivery) { + requestedRedelivery = true + modelEpoch.intValue += 1 + } + }, + ) + } + } + } + NavigationDisplay( + backStack = backStack, + onBack = { request -> + check(appliedEpoch >= 0) + request.applyTo(backStack) + }, + entryProvider = provider, + ) + } + } + }, + ) { _, _ -> + backStack += ViewLifecycleMiddle + idleMainThread() + + assertTrue(requestedRedelivery) + assertEquals(setOf("middle"), activeEntries) + assertEquals(mapOf("home" to 1, "middle" to 1), realizationCounts) + assertEquals(mapOf("home" to 1), disposalCounts) + } + } + + @Test + public fun onlyTheVisibleAndroidViewPageKeepsItsEffectsActive() { + val activeEntries = mutableSetOf() + val realizationCounts = mutableMapOf() + val disposalCounts = mutableMapOf() + val backStack = + mutableStateListOf( + ViewLifecycleHome, + ViewLifecycleMiddle, + ViewLifecycleDetail, + ) + withAttachedHost( + createHost = { activity -> + FlareAndroidViewHost( + context = activity, + widgetSystem = createAndroidWidgetSystem(AndroidViewNavigationRendererPlugin), + nativeControllerOwner = AndroidViewNavigationOwner(activity), + ).apply { + setContent { + val provider = + remember { + entryProvider { + entry { + TrackedAndroidViewNavigationPage( + label = "home", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + entry { + TrackedAndroidViewNavigationPage( + label = "middle", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + entry { + TrackedAndroidViewNavigationPage( + label = "detail", + activeEntries = activeEntries, + realizationCounts = realizationCounts, + disposalCounts = disposalCounts, + ) + } + } + } + NavigationDisplay( + backStack = backStack, + onBack = { request -> request.applyTo(backStack) }, + entryProvider = provider, + ) + } + } + }, + ) { _, _ -> + assertEquals(setOf("detail"), activeEntries) + assertEquals(mapOf("middle" to 1, "detail" to 1), realizationCounts) + assertEquals(mapOf("middle" to 1), disposalCounts) + + backStack.removeAt(backStack.lastIndex) + idleMainThread() + assertEquals(setOf("middle"), activeEntries) + assertEquals( + mapOf("home" to 1, "middle" to 2, "detail" to 1), + realizationCounts, + ) + assertEquals( + mapOf("home" to 1, "middle" to 1, "detail" to 1), + disposalCounts, + ) + + backStack.removeAt(backStack.lastIndex) + idleMainThread() + assertEquals(setOf("home"), activeEntries) + assertEquals( + mapOf("home" to 2, "middle" to 2, "detail" to 1), + realizationCounts, + ) + assertEquals( + mapOf("home" to 1, "middle" to 2, "detail" to 1), + disposalCounts, + ) + } + } + + @Test + public fun androidViewNavigationPreservesTheOuterPrimaryFragment() { + lateinit var outerPrimary: Fragment + withAttachedHost( + createHost = { activity -> + outerPrimary = Fragment() + activity.supportFragmentManager + .beginTransaction() + .add(outerPrimary, "outer-primary") + .setPrimaryNavigationFragment(outerPrimary) + .commitNow() + createAndroidViewDemoView(activity) as FlareAndroidViewHost + }, + ) { activity, host -> + assertSame(outerPrimary, activity.supportFragmentManager.primaryNavigationFragment) + + host.requireTaggedView("demo-open-resources").performClick() + idleMainThread() + assertSame(outerPrimary, activity.supportFragmentManager.primaryNavigationFragment) + + host.disposeComposition() + idleMainThread() + assertSame(outerPrimary, activity.supportFragmentManager.primaryNavigationFragment) + } + } + + @Test + public fun multipleAndroidViewNavigationsDoNotClaimAPrimaryFragment() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java) + val activity = controller.get().apply { setTheme(MaterialR.style.Theme_Material3_DayNight) } + controller.setup() + val firstHost = createAndroidViewDemoView(activity) as FlareAndroidViewHost + val secondHost = createAndroidViewDemoView(activity) as FlareAndroidViewHost + val root = FrameLayout(activity) + + try { + root.addView( + firstHost, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + root.addView( + secondHost, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + activity.setContentView(root) + idleMainThread() + + firstHost.requireTaggedView("demo-open-resources").performClick() + secondHost.requireTaggedView("demo-open-lazy-layouts").performClick() + idleMainThread() + + assertNull(activity.supportFragmentManager.primaryNavigationFragment) + firstHost.disposeComposition() + idleMainThread() + assertNull(activity.supportFragmentManager.primaryNavigationFragment) + secondHost.requireTaggedView("demo-lazy-layouts") + } finally { + firstHost.disposeComposition() + secondHost.disposeComposition() + controller.pause().stop().destroy() + idleMainThread() + } + } + + private fun withAttachedHost(block: (FlareAndroidViewHost) -> Unit) { + withAttachedHost { _, host -> block(host) } + } + + private fun withAttachedHost(block: (FragmentActivity, FlareAndroidViewHost) -> Unit) { + withAttachedHost( + createHost = { activity -> createAndroidViewDemoView(activity) as FlareAndroidViewHost }, + block = block, + ) + } + + private fun withAttachedHost( + createHost: (FragmentActivity) -> FlareAndroidViewHost, + block: (FragmentActivity, FlareAndroidViewHost) -> Unit, + ) { + val controller = Robolectric.buildActivity(FragmentActivity::class.java) + val activity = controller.get().apply { setTheme(MaterialR.style.Theme_Material3_DayNight) } + controller.setup() + val host = createHost(activity) + + try { + activity.setContentView(host) + idleMainThread() + block(activity, host) + } finally { + host.disposeComposition() + controller.pause().stop().destroy() + idleMainThread() + } + } + + private fun idleMainThread() { + idleMainThread(Duration.ofMillis(64)) + } + + private fun idleMainThread(duration: Duration) { + shadowOf(Looper.getMainLooper()).idleFor(duration) + } + + private fun View.requireTaggedView(tag: String): T = checkNotNull(findViewWithTag(tag)) { "No view has tag $tag." } + + private fun Int.dp(view: View): Int = (this * view.resources.displayMetrics.density).roundToInt() +} + +private sealed interface ReconstructionRoute + +private data object ReconstructionHome : ReconstructionRoute + +private data class ReconstructionDetail( + val label: String, +) : ReconstructionRoute + +private sealed interface ViewLifecycleRoute + +private data object ViewLifecycleHome : ViewLifecycleRoute + +private data object ViewLifecycleMiddle : ViewLifecycleRoute + +private data object ViewLifecycleDetail : ViewLifecycleRoute + +@Composable +@FlareUiComposable +private fun TrackedAndroidViewNavigationPage( + label: String, + activeEntries: MutableSet, + realizationCounts: MutableMap, + disposalCounts: MutableMap, + onRealized: () -> Unit = {}, +) { + DisposableEffect(label) { + activeEntries += label + realizationCounts[label] = realizationCounts.getOrElse(label) { 0 } + 1 + onRealized() + onDispose { + activeEntries -= label + disposalCounts[label] = disposalCounts.getOrElse(label) { 0 } + 1 + } + } + Text( + text = label, + modifier = FlareModifier(testTag = "lifecycle-$label"), + ) +} diff --git a/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareDemoComposeResourcesTest.kt b/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareDemoComposeResourcesTest.kt new file mode 100644 index 0000000000..b318bbd638 --- /dev/null +++ b/flareUI/demo/shared/src/androidHostTest/kotlin/dev/dimension/flare/ui/demo/FlareDemoComposeResourcesTest.kt @@ -0,0 +1,103 @@ +package dev.dimension.flare.ui.demo + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import dev.dimension.flare.ui.compose.AndroidComposeLazyLayoutRendererPlugin +import dev.dimension.flare.ui.compose.AndroidComposeNavigationRendererPlugin +import dev.dimension.flare.ui.compose.FlareComposeHost +import dev.dimension.flare.ui.compose.createAndroidComposeWidgetSystem +import dev.dimension.flare.ui.resources.moko.AndroidComposeMokoResourcesRendererPlugin +import dev.dimension.flare.ui.resources.moko.AndroidMokoResourceResolver +import dev.dimension.flare.ui.resources.moko.ProvideMokoResources +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "en") +public class FlareDemoComposeResourcesTest { + @get:Rule + public val composeRule = createComposeRule() + + @Test + public fun rendersAndUpdatesGeneratedResources() { + val widgetSystem = + createAndroidComposeWidgetSystem( + AndroidComposeMokoResourcesRendererPlugin, + AndroidComposeLazyLayoutRendererPlugin, + AndroidComposeNavigationRendererPlugin, + ) + composeRule.setContent { + val context = LocalContext.current + val resolver = remember(context) { AndroidMokoResourceResolver(context) } + MaterialTheme { + FlareComposeHost(widgetSystem = widgetSystem) { + ProvideMokoResources(resolver) { + FlareDemoContent() + } + } + } + } + + composeRule.onNodeWithTag("demo-navigation").assertExists() + composeRule.onNodeWithTag("demo-catalog-title").assertTextEquals("Flare UI Catalog") + composeRule + .onNodeWithTag("demo-catalog-image") + .assertContentDescriptionEquals("Flare resource image") + .assertWidthIsEqualTo(64.dp) + .assertHeightIsEqualTo(64.dp) + composeRule.onNodeWithTag("demo-open-resources").performClick() + + composeRule.onNodeWithTag("demo-image").assertContentDescriptionEquals("Flare resource image") + composeRule + .onNodeWithTag("demo-count") + .assertTextEquals("Count: 0") + composeRule + .onNodeWithTag("demo-updates") + .assertTextEquals("0 updates") + composeRule + .onNodeWithTag("demo-increment") + .assertHeightIsAtLeast(40.dp) + + val incrementBounds = composeRule.onNodeWithTag("demo-increment").getUnclippedBoundsInRoot() + val resetBounds = composeRule.onNodeWithTag("demo-reset").getUnclippedBoundsInRoot() + assertEquals(12f, (resetBounds.left - incrementBounds.right).value, 0.1f) + + composeRule + .onNodeWithTag("demo-increment") + .performClick() + + composeRule + .onNodeWithTag("demo-count") + .assertTextEquals("Count: 1") + composeRule + .onNodeWithTag("demo-updates") + .assertTextEquals("1 update") + + composeRule.onNodeWithTag("demo-back").performClick() + composeRule.onNodeWithTag("demo-catalog-title").assertTextEquals("Flare UI Catalog") + composeRule.onNodeWithTag("demo-open-lazy-layouts").performClick() + + composeRule.onNodeWithTag("demo-lazy-row-item-0").assertTextEquals("Card 0") + composeRule.onNodeWithTag("demo-lazy-column-item-0").assertTextEquals("Lazy item 0") + composeRule.onAllNodesWithTag("demo-lazy-column-item-9999").assertCountEquals(0) + val firstLazyItem = composeRule.onNodeWithTag("demo-lazy-column-item-0").getUnclippedBoundsInRoot() + assertEquals(36f, (firstLazyItem.bottom - firstLazyItem.top).value, 0.1f) + } +} diff --git a/flareUI/demo/shared/src/androidMain/kotlin/dev/dimension/flare/ui/demo/FlareDemo.android.kt b/flareUI/demo/shared/src/androidMain/kotlin/dev/dimension/flare/ui/demo/FlareDemo.android.kt new file mode 100644 index 0000000000..4fc8219d52 --- /dev/null +++ b/flareUI/demo/shared/src/androidMain/kotlin/dev/dimension/flare/ui/demo/FlareDemo.android.kt @@ -0,0 +1,88 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.demo + +import android.content.Context +import android.view.View +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity +import dev.dimension.flare.ui.android.AndroidViewLazyLayoutRendererPlugin +import dev.dimension.flare.ui.android.AndroidViewNavigationOwner +import dev.dimension.flare.ui.android.AndroidViewNavigationRendererPlugin +import dev.dimension.flare.ui.android.FlareAndroidViewHost +import dev.dimension.flare.ui.android.createAndroidWidgetSystem +import dev.dimension.flare.ui.compose.AndroidComposeLazyLayoutRendererPlugin +import dev.dimension.flare.ui.compose.AndroidComposeNavigationRendererPlugin +import dev.dimension.flare.ui.compose.FlareComposeHost +import dev.dimension.flare.ui.compose.createAndroidComposeWidgetSystem +import dev.dimension.flare.ui.resources.moko.AndroidComposeMokoResourcesRendererPlugin +import dev.dimension.flare.ui.resources.moko.AndroidMokoResourceResolver +import dev.dimension.flare.ui.resources.moko.AndroidViewMokoResourcesRendererPlugin +import dev.dimension.flare.ui.resources.moko.ProvideMokoResources + +/** Creates the demo with the Android View renderer backend. */ +public fun createAndroidViewDemoView(context: FragmentActivity): View { + val resolver = AndroidMokoResourceResolver(context) + return FlareAndroidViewHost( + context = context, + widgetSystem = + createAndroidWidgetSystem( + AndroidViewMokoResourcesRendererPlugin, + AndroidViewLazyLayoutRendererPlugin, + AndroidViewNavigationRendererPlugin, + ), + nativeControllerOwner = AndroidViewNavigationOwner(context), + ).apply { + setContent { + ProvideMokoResources(resolver) { + FlareDemoContent() + } + } + } +} + +/** Creates the same demo with the Android Compose renderer backend. */ +public fun createAndroidComposeDemoView(context: Context): View { + val widgetSystem = + createAndroidComposeWidgetSystem( + AndroidComposeMokoResourcesRendererPlugin, + AndroidComposeLazyLayoutRendererPlugin, + AndroidComposeNavigationRendererPlugin, + ) + return ComposeView(context).apply { + setContent { + val currentContext = LocalContext.current + val configuration = LocalConfiguration.current + val resolver = + remember(currentContext, configuration) { + AndroidMokoResourceResolver(currentContext) + } + MaterialTheme( + colorScheme = + if (isSystemInDarkTheme()) { + darkColorScheme() + } else { + lightColorScheme() + }, + ) { + Surface(modifier = Modifier.fillMaxSize()) { + FlareComposeHost(widgetSystem = widgetSystem) { + ProvideMokoResources(resolver) { + FlareDemoContent() + } + } + } + } + } + } +} diff --git a/flareUI/demo/shared/src/commonMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoContent.kt b/flareUI/demo/shared/src/commonMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoContent.kt new file mode 100644 index 0000000000..9ab2877ccc --- /dev/null +++ b/flareUI/demo/shared/src/commonMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoContent.kt @@ -0,0 +1,249 @@ +@file:OptIn(dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class) + +package dev.dimension.flare.ui.demo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation3.runtime.entryProvider +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.demo.resources.DemoRes +import dev.dimension.flare.ui.foundation.Column +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.NativeButton +import dev.dimension.flare.ui.foundation.Row +import dev.dimension.flare.ui.foundation.Text +import dev.dimension.flare.ui.foundation.VerticalAlignment +import dev.dimension.flare.ui.lazy.LazyColumn +import dev.dimension.flare.ui.lazy.LazyRow +import dev.dimension.flare.ui.navigation.NavigationDisplay +import dev.dimension.flare.ui.resources.moko.ResourceImage +import dev.dimension.flare.ui.resources.moko.imageResource +import dev.dimension.flare.ui.resources.moko.pluralStringResource +import dev.dimension.flare.ui.resources.moko.stringResource + +/** Navigation-driven catalog shared by every platform host. */ +@Composable +@FlareUiComposable +public fun FlareDemoContent() { + val backStack = remember { mutableStateListOf(DemoCatalog) } + val demoEntryProvider = + remember(backStack) { + entryProvider { + entry { + CatalogScreen( + onOpenResources = { backStack.pushFromCatalog(DemoResources) }, + onOpenLazyLayouts = { backStack.pushFromCatalog(DemoLazyLayouts) }, + ) + } + entry { + ResourcesScreen(onBack = backStack::popOne) + } + entry { + LazyLayoutsScreen(onBack = backStack::popOne) + } + } + } + + NavigationDisplay( + backStack = backStack, + modifier = FlareModifier(testTag = "demo-navigation").fillMaxSize(), + onBack = { request -> request.applyTo(backStack) }, + entryProvider = demoEntryProvider, + ) +} + +@Composable +@FlareUiComposable +private fun CatalogScreen( + onOpenResources: () -> Unit, + onOpenLazyLayouts: () -> Unit, +) { + Column( + modifier = FlareModifier(testTag = "demo-catalog").fillMaxWidth(), + spacing = DEMO_ITEM_SPACING, + horizontalAlignment = HorizontalAlignment.Start, + ) { + ResourceImage( + image = imageResource(DemoRes.images.flare_mark), + contentDescription = stringResource(DemoRes.strings.flare_mark_description), + modifier = + FlareModifier(testTag = "demo-catalog-image") + .width(DEMO_IMAGE_SIZE) + .height(DEMO_IMAGE_SIZE), + ) + Text( + text = stringResource(DemoRes.strings.catalog_title), + modifier = FlareModifier(testTag = "demo-catalog-title"), + ) + Text(stringResource(DemoRes.strings.catalog_description)) + NativeButton( + label = stringResource(DemoRes.strings.open_resources_feature), + modifier = FlareModifier(testTag = "demo-open-resources").fillMaxWidth(), + onClick = onOpenResources, + ) + Text(stringResource(DemoRes.strings.resources_feature_description)) + NativeButton( + label = stringResource(DemoRes.strings.open_lazy_layouts_feature), + modifier = FlareModifier(testTag = "demo-open-lazy-layouts").fillMaxWidth(), + onClick = onOpenLazyLayouts, + ) + Text(stringResource(DemoRes.strings.lazy_layouts_feature_description)) + } +} + +@Composable +@FlareUiComposable +private fun ResourcesScreen(onBack: () -> Unit) { + var count by remember { mutableIntStateOf(0) } + + Column( + modifier = FlareModifier(testTag = "demo-resources").fillMaxWidth(), + spacing = DEMO_ITEM_SPACING, + horizontalAlignment = HorizontalAlignment.Start, + ) { + BackToCatalogButton(onBack) + ResourceImage( + image = imageResource(DemoRes.images.flare_mark), + contentDescription = stringResource(DemoRes.strings.flare_mark_description), + modifier = + FlareModifier(testTag = "demo-image") + .width(DEMO_IMAGE_SIZE) + .height(DEMO_IMAGE_SIZE), + ) + Text( + text = stringResource(DemoRes.strings.demo_title), + modifier = FlareModifier(testTag = "demo-title"), + ) + Text(stringResource(DemoRes.strings.demo_description)) + Text( + text = stringResource(DemoRes.strings.count_format, count), + modifier = FlareModifier(testTag = "demo-count"), + ) + Text( + text = pluralStringResource(DemoRes.plurals.update_count, count, count), + modifier = FlareModifier(testTag = "demo-updates"), + ) + Row( + modifier = FlareModifier(testTag = "demo-actions"), + spacing = DEMO_ITEM_SPACING, + verticalAlignment = VerticalAlignment.Center, + ) { + NativeButton( + label = stringResource(DemoRes.strings.increment), + modifier = FlareModifier(testTag = "demo-increment"), + onClick = { count += 1 }, + ) + NativeButton( + label = stringResource(DemoRes.strings.reset), + modifier = FlareModifier(testTag = "demo-reset"), + enabled = count != 0, + onClick = { count = 0 }, + ) + } + } +} + +@Composable +@FlareUiComposable +private fun LazyLayoutsScreen(onBack: () -> Unit) { + Column( + modifier = FlareModifier(testTag = "demo-lazy-layouts").fillMaxWidth(), + spacing = DEMO_ITEM_SPACING, + horizontalAlignment = HorizontalAlignment.Start, + ) { + BackToCatalogButton(onBack) + Text(stringResource(DemoRes.strings.lazy_row_title)) + LazyRow( + modifier = + FlareModifier(testTag = "demo-lazy-row") + .fillMaxWidth() + .height(DEMO_LAZY_ROW_HEIGHT), + spacing = DEMO_LAZY_ITEM_SPACING, + verticalAlignment = VerticalAlignment.Center, + ) { + items( + count = DEMO_CARD_COUNT, + key = { index -> "card-$index" }, + contentType = { "card" }, + ) { index -> + Text( + text = stringResource(DemoRes.strings.lazy_card_format, index), + modifier = + FlareModifier(testTag = "demo-lazy-row-item-$index") + .width(DEMO_CARD_WIDTH) + .height(DEMO_CARD_HEIGHT), + ) + } + } + Text(stringResource(DemoRes.strings.lazy_column_title)) + LazyColumn( + modifier = + FlareModifier(testTag = "demo-lazy-column") + .fillMaxWidth() + .height(DEMO_LAZY_COLUMN_HEIGHT), + spacing = DEMO_LAZY_ITEM_SPACING, + ) { + items( + count = DEMO_LAZY_ITEM_COUNT, + key = { index -> index }, + contentType = { "item" }, + ) { index -> + Text( + text = stringResource(DemoRes.strings.lazy_item_format, index), + modifier = + FlareModifier(testTag = "demo-lazy-column-item-$index") + .height(DEMO_ITEM_HEIGHT), + ) + } + } + } +} + +@Composable +@FlareUiComposable +private fun BackToCatalogButton(onBack: () -> Unit) { + NativeButton( + label = stringResource(DemoRes.strings.back_to_catalog), + modifier = FlareModifier(testTag = "demo-back"), + onClick = onBack, + ) +} + +private fun MutableList.pop(popCount: Int) { + require(popCount > 0) { "A catalog back request must pop at least one entry." } + repeat(popCount) { + if (size > 1) removeAt(lastIndex) + } +} + +private fun MutableList.popOne() { + pop(1) +} + +private fun MutableList.pushFromCatalog(route: DemoRoute) { + if (size == 1 && firstOrNull() == DemoCatalog) add(route) +} + +private sealed interface DemoRoute + +private data object DemoCatalog : DemoRoute + +private data object DemoResources : DemoRoute + +private data object DemoLazyLayouts : DemoRoute + +private const val DEMO_ITEM_SPACING: Float = 12f +private const val DEMO_IMAGE_SIZE: Float = 64f +private const val DEMO_LAZY_ITEM_SPACING: Float = 6f +private const val DEMO_LAZY_ROW_HEIGHT: Float = 80f +private const val DEMO_LAZY_COLUMN_HEIGHT: Float = 240f +private const val DEMO_CARD_WIDTH: Float = 96f +private const val DEMO_CARD_HEIGHT: Float = 56f +private const val DEMO_ITEM_HEIGHT: Float = 36f +private const val DEMO_CARD_COUNT: Int = 50 +private const val DEMO_LAZY_ITEM_COUNT: Int = 10_000 diff --git a/flareUI/demo/shared/src/commonMain/moko-resources/base/plurals.xml b/flareUI/demo/shared/src/commonMain/moko-resources/base/plurals.xml new file mode 100644 index 0000000000..9ee2bcc380 --- /dev/null +++ b/flareUI/demo/shared/src/commonMain/moko-resources/base/plurals.xml @@ -0,0 +1,7 @@ + + + + %d update + %d updates + + diff --git a/flareUI/demo/shared/src/commonMain/moko-resources/base/strings.xml b/flareUI/demo/shared/src/commonMain/moko-resources/base/strings.xml new file mode 100644 index 0000000000..cd8130f19e --- /dev/null +++ b/flareUI/demo/shared/src/commonMain/moko-resources/base/strings.xml @@ -0,0 +1,20 @@ + + + Flare UI Catalog + Choose a feature to compare its native rendering across backends. + Foundation and resources + Text, buttons, rows, generated strings, plurals, and images. + Lazy layouts + Virtualized rows and columns with stable keys. + Back to catalog + Flare UI renderer runtime + One shared composition renders through the selected backend. + Count: %d + Increment + Reset + Flare resource image + LazyRow: 50 cards + LazyColumn: 10,000+ stable-key items + Card %d + Lazy item %d + diff --git a/flareUI/demo/shared/src/commonMain/moko-resources/images/flare_mark.svg b/flareUI/demo/shared/src/commonMain/moko-resources/images/flare_mark.svg new file mode 100644 index 0000000000..3ac9e28f98 --- /dev/null +++ b/flareUI/demo/shared/src/commonMain/moko-resources/images/flare_mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/flareUI/demo/shared/src/commonMain/moko-resources/zh/plurals.xml b/flareUI/demo/shared/src/commonMain/moko-resources/zh/plurals.xml new file mode 100644 index 0000000000..d9e2ef5d38 --- /dev/null +++ b/flareUI/demo/shared/src/commonMain/moko-resources/zh/plurals.xml @@ -0,0 +1,6 @@ + + + + 已更新 %d 次 + + diff --git a/flareUI/demo/shared/src/commonMain/moko-resources/zh/strings.xml b/flareUI/demo/shared/src/commonMain/moko-resources/zh/strings.xml new file mode 100644 index 0000000000..4d00b2ab88 --- /dev/null +++ b/flareUI/demo/shared/src/commonMain/moko-resources/zh/strings.xml @@ -0,0 +1,20 @@ + + + Flare UI 功能目录 + 选择功能,对比它在不同原生后端中的渲染表现。 + 基础组件与资源 + 文本、按钮、行布局、生成字符串、复数与图片。 + 懒加载布局 + 使用稳定 key 的虚拟化行与列。 + 返回功能目录 + Flare UI 渲染运行时 + 同一份组合内容通过所选 backend 渲染。 + 计数:%d + 增加 + 重置 + Flare 资源图片 + LazyRow:50 个卡片 + LazyColumn:10,000+ 个稳定 key 项 + 卡片 %d + Lazy 项 %d + diff --git a/flareUI/demo/shared/src/iosMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoHost.kt b/flareUI/demo/shared/src/iosMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoHost.kt new file mode 100644 index 0000000000..390c06c6a1 --- /dev/null +++ b/flareUI/demo/shared/src/iosMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoHost.kt @@ -0,0 +1,51 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.demo + +import dev.dimension.flare.ui.navigation.UIKitNavigationOwner +import dev.dimension.flare.ui.navigation.UIKitNavigationRendererPlugin +import dev.dimension.flare.ui.resources.moko.AppleMokoResourceResolver +import dev.dimension.flare.ui.resources.moko.ProvideMokoResources +import dev.dimension.flare.ui.resources.moko.UIKitMokoResourcesRendererPlugin +import dev.dimension.flare.ui.uikit.FlareUIKitHost +import dev.dimension.flare.ui.uikit.UIKitLazyLayoutRendererPlugin +import dev.dimension.flare.ui.uikit.createUIKitWidgetSystem +import platform.UIKit.UIView +import platform.UIKit.UIViewController + +/** Swift-visible owner of the shared demo's native UIKit hierarchy. */ +public class FlareDemoHost { + private val controller = UIViewController() + private val host = + FlareUIKitHost( + widgetSystem = + createUIKitWidgetSystem( + UIKitMokoResourcesRendererPlugin, + UIKitLazyLayoutRendererPlugin, + UIKitNavigationRendererPlugin, + ), + nativeControllerOwner = UIKitNavigationOwner(controller), + ) + + public val view: UIView + get() = host.view + + public val viewController: UIViewController + get() = controller + + init { + controller.view = host.view + host.setContent { + ProvideMokoResources(AppleMokoResourceResolver) { + FlareDemoContent() + } + } + } + + public fun dispose() { + host.dispose() + } +} diff --git a/flareUI/demo/shared/src/iosTest/kotlin/dev/dimension/flare/ui/demo/DemoResourcesIosTest.kt b/flareUI/demo/shared/src/iosTest/kotlin/dev/dimension/flare/ui/demo/DemoResourcesIosTest.kt new file mode 100644 index 0000000000..3e87b1d9e8 --- /dev/null +++ b/flareUI/demo/shared/src/iosTest/kotlin/dev/dimension/flare/ui/demo/DemoResourcesIosTest.kt @@ -0,0 +1,49 @@ +package dev.dimension.flare.ui.demo + +import dev.dimension.flare.ui.demo.resources.DemoRes +import dev.dimension.flare.ui.resources.moko.AppleMokoResourceResolver +import dev.icerock.moko.resources.desc.StringDesc +import dev.icerock.moko.resources.desc.desc +import dev.icerock.moko.resources.format +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +public class DemoResourcesIosTest { + @Test + public fun resolvesGeneratedStringsPluralsAndImage() { + StringDesc.localeType = StringDesc.LocaleType.Custom("en") + try { + assertEquals( + "Flare UI renderer runtime", + AppleMokoResourceResolver.resolve(DemoRes.strings.demo_title.desc()), + ) + assertEquals( + "Count: 7", + AppleMokoResourceResolver.resolve(DemoRes.strings.count_format.format(7)), + ) + assertEquals( + "1 update", + AppleMokoResourceResolver.resolve(DemoRes.plurals.update_count.format(1, 1)), + ) + assertTrue( + AppleMokoResourceResolver + .resolve(DemoRes.images.flare_mark) + .uiImage + .toString() + .isNotBlank(), + ) + StringDesc.localeType = StringDesc.LocaleType.Custom("zh") + assertEquals( + "Flare UI 渲染运行时", + AppleMokoResourceResolver.resolve(DemoRes.strings.demo_title.desc()), + ) + assertEquals( + "已更新 3 次", + AppleMokoResourceResolver.resolve(DemoRes.plurals.update_count.format(3, 3)), + ) + } finally { + StringDesc.localeType = StringDesc.LocaleType.System + } + } +} diff --git a/flareUI/demo/shared/src/macosMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoHost.kt b/flareUI/demo/shared/src/macosMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoHost.kt new file mode 100644 index 0000000000..9db6e56d42 --- /dev/null +++ b/flareUI/demo/shared/src/macosMain/kotlin/dev/dimension/flare/ui/demo/FlareDemoHost.kt @@ -0,0 +1,51 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.demo + +import dev.dimension.flare.ui.appkit.AppKitLazyLayoutRendererPlugin +import dev.dimension.flare.ui.appkit.AppKitNavigationOwner +import dev.dimension.flare.ui.appkit.AppKitNavigationRendererPlugin +import dev.dimension.flare.ui.appkit.FlareAppKitHost +import dev.dimension.flare.ui.appkit.createAppKitWidgetSystem +import dev.dimension.flare.ui.resources.moko.AppKitMokoResourcesRendererPlugin +import dev.dimension.flare.ui.resources.moko.AppleMokoResourceResolver +import dev.dimension.flare.ui.resources.moko.ProvideMokoResources +import platform.AppKit.NSView +import platform.AppKit.NSViewController + +/** Swift-visible owner of the shared demo's native AppKit hierarchy. */ +public class FlareDemoHost { + private val controller = NSViewController() + private val host = + FlareAppKitHost( + widgetSystem = + createAppKitWidgetSystem( + AppKitMokoResourcesRendererPlugin, + AppKitLazyLayoutRendererPlugin, + AppKitNavigationRendererPlugin, + ), + nativeControllerOwner = AppKitNavigationOwner(controller), + ) + + public val view: NSView + get() = host.view + + public val viewController: NSViewController + get() = controller + + init { + controller.view = host.view + host.setContent { + ProvideMokoResources(AppleMokoResourceResolver) { + FlareDemoContent() + } + } + } + + public fun dispose() { + host.dispose() + } +} diff --git a/flareUI/demo/shared/src/macosTest/kotlin/dev/dimension/flare/ui/demo/DemoAppKitGeometryTest.kt b/flareUI/demo/shared/src/macosTest/kotlin/dev/dimension/flare/ui/demo/DemoAppKitGeometryTest.kt new file mode 100644 index 0000000000..881d9d2b41 --- /dev/null +++ b/flareUI/demo/shared/src/macosTest/kotlin/dev/dimension/flare/ui/demo/DemoAppKitGeometryTest.kt @@ -0,0 +1,99 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.demo + +import kotlinx.cinterop.useContents +import platform.AppKit.NSApplication +import platform.AppKit.NSBackingStoreBuffered +import platform.AppKit.NSView +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowStyleMaskBorderless +import platform.AppKit.alignmentRectForFrame +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSThread +import platform.Foundation.valueForKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +public class DemoAppKitGeometryTest { + @Test + public fun catalogImageAndTitleShareTheirLeadingEdge() { + assertTrue(NSThread.isMainThread) + NSApplication.sharedApplication + val window = + NSWindow( + contentRect = CGRectMake(0.0, 0.0, HOST_WIDTH, HOST_HEIGHT), + styleMask = NSWindowStyleMaskBorderless, + backing = NSBackingStoreBuffered, + defer = false, + ) + val root = NSView(frame = CGRectMake(0.0, 0.0, HOST_WIDTH, HOST_HEIGHT)) + window.contentView = root + val host = FlareDemoHost() + + try { + host.view.frame = root.bounds + root.addSubview(host.view) + + var image: NSView? = null + var title: NSView? = null + awaitDemoLayout("AppKit demo did not create the catalog hierarchy.") { + root.layoutSubtreeIfNeeded() + image = root.findTaggedView(CATALOG_IMAGE_TAG) + title = root.findTaggedView(CATALOG_TITLE_TAG) + image != null && title != null + } + root.layoutSubtreeIfNeeded() + + val catalogImage = checkNotNull(image) + val catalogTitle = checkNotNull(title) + assertEquals(catalogImage.superview, catalogTitle.superview) + + val imageFrame = catalogImage.frame + imageFrame.useContents { + assertEquals(IMAGE_SIZE, size.width, absoluteTolerance = FRAME_TOLERANCE) + assertEquals(IMAGE_SIZE, size.height, absoluteTolerance = FRAME_TOLERANCE) + } + val imageLeading = catalogImage.alignmentRectForFrame(imageFrame).useContents { origin.x } + val titleFrame = catalogTitle.frame + val titleLeading = catalogTitle.alignmentRectForFrame(titleFrame).useContents { origin.x } + assertEquals(imageLeading, titleLeading, absoluteTolerance = FRAME_TOLERANCE) + } finally { + host.dispose() + window.close() + } + } +} + +private fun NSView.findTaggedView(tag: String): NSView? { + if (valueForKey(ACCESSIBILITY_IDENTIFIER_KEY) == tag) return this + return subviews + .filterIsInstance() + .firstNotNullOfOrNull { child -> child.findTaggedView(tag) } +} + +private fun awaitDemoLayout( + message: String, + condition: () -> Boolean, +) { + val startedAt = TimeSource.Monotonic.markNow() + while (!condition() && startedAt.elapsedNow() < UI_TIMEOUT) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, RUN_LOOP_STEP_SECONDS, true) + } + check(condition()) { message } +} + +private const val ACCESSIBILITY_IDENTIFIER_KEY: String = "accessibilityIdentifier" +private const val CATALOG_IMAGE_TAG: String = "demo-catalog-image" +private const val CATALOG_TITLE_TAG: String = "demo-catalog-title" +private const val HOST_WIDTH: Double = 640.0 +private const val HOST_HEIGHT: Double = 480.0 +private const val IMAGE_SIZE: Double = 64.0 +private const val FRAME_TOLERANCE: Double = 0.5 +private const val RUN_LOOP_STEP_SECONDS: Double = 0.01 +private val UI_TIMEOUT = 5.seconds diff --git a/flareUI/demo/shared/src/macosTest/kotlin/dev/dimension/flare/ui/demo/DemoResourcesMacosTest.kt b/flareUI/demo/shared/src/macosTest/kotlin/dev/dimension/flare/ui/demo/DemoResourcesMacosTest.kt new file mode 100644 index 0000000000..254d301ea9 --- /dev/null +++ b/flareUI/demo/shared/src/macosTest/kotlin/dev/dimension/flare/ui/demo/DemoResourcesMacosTest.kt @@ -0,0 +1,49 @@ +package dev.dimension.flare.ui.demo + +import dev.dimension.flare.ui.demo.resources.DemoRes +import dev.dimension.flare.ui.resources.moko.AppleMokoResourceResolver +import dev.icerock.moko.resources.desc.StringDesc +import dev.icerock.moko.resources.desc.desc +import dev.icerock.moko.resources.format +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +public class DemoResourcesMacosTest { + @Test + public fun resolvesGeneratedStringsPluralsAndImage() { + StringDesc.localeType = StringDesc.LocaleType.Custom("en") + try { + assertEquals( + "Flare UI renderer runtime", + AppleMokoResourceResolver.resolve(DemoRes.strings.demo_title.desc()), + ) + assertEquals( + "Count: 7", + AppleMokoResourceResolver.resolve(DemoRes.strings.count_format.format(7)), + ) + assertEquals( + "1 update", + AppleMokoResourceResolver.resolve(DemoRes.plurals.update_count.format(1, 1)), + ) + assertTrue( + AppleMokoResourceResolver + .resolve(DemoRes.images.flare_mark) + .nsImage + .toString() + .isNotBlank(), + ) + StringDesc.localeType = StringDesc.LocaleType.Custom("zh") + assertEquals( + "Flare UI 渲染运行时", + AppleMokoResourceResolver.resolve(DemoRes.strings.demo_title.desc()), + ) + assertEquals( + "已更新 3 次", + AppleMokoResourceResolver.resolve(DemoRes.plurals.update_count.format(3, 3)), + ) + } finally { + StringDesc.localeType = StringDesc.LocaleType.System + } + } +} diff --git a/flareUI/docs/lazy-layout-research.md b/flareUI/docs/lazy-layout-research.md new file mode 100644 index 0000000000..eb682cf734 --- /dev/null +++ b/flareUI/docs/lazy-layout-research.md @@ -0,0 +1,328 @@ +# FlareUI LazyColumn / LazyRow 一手资料调研 + +> 调研日期:2026-08-27 +> +> 范围:Cash App Redwood、React / React Native、.NET MAUI、Jetpack Compose LazyLayout。 +> 来源规则:仅引用官方文档、官方仓库源码和本仓库源码;源码引用固定到具体提交。 + +## 结论摘要 + +FlareUI 的 lazy list 不应实现为“可滚动的 `Column` / `Row`”。这仍会让 Compose Runtime 先创建全部 item subtree,既没有 composition window,也无法让原生 collection adapter 按需绑定 item。 + +四套方案共同指向一个更合适的分层: + +1. 公共层保存轻量的 item 描述(interval provider),而不是立即 emit 全部 children。 +2. `LazyColumn` / `LazyRow` 共享一个按方向参数化的底层协议,公共 API 只做易用包装。 +3. 每个平台用原生虚拟列表负责 viewport、测量、滚动和 cell pool;公共层负责 item factory、稳定 identity、状态锚点和一致的事件/命令语义。 +4. item 的 `key`、`contentType`、原生 cell recycling、Flare subtree composition/state retention 是四个不同概念,不能合并成一个“复用”开关。 +5. Redwood 的 `itemsBefore + loaded items + itemsAfter + placeholder` 稀疏窗口协议很适合 guest/host 或跨进程边界,但它移除了稳定 key/content type,且 UIKit `LazyRow` 实际未实现;FlareUI 应借架构,不应复制其 API 缺口。 +6. 最接近 FlareUI Kotlin DSL 的行为基线是 Compose `LazyListScope`;最值得借鉴的跨平台 adapter 边界是 MAUI 的 common API + native handler;React Native 则提供了最清楚的可调 window/batch/估算模型。 + +## 当前 FlareUI 约束 + +当前 runtime 中,一个 `FlareWidget` 最多暴露一个 `FlareChildren`;普通 composition 的结构变更直接映射为 backend `insert/move/remove`。[FlareWidget.kt](../runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareWidget.kt) [FlareRuntime.kt](../runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareRuntime.kt) + +当节点从普通 applier tree 移除时,runtime 会递归 dispose 整个 subtree。[RuntimeNode.kt](../runtime/src/commonMain/kotlin/dev/dimension/flare/ui/RuntimeNode.kt) 架构文档也明确把 scrolling 与 native lazy collections 列为尚未实现的能力。[ARCHITECTURE.md](../ARCHITECTURE.md) + +由此可推得:lazy item 不能只是普通 `FlareChildren` 中的全部直接子节点。它需要一个新的 item-provider / item-composition 边界,让 backend 可以只请求当前窗口内的 item;否则“虚拟化”最多只发生在 native view 层,Compose/Flare subtree 仍是全量的。 + +## 横向比较 + +| 维度 | Redwood | React Native `VirtualizedList` | MAUI `CollectionView` | Jetpack Compose | 对 FlareUI 的含义 | +| --- | --- | --- | --- | --- | --- | +| 数据 / DSL | `item`、`items(count)`,内部 interval;无 key/type | `data + getItem + getItemCount + renderItem` | `ItemsSource + DataTemplate` | `item/items/itemsIndexed` interval DSL | 采用 interval provider,避免物化全部 item | +| 可见区与窗口 | native 上报首末可见 index;guest 计算 loaded range | offset/viewport + 已测尺寸或平均尺寸估算;overscan + batch | 公共层不规定算法,委托原生 control | measure pass 从 anchor 向前后填满 viewport | 公共协议传 viewport/anchor;测量尽量留给 native adapter | +| identity / key | global index;稳定 key 被移除 | `keyExtractor`,默认 `key/id/index` | 公共 API 无显式 key;Android item id 是 position | stable unique key,缺省 position | Flare 必须正式支持 stable key;index 只能是降级路径 | +| 回收 / 复用 | native cell pool;可选 subtree reuse;无 content type | 窗口外 React cell unmount,无 type-aware pool contract | native cell 根据模板复用 | composition slot 按 content type 兼容复用 | `contentType` 映射 native view type/reuse id;与 key 分开 | +| item 状态 | 离开 loaded window 后重新 compose;state 只存 index | 窗口外内部状态不保留 | cell 被重新绑定,公共层无 item-state 保留保证 | keyed `SaveableStateHolder` 可保留可保存状态 | 明确 v1 状态契约;业务状态默认 hoist,keyed saveable registry 可后续增强 | +| 滚动控制 | index + animated;恢复只存 index | index/item/offset/end;未测量目标可能失败 | index/item + Start/Center/End/MakeVisible | index + offset;立即/动画;layout info | state 至少包含 index、offset、visible info;命令需有 alignment/失败语义 | +| 异构 item | DSL 可异构,但单一通用 cell/type | `renderItem` 可异构,无显式 reuse type | `DataTemplateSelector`,reuse id 含模板 | `contentType` 控制兼容复用 | `contentType` 是一等 API,不从 composable shape 猜测 | +| cache / prefetch | 按 item 数、方向调整 loaded window | `windowSize`、batch size、batch period、优先级 | 依赖原生 control,无统一 cache knob | beyond-bounds、type-aware slot cache、frame-budget prefetch | 公共层定义 hint/事件,不承诺各平台完全相同的内部常量 | +| 增量数据加载 | 与 UI windowing 分离 | `onEndReached` | `RemainingItemsThreshold` | Paging 集成 | 与 UI 虚拟化分成独立能力,要求调用方去重/背压 | +| 跨平台 adapter | 同一 widget protocol,各平台完成度不一致 | RN 自身 Android/iOS host | common API 映射 native handlers | 同一 Compose layout engine | 为每个平台做 capability tests,不能仅凭统一接口宣称行为一致 | + +## 1. Cash App Redwood + +### 1.1 状态与适用范围 + +Redwood 官方已声明 0.19 是可预见未来的最终版本,项目停止活跃开发,因此它适合作为架构案例,不适合作为会继续收敛行为的依赖基线。[Redwood CHANGELOG](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/CHANGELOG.md#L11-L15) + +本节源码固定到 [`5c49a0b`](https://github.com/cashapp/redwood/commit/5c49a0bcc224b7fef10316bfdeb227639bcc42ec)。 + +### 1.2 数据 DSL 与 composition window + +Redwood 的 `LazyListScope` 只有 `item {}` 和 `items(count) { index -> }`,List/Array 只是便利扩展;多个 interval 可以描述异构内容,而不会先创建全部 item。[LazyDsl.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyDsl.kt#L25-L107) DSL 会被压缩为 interval,并在给定 global index 时定位对应 factory。[LazyListIntervalContent.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListIntervalContent.kt#L25-L58) + +端到端流程是: + +```text +LazyListScope intervals + │ + ▼ +LoadingStrategy.loadRange(totalCount) + │ + ├─ itemsBefore + ├─ loaded item subtrees + ├─ itemsAfter + └─ placeholder subtree pool + │ + ▼ +LazyList widget protocol + │ + ▼ +native virtual list + sparse bindings +``` + +guest 只 compose `loadRange` 中的 item,并向 host 发送 `itemsBefore`、实际 `items`、`itemsAfter` 和 20 个预建 placeholder。[LazyList.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt#L40-L65) 这些字段连同方向、viewport callback 和 scroll command 是正式 widget schema;`LazyColumn` 与 `LazyRow` 共用同一个 widget,通过 `isVertical` 区分。[widgets.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-schema/src/main/kotlin/app/cash/redwood/lazylayout/widgets.kt#L34-L47) + +`LazyListUpdateProcessor` 明确维护两个窗口:已加载窗口与用户可见窗口。用户滚出已加载区域时先绑定 placeholder,真实 subtree 到达后在同一 binding/cell 中替换。[LazyListUpdateProcessor.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt#L21-L57) `itemsBefore/itemsAfter` 使用稀疏结构,逻辑上一百万个未加载位置不等于创建一百万个 placeholder 对象。[SparseList.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/SparseList.kt#L18-L60) + +这个协议尤其适合 Treehouse 这类 guest/host 或序列化边界。对当前同进程 FlareUI,它更像可选的高级协议:native adapter 可以直接向公共 item provider 请求 index;但如果未来要跨进程、跨语言或异步产生 subtree,`itemsBefore/itemsAfter + sparse placeholder` 是经过验证的模型。 + +### 1.3 viewport 与 preload + +`LoadingStrategy` 只接收首末可见 index,并返回需要进入 view tree 的 range;接口要求 range 覆盖最近 viewport,并允许两侧预加载。[LoadingStrategy.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LoadingStrategy.kt#L18-L50) + +默认 `ScrollOptimizedLoadingStrategy` 按 item 数而非像素工作:初始向后 15 项;滚动时主方向 20、反方向 5;停止后主方向 20、另一侧 10;窗口连续时尽量保留旧 range,减少 churn。[ScrollOptimizedLoadingStrategy.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/ScrollOptimizedLoadingStrategy.kt#L24-L45) [窗口算法](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/ScrollOptimizedLoadingStrategy.kt#L81-L151) + +优点是简单且跨平台;缺点是 20 个 24dp 行和 20 个全屏卡片的成本完全不同。FlareUI 可保留“方向偏置 + 滚动结束扩窗”的思想,但不宜把固定 item count 当成唯一策略。 + +### 1.4 key、状态与滚动 + +Redwood 从 Compose lazy 实现移植 interval 代码时,明确移除了 keys、content types、sticky headers。[LazyListIntervalContent.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListIntervalContent.kt#L22-L24) 实际 composition 使用 `key(index)`,因此 identity 是位置,不是业务对象。[LazyList.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt#L59-L62) + +item 留在 loaded window 内不会反复 compose;离开窗口后回来会重新 compose。[LazyListTest.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonTest/kotlin/app/cash/redwood/lazylayout/compose/LazyListTest.kt#L112-L157) `LazyListState` 的 saver 只保存首个可见 index,没有 offset、anchor key 或 layout info。[LazyListState.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListState.kt#L29-L46) + +程序滚动只有 `index + animated`;递增 command id 使同一目标也能再次触发。host 会等逻辑 item count 足够后才执行,并去重相同 viewport 回调。[LazyListState.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListState.kt#L53-L98) [LazyListScrollProcessor.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListScrollProcessor.kt#L20-L65) + +这些选择会让 prepend、reorder、item 状态跟随业务对象等场景变弱;而 update processor 对 children move 直接报错。[LazyListUpdateProcessor.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt#L241-L269) FlareUI 不应复制 index-only identity。 + +### 1.5 placeholder、cell recycling 与 subtree reuse + +Redwood 要求每次 placeholder composition 内容和尺寸相同。[LazyDsl.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyDsl.kt#L118-L133) 20 个真实 placeholder 耗尽后,host 以第一个 placeholder 的尺寸制造 size-only placeholder。[LazyListUpdateProcessor.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt#L409-L434) 这对高度差异很大的异构列表只是粗略 scroll extent。 + +Redwood 实际有相互独立的复用层: + +- loaded composition window:离开窗口即卸载。 +- placeholder pool:真实 placeholder 加 size-only clone。 +- native cell pool:Android `RecyclerView` 与 UIKit table cell 各自回收 container。 +- Treehouse subtree pool:只有 item 根显式 `Modifier.reuse()` 时才启用,按 widget shape 匹配,并不是 LazyList 自动行为。[HostProtocolAdapter.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt#L190-L284) [NodeReuse.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/NodeReuse.kt#L23-L55) + +因此“回收一个原生 container”不等于“保留这个业务 item 的 composition/state”,也不等于“把旧 subtree 安全绑定给新 key”。FlareUI 的协议需要分别表达这三件事。 + +### 1.6 各平台完成度与明确限制 + +| Redwood backend | 实现 | 已知事实 | +| --- | --- | --- | +| Android View | `RecyclerView + LinearLayoutManager` | Row/Column 均支持;所有内容使用同一 view type,pool 上限硬编码为 30。[ViewLazyList.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt#L90-L141) | +| UIKit | `UITableView` | **`isVertical` 是空实现,并有 `TODO: support horizontal LazyLists`;即 API 暴露 `LazyRow`,UIKit 实际不支持。** width/height/cross-axis 也未实现。[UIViewLazyList.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UIViewLazyList.kt#L274-L317) | +| Compose UI | Compose `LazyColumn` / `LazyRow` | 两方向可用,但保存的 `itemsBefore/itemsAfter` 没有加入 native list,源码标有 `TODO Fix item count truncation`;滚动也始终走非动画 `scrollToItem`。[ComposeUiLazyList.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiLazyList.kt#L124-L210) | +| DOM | flex/overflow + observer | 只增长最高可见 index,不卸载已滚出项;程序滚动等能力未完成。[HTMLLazyList.kt](https://github.com/cashapp/redwood/blob/5c49a0bcc224b7fef10316bfdeb227639bcc42ec/redwood-lazylayout-dom/src/commonMain/kotlin/app/cash/redwood/lazylayout/dom/HTMLLazyList.kt#L130-L192) | + +Redwood 最重要的反例是:统一 schema 并不自动带来平台语义对齐。FlareUI 的 `LazyRow` 必须从第一阶段就在 UIKit/AppKit/Android/Compose backend 的 capability test 中出现。 + +## 2. React 与 React Native + +React 源码固定到 [`29d9d31`](https://github.com/facebook/react/commit/29d9d3184484b03cb0369e0494617207df777b7af),React Native 源码固定到 [`d6ba88e`](https://github.com/facebook/react-native/commit/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e)。 + +### 2.1 React key 是 reconciliation identity,不是缓存策略 + +React 官方文档要求列表 key 在同级中稳定且唯一;用 index 处理会发生插入、删除、重排时的错误匹配,随机 key 则导致每次重建并丢失输入状态。[Rendering Lists](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key) + +reconciler 源码会把旧 children 放进 key map;无 key 的 child 才退化为 index。新 child 按 `key ?? index` 匹配,未消费的旧 child 被删除。[ReactChildFiber.js:构建 map](https://github.com/facebook/react/blob/29d9d3184484b03cb0369e0494617207df777b7af/packages/react-reconciler/src/ReactChildFiber.js#L467-L499) [按 key 匹配](https://github.com/facebook/react/blob/29d9d3184484b03cb0369e0494617207df777b7af/packages/react-reconciler/src/ReactChildFiber.js#L985-L1019) [move/delete 阶段](https://github.com/facebook/react/blob/29d9d3184484b03cb0369e0494617207df777b7af/packages/react-reconciler/src/ReactChildFiber.js#L1308-L1361) + +React state 绑定到 render tree 中的 identity/position;key 可以改变该 identity,但节点真的 unmount 后 state 仍会被销毁。[Preserving and Resetting State](https://react.dev/learn/preserving-and-resetting-state#state-is-tied-to-a-position-in-the-render-tree) + +对 FlareUI 的直接含义是:`key` 能解决窗口内重排与 keyed state registry 的寻址,但它本身不会虚拟化,也不会让已 dispose 的 subtree 自动复活。 + +### 2.2 VirtualizedList 的数据接口与窗口算法 + +React Native `FlatList` 是 `VirtualizedList` 的便利封装。后者用 `data + getItem(data,index) + getItemCount(data) + renderItem` 支持普通数组之外的数据结构;`horizontal` 让同一实现覆盖 row/column。[VirtualizedList 官方文档](https://reactnative.dev/docs/virtualizedlist) [FlatList 官方文档](https://reactnative.dev/docs/flatlist) + +窗口计算以 scroll offset、visible length 和 cell metrics 为输入: + +1. 可见像素区间是 `[offset, offset + visibleLength]`。 +2. overscan 总长度为 `(windowSize - 1) * visibleLength`;默认 `windowSize = 21`,即当前屏加前后最多各约 10 屏。 +3. 算法把像素边界映射为 item index,再围绕可见区扩张;扩张方向受速度方向影响,且单批新增 cell 受 `maxToRenderPerBatch` 限制(默认 10)。[VirtualizeUtils.js](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/VirtualizeUtils.js#L89-L243) [默认参数](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/VirtualizedListProps.js#L305-L334) +4. 已测 item 使用精确 frame;未测 item 使用已测平均长度,并尽量从最高已测 frame 外推;`getItemLayout` 可以为固定/可计算尺寸提供精确 offset。[ListMetricsAggregator.js](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/ListMetricsAggregator.js#L168-L242) +5. 窗口外区域在滚动内容中表现为合适尺寸的空白 spacer;远离 viewport 的 item 低优先级批量渲染,靠近 viewport 的 item 高优先级渲染。官方明确承认快速滚动可能暂时看到空白。[VirtualizedList 官方文档](https://reactnative.dev/docs/virtualizedlist) + +这套算法揭示了三个独立旋钮:空间窗口大小、每批工作量、批次时间间隔。FlareUI 即便让 native adapter 主导算法,也应在内部把它们作为独立概念,而不是只有一个 `cacheItemCount`。 + +### 2.3 key、状态、回收与滚动 + +默认 key extractor 依次使用对象的 `key`、`id`、最后才是 index。[VirtualizeUtils.js](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/VirtualizeUtils.js#L246-L254) 解析后的 key 同时用作 React element key、cell key 和 ref map key。[VirtualizedList.js](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/VirtualizedList.js#L785-L846) + +不过 `VirtualizedList` 官方文档明确说明,item 滚出 render window 后其内部状态不保留,应把状态放进 item data 或外部 store。它也是 `PureComponent`,依赖 render 的外部值需要通过不可变 data 或 `extraData` 触发更新;异步填充窗口可能出现 blank area。[VirtualizedList 官方文档](https://reactnative.dev/docs/virtualizedlist) + +这是一种“卸载并重建”的 composition 策略,不是 MAUI/RecyclerView 那种面向模板类型的公共 cell pool contract。`CellRendererComponent` 允许替换 container,但 API 没有 Compose `contentType` 等价物;`removeClippedSubviews` 只是把不可见原生 view 从 native hierarchy detach,官方也警告它可能造成缺失内容,不能把它当 item state cache。[FlatList 官方文档](https://reactnative.dev/docs/flatlist) + +滚动 API 支持 index、item、offset、end。目标 index 尚未测量且无 `getItemLayout` 时,`scrollToIndex` 可能失败,回调只提供最高已测 index 和平均长度,调用方需先滚到可达位置再重试。[VirtualizedListProps.js](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/VirtualizedListProps.js#L206-L215) `maintainVisibleContentPosition` 会记录首个可见 key,在头部插入后寻找它的新 index 并平移窗口。[VirtualizedList.js](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/VirtualizedList.js#L729-L782) + +### 2.4 已知限制 + +- `key` 只在节点仍参与 reconciliation 时保留 React state;virtualizer unmount 后不能依靠 key 保存内部 state。 +- 未知尺寸只能估算,远距离 `scrollToIndex` 需要固定布局信息或失败/纠正流程。 +- 大 window 降低 blank 风险但增加内存;大 batch 提高 fill rate 但阻塞交互。 +- `FlatList.numColumns` 要求同一行 item 高度一致,不是 masonry。[FlatList 官方文档](https://reactnative.dev/docs/flatlist) +- `removeClippedSubviews` 有 missing-content 风险;不能作为跨平台必选优化。[VirtualizedListProps.js](https://github.com/facebook/react-native/blob/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e/packages/virtualized-lists/Lists/VirtualizedListProps.js#L253-L267) + +## 3. .NET MAUI CollectionView / ItemsView + +MAUI 源码固定到 [`073c90c`](https://github.com/dotnet/maui/commit/073c90c8911e2e7adc0082a13ef5a3a22d4b4d29)。 + +### 3.1 common API 与 native-handler 边界 + +MAUI `CollectionView` 的公共模型是 `ItemsSource: IEnumerable` 加 `ItemTemplate: DataTemplate`。它不公开 cell 概念,并明确说明自动使用底层原生 control 的 virtualization;vertical/horizontal list 与 grid 都由 layout 配置表达。[CollectionView 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/?view=net-maui-10.0) [layout 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/layout?view=net-maui-10.0) + +这是对 FlareUI 很重要的先例:common API 不需要发明一套跨平台像素布局/回收引擎。它可以定义 item provider、identity、type、滚动和可见区语义,再由 Android `RecyclerView`、UIKit `UICollectionView`、AppKit `NSCollectionView`、Compose LazyList 执行平台算法。 + +MAUI 当前文档也说明 iOS/Mac Catalyst 的优化 handler 已成为 .NET 10 默认,.NET 11 Windows handler 基于 WinUI `ItemsRepeater` 以改善 virtualization/scrolling。这进一步表明其性能路径是持续向原生 virtual control 收敛,而不是在 common 层统一实现窗口算法。[CollectionView 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/?view=net-maui-10.0) + +### 3.2 template type 与回收 + +Android `ItemsViewAdapter` 直接继承 `RecyclerView.Adapter`。使用 `DataTemplateSelector` 时,所选模板 id 成为 view type 并被缓存;bind 时取 position 对应数据,recycle 时通知 holder。[ItemsViewAdapter.cs](https://github.com/dotnet/maui/blob/073c90c8911e2e7adc0082a13ef5a3a22d4b4d29/src/Controls/src/Core/Handlers/Items/Android/Adapters/ItemsViewAdapter.cs#L7-L143) + +`TemplatedItemViewHolder` 在模板不变时保留现有 view,仅替换 `BindingContext`;模板改变时回收旧 content 并重新 `CreateContent()`。被 RecyclerView 回收时,它从 MAUI logical children 中移除,再绑定时重新加入。[TemplatedItemViewHolder.cs](https://github.com/dotnet/maui/blob/073c90c8911e2e7adc0082a13ef5a3a22d4b4d29/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs#L35-L88) + +iOS 优化 handler 使用 `UICollectionView.DequeueReusableCell`;reuse id 包含 cell 类型、方向和选中的 `DataTemplate.Id`,因此不同模板和方向不会误入同一 pool。[ItemsViewController2.cs](https://github.com/dotnet/maui/blob/073c90c8911e2e7adc0082a13ef5a3a22d4b4d29/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs#L111-L130) [reuse id](https://github.com/dotnet/maui/blob/073c90c8911e2e7adc0082a13ef5a3a22d4b4d29/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs#L407-L426) + +这正是 FlareUI `contentType` 的 native 映射:Android view type / iOS reuse id / AppKit item identifier / Compose slot compatibility。它不是业务 identity。 + +### 3.3 identity 与状态 + +MAUI 的公共 CollectionView API 没有与 React/Compose stable key 对等的一等参数;Android adapter 的 `GetItemId(position)` 直接返回 position。[ItemsViewAdapter.cs](https://github.com/dotnet/maui/blob/073c90c8911e2e7adc0082a13ef5a3a22d4b4d29/src/Controls/src/Core/Handlers/Items/Android/Adapters/ItemsViewAdapter.cs#L140-L147) 数据变更依靠可观察 collection 通知,且 ItemsSource 更新必须发生在 UI thread。[populate data 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/populate-data?view=net-maui-10.0) + +由源码可作出的保守推论是:复用 cell 中的 view-local transient state 会随 holder 被重新绑定,框架并没有承诺它跟随某个业务 item;业务状态应在 model/view-model 中。FlareUI 若已有 Compose Runtime state,应比 MAUI 多提供一层明确的 keyed state 语义,而不是依赖 native cell 恰好未被回收。 + +### 3.4 测量、滚动与增量加载 + +MAUI 默认 `MeasureAllItems`;`MeasureFirstItem` 只测第一项并把尺寸用于后续项,在 item 尺寸统一时性能更好。[layout 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/layout?view=net-maui-10.0#item-sizing) 这与 FlareUI 可提供的 `estimatedItemSize` / fixed-extent hint 类似,但动态尺寸列表不能误用首项尺寸作为精确值。 + +`Scrolled` 事件提供 horizontal/vertical offset 与 first/center/last visible index。`ScrollTo` 可按 index 或 item 定位,支持 animation 与 `MakeVisible/Start/Center/End` alignment。[scrolling 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/scrolling?view=net-maui-10.0) 数据插入时,`ItemsUpdatingScrollMode` 提供 `KeepItemsInView`、`KeepScrollOffset`、`KeepLastItemInView` 三种策略。[scroll position 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/scrolling?view=net-maui-10.0#control-scroll-position-when-new-items-are-added) + +增量数据加载通过 `RemainingItemsThreshold`、command 和 event 触发;`-1` 禁用、`0` 到末尾触发、正数表示剩余 item 阈值。[populate data 官方文档](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/populate-data?view=net-maui-10.0#load-data-incrementally) 这只是“何时请求更多业务数据”,不等于 UI subtree window。 + +### 3.5 已知限制与启示 + +- MAUI 不公开统一 cache/prefetch 大小,具体行为属于 native handler;FlareUI 的公共 cache 参数宜定义为 hint,而不是逐平台像素级保证。 +- `MeasureFirstItem` 只适合同尺寸 item;异构高度必须允许各 item 实测。 +- 把 CollectionView 放进不能提供有界 viewport 的 StackLayout,可能阻止滚动;把 `ItemsLayout` 设为 StackLayout-based layout 会关闭 virtualization、全量测量渲染,并让增量加载阈值连续触发。[populate data 官方警告](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/collectionview/populate-data?view=net-maui-10.0#load-data-incrementally) +- template selection 可以解决异构复用,但没有 stable business key;FlareUI 需要把 MAUI handler 架构与 React/Compose identity 语义组合起来。 + +## 4. Jetpack Compose LazyLayout 基线 + +AndroidX 源码固定到 [`6ae639f`](https://github.com/androidx/androidx/commit/6ae639fbaf432d072d7743936127a93c6e82aa2e)。 + +### 4.1 DSL、key 与 content type + +`LazyListScope.item/items` 原生支持 stable unique `key` 与 `contentType`。key 缺省时 position 充当 identity;提供 key 后,在当前可见项之前插入/删除数据时,会尽量保持该 key 仍为首个可见项。相同 `contentType` 的 item composition 才被认为可兼容复用。[LazyDsl.kt](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt#L33-L88) + +这套接口是 FlareUI 最合适的 public DSL 基线:它符合 Kotlin/Compose 用户预期,也同时提供 native adapter 所需的 business key 与 reuse type。 + +### 4.2 composition 与 measure window + +底层 `LazyLayout` 的定义就是“只 compose/layout 当前需要的 item”;measure pass 主动请求某个 item,即表示它当前需要被 composition。[LazyLayout.kt](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt#L32-L39) + +LazyList measure 从已知 `firstVisibleItemIndex + scrollOffset` 开始:offset 为负时向前测量,再向后测量直到填满 viewport;完全离屏的已测 item 会从 visible set 移除。随后额外加入 beyond-bounds 与 pinned items。[LazyListMeasure.kt](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt#L140-L266) [extra items](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt#L321-L344) + +FlareUI 不必把这套 measure 算法复制到 `RecyclerView` / `UICollectionView`,但应保留同样的状态输入输出:anchor index/key、anchor offset、visible item info、viewport range,以及可选 beyond-bounds/prefetch 请求。 + +### 4.3 slot reuse、状态与 prefetch + +Compose 的 lazy slot 只有 `contentType` 相同才兼容;当前实现每种 type 最多保留 7 个 slot,常量解释为 RecyclerView 默认 5 个 pool + 2 个 cache。[LazyLayout.kt](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt#L148-L173) 这是实现细节,不应成为 FlareUI public contract 的固定数字。 + +item content factory 以 key 缓存 content lambda;当 item 移动时,它通过 key 重新查 index,并在 `SaveableStateProvider(key)` 下运行 item content。[LazyLayoutItemContentFactory.kt](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemContentFactory.kt#L48-L128) 这解释了为什么 stable key 能让可保存的 item state 跟随业务项,但普通未保存状态仍不应被当作无限期 cache。 + +`LazyListState` 暴露首个可见 index/offset 和 layout info,提供 `scrollToItem`、`requestScrollToItem`、`animateScrollToItem`;默认 saver 保存 index 与 offset。使用 custom key 时,它还会在头部插入/删除后寻找原首项的新位置。[LazyListState.kt](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt#L251-L264) [滚动 API](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt#L439-L473) [动画与 saver](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt#L631-L742) + +prefetch API 区分只 precompose 和 precompose + premeasure;调度器按 `contentType` 维护耗时移动平均,只在 frame budget 足够时执行,urgent 请求可提升优先级,并支持 nested prefetch。[LazyLayoutPrefetchState.kt](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt#L140-L226) [type-aware metrics](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt#L339-L408) [frame-budget execution](https://github.com/androidx/androidx/blob/6ae639fbaf432d072d7743936127a93c6e82aa2e/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt#L565-L707) + +### 4.4 已知限制 + +Compose 官方文档提醒:item 初始为 0 像素时,lazy layout 可能先 compose 全部 item;异步内容出现后尺寸变化又会使滚动位置失真。Paging placeholder 也应有接近真实内容的尺寸。[Lists 官方文档](https://developer.android.com/develop/ui/compose/lists) + +因此 FlareUI 必须要求 lazy list 获得有界 main-axis viewport,并为未加载/未测量 item 提供非零、合理的 extent estimate;否则任何 window 算法都可能退化为全量工作。 + +## 5. 对 FlareUI 落地的研究结论 + +以下是由上述实现共同支持的设计约束,不是最终 API 定稿。 + +### 5.1 数据与 DSL + +- `LazyColumn` / `LazyRow` 应共享同一个内部 `LazyList(orientation)` primitive 和 controller,避免两个方向形成两套协议。 +- 公共 DSL 至少需要 `item`、`items(count)`、List/Array `items`、`itemsIndexed`;内部保存 interval,不 emit 全量 subtree。 +- 每项应有 `key` 和 `contentType`。`key` 是业务 identity;`contentType` 是复用兼容性。允许缺省 key 时,只能明确文档化为 position fallback,并在 debug 模式检查重复 key。 +- heterogeneous item 不要求不同 API;多个 interval / `contentType` 即可描述。不要从实际 widget tree shape 动态猜 type。 + +### 5.2 runtime seam + +最关键的新抽象不是 `ScrollView`,而是“按 index/key 创建一个独立 item subtree”的能力。可行边界应满足: + +- backend adapter 能请求、绑定、解绑某个 item,而无需让主 `FlareApplier` 持有全部 item child。 +- 每个 realized item 有独立 composition 生命周期,或有等价的可重用 composition slot;离开窗口后是 dispose、进入有界 hot cache,还是保存可恢复 state,应由明确策略决定。 +- 原生 cell pool 按 `contentType` 工作;业务 key 不用作 reuse id。 +- list dataset 更新应批处理为 insert/remove/move/change 或 snapshot diff,不能把原生 adapter 暴露在 Compose apply transaction 的中间状态。Redwood 的 `onEndChanges` 批处理是直接先例。 + +### 5.3 viewport、测量与窗口 + +- native adapter 是实际 viewport 与 measurement 的 source of truth:Android `RecyclerView`、UIKit `UICollectionView`、AppKit `NSCollectionView`、Compose `LazyColumn/Row`。 +- 公共反馈至少包括 first/last visible index、每个可见项 key/index/offset/size、viewport start/end、scroll direction/velocity(若平台可得)。 +- 对未知尺寸同时支持 fixed extent / per-type estimate / measured cache。只用全局平均值会在异构列表中产生明显误差;只用 Redwood 单一 placeholder 尺寸更弱。 +- cache window、每批 composition 数、prefetch 调度应分开。平台可以解释为 hint;不能要求所有 backend 使用相同常量。 +- prepend/update 时用 `firstVisibleKey + intra-item offset` 作 anchor;仅保存 index 不足以抵抗重排。 + +### 5.4 回收与状态 + +建议在设计文档中明确区分: + +| 层 | 寻址依据 | 目的 | 是否保留业务状态 | +| --- | --- | --- | --- | +| 尺寸缓存 | key,必要时 type | 估算 offset/extent | 否 | +| 原生 cell pool | contentType | 少创建 native container | 否 | +| composition slot pool | contentType/shape | 少做 subtree 初始化 | 不应隐式保证 | +| keyed state registry | key | item 离窗后恢复可保存状态 | 是,且必须有容量/可保存性边界 | + +v1 可以采用保守契约:离开 composition window 后 item-local transient state 不保证保留,业务状态必须 hoist;同时预留 keyed saveable-state registry。不要让 RecyclerView/UICollectionView 恰好保留 view 的行为成为跨平台语义。 + +### 5.5 滚动与增量加载 + +- `LazyListState` 至少应观察 index、offset、visible items/layout info,并提供立即/动画滚到 index 的命令。 +- alignment 应从一开始统一为 `Start/Center/End/MakeVisible` 或等价集合;远距离目标尺寸未知时,需要失败/估算后纠正的定义。 +- 数据更新时至少定义 keep-anchor、keep-offset、keep-end 三类策略,覆盖 feed prepend、普通列表和 chat。 +- `onEndReached` / prefetch callback 属于业务数据加载,不是 UI virtualization。需要文档化去重、并发与“加载中”控制,避免 MAUI 所示的无界 viewport 连续触发。 + +### 5.6 跨平台 adapter 建议 + +| Flare backend | 推荐底座 | 必须验证 | +| --- | --- | --- | +| Android View | `RecyclerView + LinearLayoutManager` | vertical/horizontal、stable anchor、view type、variable size、fast fling | +| UIKit | `UICollectionView` + list/compositional layout | **LazyRow 真正工作**、self-sizing、batch update、reuse id | +| AppKit | `NSCollectionView` | 两方向、self-sizing、selection/focus 与 reuse | +| Compose UI | Compose `LazyColumn/LazyRow` | global index 不被 loaded window 截断、key/type 透传、避免双重 windowing | + +Compose backend 尤其要避免 Redwood 的问题:若公共层先截取 loaded window,再把局部数组交给 Compose LazyList,必须显式维护 global index/total extent;更简单的方向是让 Compose backend 直接消费同一个 logical item provider,由 Compose LazyList 自己决定 realized window。 + +### 5.7 最低验收矩阵 + +在宣布 `LazyColumn` / `LazyRow` 可用前,四个 backend 都应覆盖: + +- 空列表、单项、百万逻辑项但只 realized 小窗口。 +- 首屏、快速 fling、远距离 `scrollToItem`、重复滚到同一 index。 +- prepend/append/insert/remove/move/change、数据缩短到当前 anchor 之前。 +- stable key 跟随 reorder;重复 key 明确失败;无 key 的 position fallback 行为明确。 +- 多 `contentType`、同 type 重绑、type 改变、动态高度/宽度。 +- item 离窗再回来时,hoisted state 与可保存 state 的约定一致。 +- list 获得无界 main-axis constraint 时 fail-fast 或明确退化,不能静默 compose 全量。 +- nested lazy list、同方向嵌套、LazyRow in LazyColumn 的最小支持边界。 +- Android、UIKit、AppKit、Compose 上 LazyColumn 与 LazyRow 对称通过;不能重复 Redwood “接口存在但 UIKit 横向为空实现”的缺陷。 + +## 固定源码版本 + +| 项目 | 提交 | +| --- | --- | +| Cash App Redwood | [`5c49a0bcc224b7fef10316bfdeb227639bcc42ec`](https://github.com/cashapp/redwood/commit/5c49a0bcc224b7fef10316bfdeb227639bcc42ec) | +| React | [`29d9d3184484b03cb0369e0494617207df777b7af`](https://github.com/facebook/react/commit/29d9d3184484b03cb0369e0494617207df777b7af) | +| React Native | [`d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e`](https://github.com/facebook/react-native/commit/d6ba88e16d1cc42c0e90a31eb6586586df2e9d5e) | +| .NET MAUI | [`073c90c8911e2e7adc0082a13ef5a3a22d4b4d29`](https://github.com/dotnet/maui/commit/073c90c8911e2e7adc0082a13ef5a3a22d4b4d29) | +| AndroidX | [`6ae639fbaf432d072d7743936127a93c6e82aa2e`](https://github.com/androidx/androidx/commit/6ae639fbaf432d072d7743936127a93c6e82aa2e) | diff --git a/flareUI/foundation/build.gradle.kts b/flareUI/foundation/build.gradle.kts new file mode 100644 index 0000000000..c98225967c --- /dev/null +++ b/flareUI/foundation/build.gradle.kts @@ -0,0 +1,55 @@ +import dev.dimension.flareui.buildlogic.FlareUiPlatform +import dev.dimension.flareui.buildlogic.flareUi + +plugins { + id("dev.dimension.flareui.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flareUi { + namespace = "dev.dimension.flare.ui.foundation" + platforms( + FlareUiPlatform.ANDROID, + FlareUiPlatform.JVM, + FlareUiPlatform.IOS, + FlareUiPlatform.MACOS, + ) + } + android { + withHostTest { + isIncludeAndroidResources = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":flare-runtime")) + } + } + val androidMain by getting { + dependencies { + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.material.components) + } + } + val androidHostTest by getting { + dependencies { + implementation(libs.compose.ui.test.junit4) + implementation(libs.compose.ui.test.manifest) + implementation(libs.junit) + implementation(libs.robolectric) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/flareUI/foundation/src/androidHostTest/kotlin/dev/dimension/flare/ui/android/FlareAndroidViewHostTest.kt b/flareUI/foundation/src/androidHostTest/kotlin/dev/dimension/flare/ui/android/FlareAndroidViewHostTest.kt new file mode 100644 index 0000000000..bbc36efd89 --- /dev/null +++ b/flareUI/foundation/src/androidHostTest/kotlin/dev/dimension/flare/ui/android/FlareAndroidViewHostTest.kt @@ -0,0 +1,92 @@ +package dev.dimension.flare.ui.android + +import android.app.Activity +import android.os.Looper +import android.view.ContextThemeWrapper +import android.view.Gravity +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.google.android.material.button.MaterialButton +import com.google.android.material.textview.MaterialTextView +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.foundation.Column +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.NativeButton +import dev.dimension.flare.ui.foundation.Text +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.time.Duration +import kotlin.math.roundToInt +import com.google.android.material.R as MaterialR + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@LooperMode(LooperMode.Mode.PAUSED) +public class FlareAndroidViewHostTest { + @Test + public fun rendersAndRecomposesInPlace() { + val controller = Robolectric.buildActivity(Activity::class.java).setup() + val activity = controller.get() + val context = ContextThemeWrapper(activity, MaterialR.style.Theme_Material3_DayNight) + val host = + FlareAndroidViewHost( + context = context, + widgetSystem = createAndroidWidgetSystem(), + ) + + try { + activity.setContentView(host) + host.setContent { + var count by remember { mutableIntStateOf(0) } + Column( + spacing = 12f, + horizontalAlignment = HorizontalAlignment.End, + ) { + Text( + text = "Count $count", + modifier = FlareModifier(testTag = "count"), + ) + NativeButton( + label = "Increment", + onClick = { count += 1 }, + ) + } + } + shadowOf(Looper.getMainLooper()).idle() + + val column = host.getChildAt(0) as LinearLayout + val label = column.getChildAt(0) as MaterialTextView + val button = column.getChildAt(1) as MaterialButton + assertEquals("Count 0", label.text.toString()) + assertEquals("count", label.tag) + assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, column.layoutParams.width) + assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, label.layoutParams.width) + assertEquals(Gravity.TOP or Gravity.END, column.gravity) + assertEquals( + (12 * activity.resources.displayMetrics.density).roundToInt(), + column.dividerDrawable.intrinsicHeight, + ) + + button.performClick() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + + assertSame(label, column.getChildAt(0)) + assertEquals("Count 1", label.text.toString()) + } finally { + host.disposeComposition() + controller.pause().stop().destroy() + shadowOf(Looper.getMainLooper()).idle() + } + } +} diff --git a/flareUI/foundation/src/androidHostTest/kotlin/dev/dimension/flare/ui/compose/FlareComposeHostTest.kt b/flareUI/foundation/src/androidHostTest/kotlin/dev/dimension/flare/ui/compose/FlareComposeHostTest.kt new file mode 100644 index 0000000000..67611cdfba --- /dev/null +++ b/flareUI/foundation/src/androidHostTest/kotlin/dev/dimension/flare/ui/compose/FlareComposeHostTest.kt @@ -0,0 +1,92 @@ +package dev.dimension.flare.ui.compose + +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.foundation.Column +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.NativeButton +import dev.dimension.flare.ui.foundation.Text +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +public class FlareComposeHostTest { + @get:Rule + public val composeRule = createComposeRule() + + @Test + public fun rendersFoundationAndComposeOnlyContent() { + composeRule.setContent { + MaterialTheme { + FlareComposeHost(widgetSystem = createAndroidComposeWidgetSystem()) { + var count by remember { mutableIntStateOf(0) } + Column( + spacing = 12f, + horizontalAlignment = HorizontalAlignment.End, + ) { + Text( + text = "Count $count", + modifier = FlareModifier(testTag = "count"), + ) + AndroidCompose { + BasicText( + text = "Compose only $count", + modifier = Modifier.testTag("compose-only"), + ) + } + NativeButton( + label = "Increment", + modifier = FlareModifier(testTag = "increment"), + onClick = { count += 1 }, + ) + } + } + } + } + + composeRule + .onNodeWithTag("count") + .assertTextEquals("Count 0") + composeRule + .onNodeWithTag("compose-only") + .assertTextEquals("Compose only 0") + composeRule + .onNodeWithTag("increment") + .assertHeightIsAtLeast(40.dp) + + val countBounds = composeRule.onNodeWithTag("count").getUnclippedBoundsInRoot() + val composeBounds = composeRule.onNodeWithTag("compose-only").getUnclippedBoundsInRoot() + assertTrue(countBounds.left > composeBounds.left) + assertEquals(12f, (composeBounds.top - countBounds.bottom).value, 0.1f) + + composeRule + .onNodeWithTag("increment") + .performClick() + composeRule + .onNodeWithTag("count") + .assertTextEquals("Count 1") + composeRule + .onNodeWithTag("compose-only") + .assertTextEquals("Compose only 1") + } +} diff --git a/flareUI/foundation/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidFoundationRenderer.kt b/flareUI/foundation/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidFoundationRenderer.kt new file mode 100644 index 0000000000..1f02612561 --- /dev/null +++ b/flareUI/foundation/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidFoundationRenderer.kt @@ -0,0 +1,162 @@ +package dev.dimension.flare.ui.android + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.widget.LinearLayout +import com.google.android.material.button.MaterialButton +import com.google.android.material.textview.MaterialTextView +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.foundation.ColumnWidget +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.NativeButtonWidget +import dev.dimension.flare.ui.foundation.RowWidget +import dev.dimension.flare.ui.foundation.TextWidget +import dev.dimension.flare.ui.foundation.VerticalAlignment +import kotlin.math.roundToInt +import com.google.android.material.R as MaterialR + +/** Builds the Android View renderer set supplied by Foundation and optional plugins. */ +public fun createAndroidWidgetSystem(vararg plugins: FlareRendererPlugin): FlareWidgetSystem = + FlareWidgetSystem( + AndroidViewFoundationRendererPlugin, + *plugins, + ) + +public object AndroidViewFoundationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ColumnWidget::class) { backend -> AndroidColumnWidget(backend) } + registrar.register(RowWidget::class) { backend -> AndroidRowWidget(backend) } + registrar.register(TextWidget::class) { backend -> AndroidTextWidget(backend) } + registrar.register(NativeButtonWidget::class) { backend -> AndroidNativeButtonWidget(backend) } + } +} + +internal class AndroidColumnWidget( + backend: AndroidViewBackend, +) : AbstractAndroidWidget( + view = + LinearLayout(backend.context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.TOP or Gravity.START + }, + ), + ColumnWidget { + override val children: AndroidViewChildren = AndroidViewChildren(view) + + override fun setSpacing(value: Float) { + view.setItemSpacing(value.toPixels(view.resources.displayMetrics.density)) + } + + override fun setHorizontalAlignment(value: HorizontalAlignment) { + view.gravity = + Gravity.TOP or + when (value) { + HorizontalAlignment.Start -> Gravity.START + HorizontalAlignment.Center -> Gravity.CENTER_HORIZONTAL + HorizontalAlignment.End -> Gravity.END + HorizontalAlignment.Stretch -> Gravity.FILL_HORIZONTAL + } + } +} + +internal class AndroidRowWidget( + backend: AndroidViewBackend, +) : AbstractAndroidWidget( + view = + LinearLayout(backend.context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.START or Gravity.CENTER_VERTICAL + }, + ), + RowWidget { + override val children: AndroidViewChildren = AndroidViewChildren(view) + + override fun setSpacing(value: Float) { + view.setItemSpacing(value.toPixels(view.resources.displayMetrics.density)) + } + + override fun setVerticalAlignment(value: VerticalAlignment) { + view.gravity = + Gravity.START or + when (value) { + VerticalAlignment.Top -> Gravity.TOP + VerticalAlignment.Center -> Gravity.CENTER_VERTICAL + VerticalAlignment.Bottom -> Gravity.BOTTOM + VerticalAlignment.Stretch -> Gravity.FILL_VERTICAL + } + } +} + +internal class AndroidTextWidget( + backend: AndroidViewBackend, +) : AbstractAndroidWidget( + view = + MaterialTextView(backend.context).apply { + setTextAppearance(MaterialR.style.TextAppearance_Material3_BodyLarge) + }, + ), + TextWidget { + override fun setText(value: String) { + view.text = value + } +} + +internal class AndroidNativeButtonWidget( + backend: AndroidViewBackend, +) : AbstractAndroidWidget( + view = MaterialButton(backend.context), + ), + NativeButtonWidget { + private var clickAction: () -> Unit = {} + + init { + view.setOnClickListener { + clickAction() + } + } + + override fun setLabel(value: String) { + view.text = value + } + + override fun setEnabled(value: Boolean) { + view.isEnabled = value + } + + override fun setOnClick(value: () -> Unit) { + clickAction = value + } + + override fun dispose() { + clickAction = {} + view.setOnClickListener(null) + } +} + +private fun LinearLayout.setItemSpacing(spacing: Int) { + dividerDrawable = + if (spacing == 0) { + null + } else { + SpacingDrawable(spacing) + } + showDividers = + if (spacing == 0) { + LinearLayout.SHOW_DIVIDER_NONE + } else { + LinearLayout.SHOW_DIVIDER_MIDDLE + } +} + +private fun Float.toPixels(density: Float): Int = (this * density).roundToInt() + +private class SpacingDrawable( + private val spacing: Int, +) : ColorDrawable(Color.TRANSPARENT) { + override fun getIntrinsicWidth(): Int = spacing + + override fun getIntrinsicHeight(): Int = spacing +} diff --git a/flareUI/foundation/src/androidMain/kotlin/dev/dimension/flare/ui/compose/ComposeFoundationRenderer.kt b/flareUI/foundation/src/androidMain/kotlin/dev/dimension/flare/ui/compose/ComposeFoundationRenderer.kt new file mode 100644 index 0000000000..9de84a36dc --- /dev/null +++ b/flareUI/foundation/src/androidMain/kotlin/dev/dimension/flare/ui/compose/ComposeFoundationRenderer.kt @@ -0,0 +1,173 @@ +package dev.dimension.flare.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.UiComposable +import androidx.compose.ui.unit.dp +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.foundation.ColumnWidget +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.NativeButtonWidget +import dev.dimension.flare.ui.foundation.RowWidget +import dev.dimension.flare.ui.foundation.TextWidget +import dev.dimension.flare.ui.foundation.VerticalAlignment +import androidx.compose.foundation.layout.Column as ComposeColumn +import androidx.compose.foundation.layout.Row as ComposeRow + +/** Builds the Compose renderer set supplied by Foundation and optional plugins. */ +public fun createAndroidComposeWidgetSystem( + vararg plugins: FlareRendererPlugin, +): FlareWidgetSystem = + FlareWidgetSystem( + AndroidComposeRuntimeRendererPlugin, + AndroidComposeFoundationRendererPlugin, + *plugins, + ) + +public object AndroidComposeFoundationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ColumnWidget::class) { _ -> AndroidComposeColumnWidget() } + registrar.register(RowWidget::class) { _ -> AndroidComposeRowWidget() } + registrar.register(TextWidget::class) { _ -> AndroidComposeTextWidget() } + registrar.register(NativeButtonWidget::class) { _ -> AndroidComposeNativeButtonWidget() } + } +} + +internal class AndroidComposeColumnWidget : + AbstractAndroidComposeWidget(), + ColumnWidget { + override val children: AndroidComposeChildren = AndroidComposeChildren() + private var itemSpacing: Float by mutableFloatStateOf(0f) + private var itemAlignment: HorizontalAlignment by mutableStateOf(HorizontalAlignment.Start) + + override fun setSpacing(value: Float) { + itemSpacing = value + } + + override fun setHorizontalAlignment(value: HorizontalAlignment) { + itemAlignment = value + } + + @Composable + @UiComposable + override fun Render() { + ComposeColumn( + modifier = composeModifier, + verticalArrangement = Arrangement.spacedBy(itemSpacing.dp), + horizontalAlignment = itemAlignment.toComposeAlignment(), + ) { + children.Render() + } + } +} + +internal class AndroidComposeRowWidget : + AbstractAndroidComposeWidget(), + RowWidget { + override val children: AndroidComposeChildren = AndroidComposeChildren() + private var itemSpacing: Float by mutableFloatStateOf(0f) + private var itemAlignment: VerticalAlignment by mutableStateOf(VerticalAlignment.Center) + + override fun setSpacing(value: Float) { + itemSpacing = value + } + + override fun setVerticalAlignment(value: VerticalAlignment) { + itemAlignment = value + } + + @Composable + @UiComposable + override fun Render() { + ComposeRow( + modifier = composeModifier, + horizontalArrangement = Arrangement.spacedBy(itemSpacing.dp), + verticalAlignment = itemAlignment.toComposeAlignment(), + ) { + children.Render() + } + } +} + +internal class AndroidComposeTextWidget : + AbstractAndroidComposeWidget(), + TextWidget { + private var currentText: String by mutableStateOf("") + + override fun setText(value: String) { + currentText = value + } + + @Composable + @UiComposable + override fun Render() { + Text( + text = currentText, + modifier = composeModifier, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +internal class AndroidComposeNativeButtonWidget : + AbstractAndroidComposeWidget(), + NativeButtonWidget { + private var currentLabel: String by mutableStateOf("") + private var enabledState: Boolean by mutableStateOf(true) + private var clickAction: () -> Unit = {} + private val performClick: () -> Unit = { clickAction() } + + override fun setLabel(value: String) { + currentLabel = value + } + + override fun setEnabled(value: Boolean) { + enabledState = value + } + + override fun setOnClick(value: () -> Unit) { + clickAction = value + } + + @Composable + @UiComposable + override fun Render() { + Button( + onClick = performClick, + modifier = composeModifier, + enabled = enabledState, + ) { + Text(currentLabel) + } + } + + override fun dispose() { + clickAction = {} + } +} + +private fun HorizontalAlignment.toComposeAlignment(): Alignment.Horizontal = + when (this) { + HorizontalAlignment.Start -> Alignment.Start + HorizontalAlignment.Center -> Alignment.CenterHorizontally + HorizontalAlignment.End -> Alignment.End + HorizontalAlignment.Stretch -> Alignment.Start + } + +private fun VerticalAlignment.toComposeAlignment(): Alignment.Vertical = + when (this) { + VerticalAlignment.Top -> Alignment.Top + VerticalAlignment.Center -> Alignment.CenterVertically + VerticalAlignment.Bottom -> Alignment.Bottom + VerticalAlignment.Stretch -> Alignment.Top + } diff --git a/flareUI/foundation/src/appleTest/kotlin/dev/dimension/flare/ui/AppleHostTestSupport.kt b/flareUI/foundation/src/appleTest/kotlin/dev/dimension/flare/ui/AppleHostTestSupport.kt new file mode 100644 index 0000000000..c8f8bedc2f --- /dev/null +++ b/flareUI/foundation/src/appleTest/kotlin/dev/dimension/flare/ui/AppleHostTestSupport.kt @@ -0,0 +1,22 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui + +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +internal fun awaitAppleUi( + message: String, + condition: () -> Boolean, +) { + val startedAt = TimeSource.Monotonic.markNow() + while (!condition() && startedAt.elapsedNow() < APPLE_UI_TIMEOUT) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, RUN_LOOP_STEP_SECONDS, true) + } + check(condition()) { message } +} + +private val APPLE_UI_TIMEOUT = 5.seconds +private const val RUN_LOOP_STEP_SECONDS: Double = 0.01 diff --git a/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Column.kt b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Column.kt new file mode 100644 index 0000000000..601b7fbe33 --- /dev/null +++ b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Column.kt @@ -0,0 +1,41 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.foundation + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget + +public interface ColumnWidget : FlareWidget { + override val children: FlareChildren + + public fun setSpacing(value: Float) + + public fun setHorizontalAlignment(value: HorizontalAlignment) +} + +@Composable +@FlareUiComposable +public fun Column( + modifier: FlareModifier = FlareModifier.None, + spacing: Float = 0f, + horizontalAlignment: HorizontalAlignment = HorizontalAlignment.Start, + content: FlareContent, +) { + require(spacing.isFinite() && spacing >= 0f) { + "Column spacing must be a finite, non-negative value." + } + EmitFlareWidget( + componentType = ColumnWidget::class, + modifier = modifier, + update = { + set(spacing, ColumnWidget::setSpacing) + set(horizontalAlignment, ColumnWidget::setHorizontalAlignment) + }, + content = content, + ) +} diff --git a/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/LayoutAlignment.kt b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/LayoutAlignment.kt new file mode 100644 index 0000000000..3e68de4a99 --- /dev/null +++ b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/LayoutAlignment.kt @@ -0,0 +1,17 @@ +package dev.dimension.flare.ui.foundation + +/** Horizontal alignment used by a vertical [Column]. */ +public enum class HorizontalAlignment { + Start, + Center, + End, + Stretch, +} + +/** Vertical alignment used by a horizontal [Row]. */ +public enum class VerticalAlignment { + Top, + Center, + Bottom, + Stretch, +} diff --git a/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/NativeButton.kt b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/NativeButton.kt new file mode 100644 index 0000000000..6f5a641688 --- /dev/null +++ b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/NativeButton.kt @@ -0,0 +1,36 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.foundation + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget + +public interface NativeButtonWidget : FlareWidget { + public fun setLabel(value: String) + + public fun setEnabled(value: Boolean) + + public fun setOnClick(value: () -> Unit) +} + +@Composable +@FlareUiComposable +public fun NativeButton( + label: String, + modifier: FlareModifier = FlareModifier.None, + enabled: Boolean = true, + onClick: () -> Unit, +) { + EmitFlareWidget( + componentType = NativeButtonWidget::class, + modifier = modifier, + update = { + set(label, NativeButtonWidget::setLabel) + set(enabled, NativeButtonWidget::setEnabled) + set(onClick, NativeButtonWidget::setOnClick) + }, + ) +} diff --git a/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Row.kt b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Row.kt new file mode 100644 index 0000000000..cb316fdc64 --- /dev/null +++ b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Row.kt @@ -0,0 +1,41 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.foundation + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget + +public interface RowWidget : FlareWidget { + override val children: FlareChildren + + public fun setSpacing(value: Float) + + public fun setVerticalAlignment(value: VerticalAlignment) +} + +@Composable +@FlareUiComposable +public fun Row( + modifier: FlareModifier = FlareModifier.None, + spacing: Float = 0f, + verticalAlignment: VerticalAlignment = VerticalAlignment.Center, + content: FlareContent, +) { + require(spacing.isFinite() && spacing >= 0f) { + "Row spacing must be a finite, non-negative value." + } + EmitFlareWidget( + componentType = RowWidget::class, + modifier = modifier, + update = { + set(spacing, RowWidget::setSpacing) + set(verticalAlignment, RowWidget::setVerticalAlignment) + }, + content = content, + ) +} diff --git a/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Text.kt b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Text.kt new file mode 100644 index 0000000000..e5749813cd --- /dev/null +++ b/flareUI/foundation/src/commonMain/kotlin/dev/dimension/flare/ui/foundation/Text.kt @@ -0,0 +1,28 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.foundation + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget + +public interface TextWidget : FlareWidget { + public fun setText(value: String) +} + +@Composable +@FlareUiComposable +public fun Text( + text: String, + modifier: FlareModifier = FlareModifier.None, +) { + EmitFlareWidget( + componentType = TextWidget::class, + modifier = modifier, + update = { + set(text, TextWidget::setText) + }, + ) +} diff --git a/flareUI/foundation/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitFoundationRenderer.kt b/flareUI/foundation/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitFoundationRenderer.kt new file mode 100644 index 0000000000..0b6352a9ee --- /dev/null +++ b/flareUI/foundation/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitFoundationRenderer.kt @@ -0,0 +1,140 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.foundation.ColumnWidget +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.NativeButtonWidget +import dev.dimension.flare.ui.foundation.RowWidget +import dev.dimension.flare.ui.foundation.TextWidget +import dev.dimension.flare.ui.foundation.VerticalAlignment +import platform.UIKit.UIAction +import platform.UIKit.UIButton +import platform.UIKit.UIButtonTypeSystem +import platform.UIKit.UIControlEventTouchUpInside +import platform.UIKit.UIControlStateNormal +import platform.UIKit.UILabel +import platform.UIKit.UILayoutConstraintAxisHorizontal +import platform.UIKit.UILayoutConstraintAxisVertical +import platform.UIKit.UIStackView +import platform.UIKit.UIStackViewAlignmentBottom +import platform.UIKit.UIStackViewAlignmentCenter +import platform.UIKit.UIStackViewAlignmentFill +import platform.UIKit.UIStackViewAlignmentLeading +import platform.UIKit.UIStackViewAlignmentTop +import platform.UIKit.UIStackViewAlignmentTrailing + +public object UIKitFoundationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ColumnWidget::class) { _ -> UIKitColumnWidget() } + registrar.register(RowWidget::class) { _ -> UIKitRowWidget() } + registrar.register(TextWidget::class) { _ -> UIKitTextWidget() } + registrar.register(NativeButtonWidget::class) { _ -> UIKitNativeButtonWidget() } + } +} + +/** Builds the UIKit renderer set supplied by Foundation and optional plugins. */ +public fun createUIKitWidgetSystem(vararg plugins: FlareRendererPlugin): FlareWidgetSystem = + FlareWidgetSystem( + UIKitFoundationRendererPlugin, + *plugins, + ) + +internal class UIKitColumnWidget : + AbstractUIKitWidget( + view = + UIStackView().apply { + axis = UILayoutConstraintAxisVertical + }, + ), + ColumnWidget { + override val children: UIKitChildren = UIKitChildren(view) + + override fun setSpacing(value: Float) { + view.spacing = value.toDouble() + } + + override fun setHorizontalAlignment(value: HorizontalAlignment) { + view.alignment = + when (value) { + HorizontalAlignment.Start -> UIStackViewAlignmentLeading + HorizontalAlignment.Center -> UIStackViewAlignmentCenter + HorizontalAlignment.End -> UIStackViewAlignmentTrailing + HorizontalAlignment.Stretch -> UIStackViewAlignmentFill + } + } +} + +internal class UIKitRowWidget : + AbstractUIKitWidget( + view = + UIStackView().apply { + axis = UILayoutConstraintAxisHorizontal + }, + ), + RowWidget { + override val children: UIKitChildren = UIKitChildren(view) + + override fun setSpacing(value: Float) { + view.spacing = value.toDouble() + } + + override fun setVerticalAlignment(value: VerticalAlignment) { + view.alignment = + when (value) { + VerticalAlignment.Top -> UIStackViewAlignmentTop + VerticalAlignment.Center -> UIStackViewAlignmentCenter + VerticalAlignment.Bottom -> UIStackViewAlignmentBottom + VerticalAlignment.Stretch -> UIStackViewAlignmentFill + } + } +} + +internal class UIKitTextWidget : + AbstractUIKitWidget( + view = + UILabel().apply { + numberOfLines = 0 + }, + ), + TextWidget { + override fun setText(value: String) { + view.text = value + } +} + +internal class UIKitNativeButtonWidget : + AbstractUIKitWidget( + view = UIButton.buttonWithType(UIButtonTypeSystem), + ), + NativeButtonWidget { + private var clickAction: () -> Unit = {} + private var action: UIAction? = UIAction.actionWithHandler { clickAction() } + + init { + view.addAction(checkNotNull(action), forControlEvents = UIControlEventTouchUpInside) + } + + override fun setLabel(value: String) { + view.setTitle(value, forState = UIControlStateNormal) + } + + override fun setEnabled(value: Boolean) { + view.enabled = value + } + + override fun setOnClick(value: () -> Unit) { + clickAction = value + } + + override fun dispose() { + clickAction = {} + action?.let { current -> + view.removeAction(current, forControlEvents = UIControlEventTouchUpInside) + } + action = null + } +} diff --git a/flareUI/foundation/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/FlareUIKitHostTest.kt b/flareUI/foundation/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/FlareUIKitHostTest.kt new file mode 100644 index 0000000000..7192ada480 --- /dev/null +++ b/flareUI/foundation/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/FlareUIKitHostTest.kt @@ -0,0 +1,65 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.awaitAppleUi +import dev.dimension.flare.ui.foundation.Column +import dev.dimension.flare.ui.foundation.Text +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSThread +import platform.UIKit.UILabel +import platform.UIKit.UIStackView +import platform.UIKit.UIWindow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertTrue + +public class FlareUIKitHostTest { + @Test + public fun hostUsesLatestContentAcrossWindowAttachment() { + assertTrue(NSThread.isMainThread) + val window = UIWindow(frame = CGRectMake(0.0, 0.0, 320.0, 240.0)) + val host = FlareUIKitHost(createUIKitWidgetSystem()) + + try { + host.setContent { + Column { + Text("Initial") + } + } + assertTrue(host.view.arrangedSubviews.isEmpty()) + + window.addSubview(host.view) + val initialColumn = host.awaitColumn() + val initialLabel = initialColumn.arrangedSubviews[0] as UILabel + assertEquals("Initial", initialLabel.text) + + host.view.removeFromSuperview() + awaitAppleUi("UIKit host did not dispose content after detaching.") { + host.view.arrangedSubviews.isEmpty() + } + host.setContent { + Column { + Text("Updated while detached") + } + } + + window.addSubview(host.view) + val recreatedColumn = host.awaitColumn() + val recreatedLabel = recreatedColumn.arrangedSubviews[0] as UILabel + assertNotSame(initialColumn, recreatedColumn) + assertEquals("Updated while detached", recreatedLabel.text) + } finally { + host.dispose() + window.hidden = true + } + } + + private fun FlareUIKitHost.awaitColumn(): UIStackView { + awaitAppleUi("UIKit host did not create its native hierarchy after attaching.") { + view.arrangedSubviews.size == 1 + } + return view.arrangedSubviews.single() as UIStackView + } +} diff --git a/flareUI/foundation/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitFoundationLayoutTest.kt b/flareUI/foundation/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitFoundationLayoutTest.kt new file mode 100644 index 0000000000..2dc328ce73 --- /dev/null +++ b/flareUI/foundation/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitFoundationLayoutTest.kt @@ -0,0 +1,30 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.VerticalAlignment +import platform.UIKit.UIStackViewAlignmentBottom +import platform.UIKit.UIStackViewAlignmentTrailing +import kotlin.test.Test +import kotlin.test.assertEquals + +public class UIKitFoundationLayoutTest { + @Test + public fun mapsSharedSpacingAlignmentAndMultilineText() { + val column = UIKitColumnWidget() + val row = UIKitRowWidget() + val text = UIKitTextWidget() + + column.setSpacing(12f) + column.setHorizontalAlignment(HorizontalAlignment.End) + row.setSpacing(8f) + row.setVerticalAlignment(VerticalAlignment.Bottom) + + assertEquals(12.0, column.view.spacing) + assertEquals(UIStackViewAlignmentTrailing, column.view.alignment) + assertEquals(8.0, row.view.spacing) + assertEquals(UIStackViewAlignmentBottom, row.view.alignment) + assertEquals(0L, text.view.numberOfLines) + } +} diff --git a/flareUI/foundation/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitFoundationRenderer.kt b/flareUI/foundation/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitFoundationRenderer.kt new file mode 100644 index 0000000000..ba242d3f60 --- /dev/null +++ b/flareUI/foundation/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitFoundationRenderer.kt @@ -0,0 +1,160 @@ +@file:OptIn( + kotlinx.cinterop.BetaInteropApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.foundation.ColumnWidget +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.NativeButtonWidget +import dev.dimension.flare.ui.foundation.RowWidget +import dev.dimension.flare.ui.foundation.TextWidget +import dev.dimension.flare.ui.foundation.VerticalAlignment +import kotlinx.cinterop.ObjCAction +import platform.AppKit.NSBezelStyleRounded +import platform.AppKit.NSButton +import platform.AppKit.NSButtonTypeMomentaryPushIn +import platform.AppKit.NSLayoutAttributeBottom +import platform.AppKit.NSLayoutAttributeCenterX +import platform.AppKit.NSLayoutAttributeCenterY +import platform.AppKit.NSLayoutAttributeLeading +import platform.AppKit.NSLayoutAttributeNotAnAttribute +import platform.AppKit.NSLayoutAttributeTop +import platform.AppKit.NSLayoutAttributeTrailing +import platform.AppKit.NSLineBreakByWordWrapping +import platform.AppKit.NSStackView +import platform.AppKit.NSTextField +import platform.AppKit.NSUserInterfaceLayoutOrientationHorizontal +import platform.AppKit.NSUserInterfaceLayoutOrientationVertical +import platform.AppKit.labelWithString +import platform.darwin.NSObject +import platform.darwin.sel_registerName + +public object AppKitFoundationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ColumnWidget::class) { _ -> AppKitColumnWidget() } + registrar.register(RowWidget::class) { _ -> AppKitRowWidget() } + registrar.register(TextWidget::class) { _ -> AppKitTextWidget() } + registrar.register(NativeButtonWidget::class) { _ -> AppKitNativeButtonWidget() } + } +} + +/** Builds the AppKit renderer set supplied by Foundation and optional plugins. */ +public fun createAppKitWidgetSystem(vararg plugins: FlareRendererPlugin): FlareWidgetSystem = + FlareWidgetSystem( + AppKitFoundationRendererPlugin, + *plugins, + ) + +internal class AppKitColumnWidget : + AbstractAppKitWidget( + view = + NSStackView().apply { + orientation = NSUserInterfaceLayoutOrientationVertical + }, + ), + ColumnWidget { + override val children: AppKitChildren = AppKitChildren(view) + + override fun setSpacing(value: Float) { + view.spacing = value.toDouble() + } + + override fun setHorizontalAlignment(value: HorizontalAlignment) { + view.alignment = + when (value) { + HorizontalAlignment.Start -> NSLayoutAttributeLeading + HorizontalAlignment.Center -> NSLayoutAttributeCenterX + HorizontalAlignment.End -> NSLayoutAttributeTrailing + HorizontalAlignment.Stretch -> NSLayoutAttributeNotAnAttribute + } + } +} + +internal class AppKitRowWidget : + AbstractAppKitWidget( + view = + NSStackView().apply { + orientation = NSUserInterfaceLayoutOrientationHorizontal + }, + ), + RowWidget { + override val children: AppKitChildren = AppKitChildren(view) + + override fun setSpacing(value: Float) { + view.spacing = value.toDouble() + } + + override fun setVerticalAlignment(value: VerticalAlignment) { + view.alignment = + when (value) { + VerticalAlignment.Top -> NSLayoutAttributeTop + VerticalAlignment.Center -> NSLayoutAttributeCenterY + VerticalAlignment.Bottom -> NSLayoutAttributeBottom + VerticalAlignment.Stretch -> NSLayoutAttributeNotAnAttribute + } + } +} + +internal class AppKitTextWidget : + AbstractAppKitWidget( + view = + NSTextField.labelWithString("").apply { + maximumNumberOfLines = 0 + lineBreakMode = NSLineBreakByWordWrapping + usesSingleLineMode = false + }, + ), + TextWidget { + override fun setText(value: String) { + view.stringValue = value + } +} + +internal class AppKitNativeButtonWidget : + AbstractAppKitWidget( + view = + NSButton().apply { + bezelStyle = NSBezelStyleRounded + setButtonType(NSButtonTypeMomentaryPushIn) + }, + ), + NativeButtonWidget { + private val actionTarget = AppKitButtonActionTarget() + + init { + view.target = actionTarget + view.action = sel_registerName("performClick") + } + + override fun setLabel(value: String) { + view.title = value + } + + override fun setEnabled(value: Boolean) { + view.enabled = value + } + + override fun setOnClick(value: () -> Unit) { + actionTarget.onClick = value + } + + override fun dispose() { + view.target = null + view.action = null + actionTarget.onClick = {} + } +} + +private class AppKitButtonActionTarget : NSObject() { + var onClick: () -> Unit = {} + + @ObjCAction + fun performClick() { + onClick() + } +} diff --git a/flareUI/foundation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitFoundationLayoutTest.kt b/flareUI/foundation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitFoundationLayoutTest.kt new file mode 100644 index 0000000000..86b87808f3 --- /dev/null +++ b/flareUI/foundation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitFoundationLayoutTest.kt @@ -0,0 +1,30 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.VerticalAlignment +import platform.AppKit.NSLayoutAttributeBottom +import platform.AppKit.NSLayoutAttributeTrailing +import kotlin.test.Test +import kotlin.test.assertEquals + +public class AppKitFoundationLayoutTest { + @Test + public fun mapsSharedSpacingAlignmentAndMultilineText() { + val column = AppKitColumnWidget() + val row = AppKitRowWidget() + val text = AppKitTextWidget() + + column.setSpacing(12f) + column.setHorizontalAlignment(HorizontalAlignment.End) + row.setSpacing(8f) + row.setVerticalAlignment(VerticalAlignment.Bottom) + + assertEquals(12.0, column.view.spacing) + assertEquals(NSLayoutAttributeTrailing, column.view.alignment) + assertEquals(8.0, row.view.spacing) + assertEquals(NSLayoutAttributeBottom, row.view.alignment) + assertEquals(0L, text.view.maximumNumberOfLines) + } +} diff --git a/flareUI/foundation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/FlareAppKitHostTest.kt b/flareUI/foundation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/FlareAppKitHostTest.kt new file mode 100644 index 0000000000..92ec55fc34 --- /dev/null +++ b/flareUI/foundation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/FlareAppKitHostTest.kt @@ -0,0 +1,85 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.appkit + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.dimension.flare.ui.awaitAppleUi +import dev.dimension.flare.ui.foundation.Column +import dev.dimension.flare.ui.foundation.Text +import platform.AppKit.NSApplication +import platform.AppKit.NSBackingStoreBuffered +import platform.AppKit.NSStackView +import platform.AppKit.NSTextField +import platform.AppKit.NSView +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowStyleMaskBorderless +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSThread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertTrue + +public class FlareAppKitHostTest { + @Test + public fun hostRecomposesAndRecreatesContentAcrossWindowAttachment() { + assertTrue(NSThread.isMainThread) + NSApplication.sharedApplication + val window = + NSWindow( + contentRect = CGRectMake(0.0, 0.0, 320.0, 240.0), + styleMask = NSWindowStyleMaskBorderless, + backing = NSBackingStoreBuffered, + defer = false, + ) + val rootView = NSView(frame = window.contentView?.frame ?: CGRectMake(0.0, 0.0, 320.0, 240.0)) + window.contentView = rootView + val host = FlareAppKitHost(createAppKitWidgetSystem()) + var incrementCount: (() -> Unit)? = null + + try { + host.setContent { + var count by remember { mutableIntStateOf(0) } + incrementCount = { count += 1 } + Column { + Text("Count $count") + } + } + assertTrue(host.view.arrangedSubviews.isEmpty()) + + rootView.addSubview(host.view) + val initialColumn = host.awaitColumn() + val initialLabel = initialColumn.arrangedSubviews[0] as NSTextField + assertEquals("Count 0", initialLabel.stringValue) + + checkNotNull(incrementCount).invoke() + awaitAppleUi("AppKit host did not apply the state update.") { + initialLabel.stringValue == "Count 1" + } + + host.view.removeFromSuperview() + awaitAppleUi("AppKit host did not dispose content after detaching.") { + host.view.arrangedSubviews.isEmpty() + } + + rootView.addSubview(host.view) + val recreatedColumn = host.awaitColumn() + val recreatedLabel = recreatedColumn.arrangedSubviews[0] as NSTextField + assertNotSame(initialColumn, recreatedColumn) + assertEquals("Count 0", recreatedLabel.stringValue) + } finally { + host.dispose() + window.close() + } + } + + private fun FlareAppKitHost.awaitColumn(): NSStackView { + awaitAppleUi("AppKit host did not create its native hierarchy after attaching.") { + view.arrangedSubviews.size == 1 + } + return view.arrangedSubviews.single() as NSStackView + } +} diff --git a/flareUI/gradle.properties b/flareUI/gradle.properties new file mode 100644 index 0000000000..b3edf46185 --- /dev/null +++ b/flareUI/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +moko.resources.disableStaticFrameworkWarning=true diff --git a/flareUI/gradle/libs.versions.toml b/flareUI/gradle/libs.versions.toml new file mode 100644 index 0000000000..d028a53246 --- /dev/null +++ b/flareUI/gradle/libs.versions.toml @@ -0,0 +1,43 @@ +[versions] +agp = "9.3.0" +androidx-activity = "1.7.0" +androidx-fragment = "1.8.9" +compileSdk = "37" +compose-bom = "2026.06.01" +java = "25" +junit = "4.13.2" +kotlin = "2.4.0" +kotlinx-coroutines = "1.11.0" +material-components = "1.14.0" +minSdk = "26" +moko-resources = "0.26.4" +navigation3 = "1.1.7" +recyclerview = "1.4.0" +robolectric = "4.16.1" + +[libraries] +androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +compose-foundation = { module = "androidx.compose.foundation:foundation" } +compose-material3 = { module = "androidx.compose.material3:material3" } +compose-runtime = { module = "androidx.compose.runtime:runtime" } +compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable" } +compose-ui = { module = "androidx.compose.ui:ui" } +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +material-components = { module = "com.google.android.material:material", version.ref = "material-components" } +moko-resources = { module = "dev.icerock.moko:resources", version.ref = "moko-resources" } +navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +moko-resources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko-resources" } diff --git a/flareUI/lazy-layout/build.gradle.kts b/flareUI/lazy-layout/build.gradle.kts new file mode 100644 index 0000000000..9643c78d62 --- /dev/null +++ b/flareUI/lazy-layout/build.gradle.kts @@ -0,0 +1,62 @@ +import dev.dimension.flareui.buildlogic.FlareUiPlatform +import dev.dimension.flareui.buildlogic.flareUi + +plugins { + id("dev.dimension.flareui.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flareUi { + namespace = "dev.dimension.flare.ui.lazy" + platforms( + FlareUiPlatform.ANDROID, + FlareUiPlatform.JVM, + FlareUiPlatform.IOS, + FlareUiPlatform.MACOS, + ) + } + android { + androidResources { + enable = true + } + withHostTest { + isIncludeAndroidResources = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":foundation")) + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.compose.runtime.saveable) + implementation(libs.kotlinx.coroutines.core) + } + } + val androidMain by getting { + dependencies { + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.compose.foundation) + implementation(libs.recyclerview) + } + } + val androidHostTest by getting { + dependencies { + implementation(libs.compose.ui.test.junit4) + implementation(libs.compose.ui.test.manifest) + implementation(libs.compose.material3) + implementation(libs.junit) + implementation(libs.robolectric) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + } + } + } +} diff --git a/flareUI/lazy-layout/src/androidHostTest/kotlin/dev/dimension/flare/ui/lazy/AndroidViewLazyListTest.kt b/flareUI/lazy-layout/src/androidHostTest/kotlin/dev/dimension/flare/ui/lazy/AndroidViewLazyListTest.kt new file mode 100644 index 0000000000..ff5a8a04a3 --- /dev/null +++ b/flareUI/lazy-layout/src/androidHostTest/kotlin/dev/dimension/flare/ui/lazy/AndroidViewLazyListTest.kt @@ -0,0 +1,526 @@ +package dev.dimension.flare.ui.lazy + +import android.app.Activity +import android.os.Looper +import android.view.ContextThemeWrapper +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.android.AndroidViewLazyLayoutRendererPlugin +import dev.dimension.flare.ui.android.FlareAndroidViewHost +import dev.dimension.flare.ui.android.createAndroidWidgetSystem +import dev.dimension.flare.ui.foundation.Text +import dev.dimension.flare.ui.foundation.VerticalAlignment +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.time.Duration +import kotlin.math.roundToInt +import com.google.android.material.R as MaterialR + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@LooperMode(LooperMode.Mode.PAUSED) +public class AndroidViewLazyListTest { + @Test + public fun lazyColumnRealizesOnlyViewportItems() { + withHost { host -> + host.setContent { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> index }, + ) { index -> + Text("Item $index") + } + } + } + + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + assertEquals(RecyclerView.VERTICAL, (recycler.layoutManager as LinearLayoutManager).orientation) + assertTrue(recycler.isVerticalScrollBarEnabled) + assertFalse(recycler.isHorizontalScrollBarEnabled) + assertNotNull(recycler.verticalScrollbarThumbDrawable) + assertTrue(recycler.isScrollbarFadingEnabled) + assertTrue(recycler.childCount in 1 until 10_000) + assertEquals("Item 0", recycler.firstRenderedText()) + } + } + + @Test + public fun largeModelUpdateAvoidsRedundantFullKeyScans() { + var generation by mutableStateOf(0) + var keyLookups = 0 + withHost { host -> + host.setContent { + val generationSnapshot = generation + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> + keyLookups += 1 + index + }, + contentType = { generationSnapshot }, + ) { index -> + Text("Item $index") + } + } + } + layout(host) + + keyLookups = 0 + generation = 1 + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + layout(host) + + assertTrue("Model update resolved $keyLookups keys.", keyLookups < 25_000) + } + } + + @Test + public fun stableItemIdSurvivesMoreThanFourThousandOtherKeys() { + withHost { host -> + host.setContent { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items(count = 5_000, key = { index -> "item-$index" }) { index -> + Text("Item $index") + } + } + } + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + val adapter = checkNotNull(recycler.adapter) + val originalId = adapter.getItemId(0) + repeat(4_999) { offset -> adapter.getItemId(offset + 1) } + + assertTrue(adapter.hasStableIds()) + assertEquals(originalId, adapter.getItemId(0)) + } + } + + @Test + public fun deepAnchorModelUpdateUsesNearbyKeyLookup() { + var generation by mutableStateOf(0) + var keyLookups = 0 + withHost { host -> + host.setContent { + val generationSnapshot = generation + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> + keyLookups += 1 + index + }, + contentType = { generationSnapshot }, + ) { index -> + Text("Item $index", modifier = FlareModifier.None.height(40f)) + } + } + } + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + val layoutManager = recycler.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(9_000, -17) + layout(host) + + keyLookups = 0 + generation = 1 + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + layout(host) + + assertTrue("Deep anchor update resolved $keyLookups keys.", keyLookups < 500) + assertEquals(9_000, layoutManager.findFirstVisibleItemPosition()) + assertEquals(-17, layoutManager.getDecoratedTop(checkNotNull(layoutManager.findViewByPosition(9_000)))) + } + } + + @Test + public fun largePrependUsesTheCountDeltaAnchorFastPath() { + var prependedItems by mutableStateOf(0) + var keyLookups = 0 + withHost { host -> + host.setContent { + val prependedItemsSnapshot = prependedItems + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000 + prependedItemsSnapshot, + key = { index -> + keyLookups += 1 + index - prependedItemsSnapshot + }, + ) { index -> + Text("Item ${index - prependedItemsSnapshot}", modifier = FlareModifier.None.height(40f)) + } + } + } + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + val layoutManager = recycler.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(9_000, -17) + layout(host) + + keyLookups = 0 + prependedItems = 100 + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + layout(host) + + assertTrue("Large prepend resolved $keyLookups keys.", keyLookups < 500) + assertEquals(9_100, layoutManager.findFirstVisibleItemPosition()) + assertEquals(-17, layoutManager.getDecoratedTop(checkNotNull(layoutManager.findViewByPosition(9_100)))) + } + } + + @Test + public fun visibleStableHolderRebindsContentWithoutRecreatingTheDataset() { + var label by mutableStateOf("Before") + withHost { host -> + host.setContent { + val labelSnapshot = label + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + item(key = "stable") { Text(labelSnapshot) } + } + } + layout(host) + val recycler = host.getChildAt(0) as RecyclerView + assertEquals("Before", recycler.firstRenderedText()) + + label = "After" + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + layout(host) + + assertEquals("After", recycler.firstRenderedText()) + } + } + + @Test + public fun lazyRowUsesHorizontalNativeLayout() { + withHost { host -> + host.setContent { + LazyRow(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> index }, + ) { index -> + Text("Item $index") + } + } + } + + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + assertEquals(RecyclerView.HORIZONTAL, (recycler.layoutManager as LinearLayoutManager).orientation) + assertFalse(recycler.isVerticalScrollBarEnabled) + assertTrue(recycler.isHorizontalScrollBarEnabled) + assertNotNull(recycler.horizontalScrollbarThumbDrawable) + assertTrue(recycler.isScrollbarFadingEnabled) + assertTrue(recycler.childCount in 1 until 10_000) + assertEquals("Item 0", recycler.firstRenderedText()) + } + } + + @Test + public fun prependKeepsTheFirstVisibleStableKeyAndOffset() { + var items by mutableStateOf((0 until 100).toList()) + withHost { host -> + host.setContent { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items(items = items, key = { it }) { item -> + Text("Item $item", modifier = FlareModifier.None.height(48f)) + } + } + } + layout(host) + val recycler = host.getChildAt(0) as RecyclerView + val layoutManager = recycler.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(20, -17) + layout(host) + val anchorPosition = layoutManager.findFirstVisibleItemPosition() + val anchorView = checkNotNull(layoutManager.findViewByPosition(anchorPosition)) + val anchorOffset = layoutManager.getDecoratedTop(anchorView) + val anchorText = (anchorView as android.view.ViewGroup).firstText() + + items = listOf(-2, -1) + items + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + layout(host) + + val restoredPosition = layoutManager.findFirstVisibleItemPosition() + val restoredView = checkNotNull(layoutManager.findViewByPosition(restoredPosition)) + assertEquals(anchorText, (restoredView as android.view.ViewGroup).firstText()) + assertEquals(anchorOffset, layoutManager.getDecoratedTop(restoredView)) + } + } + + @Test + public fun itemContentPlacesMultipleRootsAlongTheMainAxis() { + withHost { host -> + host.setContent { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + item(key = "multiple") { + Text("First", modifier = FlareModifier.None.height(24f)) + Text("Second", modifier = FlareModifier.None.height(36f)) + } + } + } + + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + val itemRoot = recycler.getChildAt(0) as LinearLayout + assertEquals(LinearLayout.VERTICAL, itemRoot.orientation) + assertEquals(2, itemRoot.childCount) + assertEquals("First", (itemRoot.getChildAt(0) as TextView).text.toString()) + assertEquals("Second", (itemRoot.getChildAt(1) as TextView).text.toString()) + assertTrue(itemRoot.getChildAt(1).top >= itemRoot.getChildAt(0).bottom) + } + } + + @Test + public fun lazyGeometryUsesContentSizeSpacingAndCenteredCrossAxis() { + withHost { host -> + host.setContent { + LazyRow( + modifier = FlareModifier.None.fillMaxSize(), + spacing = 6f, + verticalAlignment = VerticalAlignment.Center, + ) { + item(key = "first") { + Text("First", modifier = FlareModifier.None.width(40f).height(24f)) + } + item(key = "second") { + Text("Second", modifier = FlareModifier.None.width(60f).height(24f)) + } + } + } + + layout(host) + + val density = host.resources.displayMetrics.density + val recycler = host.getChildAt(0) as RecyclerView + val firstRoot = recycler.getChildAt(0) as LinearLayout + val secondRoot = recycler.getChildAt(1) as LinearLayout + val first = firstRoot.getChildAt(0) + val second = secondRoot.getChildAt(0) + assertEquals((40f * density).roundToInt(), firstRoot.width) + assertEquals((60f * density).roundToInt(), secondRoot.width) + assertEquals((6f * density).roundToInt(), secondRoot.left - firstRoot.right) + assertEquals((24f * density).roundToInt(), first.height) + assertEquals((recycler.height - first.height) / 2, first.top) + assertEquals((recycler.height - second.height) / 2, second.top) + } + } + + @Test + public fun layoutInfoExcludesSpacingFromItemSize() { + val state = LazyListState() + withHost { host -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + spacing = 6f, + ) { + item(key = "first") { Text("First", modifier = FlareModifier.None.height(32f)) } + item(key = "second") { Text("Second", modifier = FlareModifier.None.height(48f)) } + } + } + + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + val firstRoot = recycler.getChildAt(0) as LinearLayout + assertEquals(recycler.width, firstRoot.getChildAt(0).width) + val first = state.layoutInfo.visibleItems.single { it.key == "first" } + val second = state.layoutInfo.visibleItems.single { it.key == "second" } + assertEquals(0f, first.offset, 0.5f) + assertEquals(32f, first.size, 0.5f) + assertEquals(38f, second.offset, 0.5f) + assertEquals(48f, second.size, 0.5f) + } + } + + @Test + public fun variableHeightColumnKeepsExactSpacingBeforeAndAfterScroll() { + var itemOffset by mutableStateOf(0) + withHost { host -> + host.setContent { + val snapshotOffset = itemOffset + LazyColumn( + modifier = FlareModifier.None.width(200f).height(240f), + spacing = 6f, + ) { + items( + count = 100 + snapshotOffset, + key = { index -> index - snapshotOffset }, + contentType = { index -> + if ((index - snapshotOffset) % 5 == 0) "highlight" else "standard" + }, + ) { index -> + val value = index - snapshotOffset + Text( + "Item $value", + modifier = FlareModifier.None.height(if (value % 5 == 0) 52f else 36f), + ) + } + } + } + + layout(host) + val recycler = host.getChildAt(0) as RecyclerView + assertVisibleItemSpacing(recycler) + + itemOffset = 1 + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + layout(host) + assertVisibleItemSpacing(recycler) + + (recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(37, 0) + layout(host) + assertVisibleItemSpacing(recycler) + } + } + + @Test + public fun stateScrollsToAnItemWithOffsetAndReportsTheViewport() { + val state = LazyListState() + var result: Result? = null + withHost { host -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(count = 1_000, key = { it }) { index -> + Text("Item $index", modifier = FlareModifier.None.height(40f)) + } + } + } + layout(host) + + CoroutineScope(Dispatchers.Unconfined).launch { + result = runCatching { state.scrollToItem(index = 80, scrollOffset = 12f) } + } + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + val layoutManager = recycler.layoutManager as LinearLayoutManager + val target = checkNotNull(layoutManager.findViewByPosition(80)) + val expectedOffset = -(12 * recycler.resources.displayMetrics.density).roundToInt() + assertTrue(result?.isSuccess == true) + assertEquals(80, layoutManager.findFirstVisibleItemPosition()) + assertEquals(expectedOffset, layoutManager.getDecoratedTop(target)) + assertEquals(1_000, state.layoutInfo.totalItemsCount) + assertTrue(state.layoutInfo.visibleItems.any { it.index == 80 }) + } + } + + @Test + public fun animatedStateScrollFinishesAtTheRequestedOffsetWithoutASnap() { + val state = LazyListState() + var result: Result? = null + withHost { host -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(count = 200, key = { it }) { index -> + Text("Item $index", modifier = FlareModifier.None.height(40f)) + } + } + } + layout(host) + + CoroutineScope(Dispatchers.Unconfined).launch { + result = runCatching { state.animateScrollToItem(index = 80, scrollOffset = 12f) } + } + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2)) + layout(host) + + val recycler = host.getChildAt(0) as RecyclerView + val layoutManager = recycler.layoutManager as LinearLayoutManager + val target = checkNotNull(layoutManager.findViewByPosition(80)) + val expectedOffset = -(12 * recycler.resources.displayMetrics.density).roundToInt() + assertTrue(result?.isSuccess == true) + assertEquals(expectedOffset, layoutManager.getDecoratedTop(target)) + } + } + + private fun withHost(block: (FlareAndroidViewHost) -> Unit) { + val controller = Robolectric.buildActivity(Activity::class.java).setup() + val activity = controller.get() + val context = ContextThemeWrapper(activity, MaterialR.style.Theme_Material3_DayNight) + val host = + FlareAndroidViewHost( + context = context, + widgetSystem = createAndroidWidgetSystem(AndroidViewLazyLayoutRendererPlugin), + ) + try { + activity.setContentView(host) + block(host) + } finally { + host.disposeComposition() + controller.pause().stop().destroy() + shadowOf(Looper.getMainLooper()).idle() + } + } + + private fun layout(host: FlareAndroidViewHost) { + shadowOf(Looper.getMainLooper()).idle() + host.measure( + View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(480, View.MeasureSpec.EXACTLY), + ) + host.layout(0, 0, 320, 480) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun RecyclerView.firstRenderedText(): String { + val holderRoot = getChildAt(0) as android.view.ViewGroup + return holderRoot.firstText() + } + + private fun assertVisibleItemSpacing(recycler: RecyclerView) { + val density = recycler.resources.displayMetrics.density + val children = + (0 until recycler.childCount) + .map(recycler::getChildAt) + .sortedBy(recycler::getChildAdapterPosition) + children.zipWithNext().forEach { (first, second) -> + assertTrue((first as android.view.ViewGroup).childCount > 0) + assertTrue((second as android.view.ViewGroup).childCount > 0) + assertEquals( + "Unexpected gap between adapter positions ${recycler.getChildAdapterPosition(first)} and " + + recycler.getChildAdapterPosition(second), + (6f * density).roundToInt(), + second.top - first.bottom, + ) + } + } + + private fun android.view.ViewGroup.firstText(): String = (getChildAt(0) as TextView).text.toString() +} diff --git a/flareUI/lazy-layout/src/androidHostTest/kotlin/dev/dimension/flare/ui/lazy/ComposeLazyListTest.kt b/flareUI/lazy-layout/src/androidHostTest/kotlin/dev/dimension/flare/ui/lazy/ComposeLazyListTest.kt new file mode 100644 index 0000000000..600f9dafed --- /dev/null +++ b/flareUI/lazy-layout/src/androidHostTest/kotlin/dev/dimension/flare/ui/lazy/ComposeLazyListTest.kt @@ -0,0 +1,403 @@ +package dev.dimension.flare.ui.lazy + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.compose.AndroidComposeLazyLayoutRendererPlugin +import dev.dimension.flare.ui.compose.FlareComposeHost +import dev.dimension.flare.ui.compose.createAndroidComposeWidgetSystem +import dev.dimension.flare.ui.foundation.Text +import dev.dimension.flare.ui.foundation.VerticalAlignment +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +public class ComposeLazyListTest { + @get:Rule + public val composeRule = createComposeRule() + + @Test + public fun customBusinessKeyIsAcceptedByTheComposeRenderer() { + val state = LazyListState() + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyColumn(modifier = FlareModifier.None.fillMaxSize(), state = state) { + items( + count = 100, + key = { index -> CustomBusinessKey(index) }, + ) { index -> + Text( + text = "Item $index", + modifier = FlareModifier(testTag = "custom-key-item-$index"), + ) + } + } + } + } + } + + composeRule.onNodeWithTag("custom-key-item-0").assertTextEquals("Item 0") + composeRule.waitForIdle() + org.junit.Assert.assertEquals( + CustomBusinessKey(0), + state.layoutInfo.visibleItems + .first() + .key, + ) + } + + @Test + public fun lazyColumnKeepsGlobalCountButComposesOnlyViewport() { + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> index }, + ) { index -> + Text( + text = "Item $index", + modifier = FlareModifier(testTag = "item-$index"), + ) + } + } + } + } + } + + composeRule.onNodeWithTag("item-0").assertTextEquals("Item 0") + composeRule.onAllNodesWithTag("item-9999").assertCountEquals(0) + } + + @Test + public fun largeModelUpdateDoesNotScanEveryKey() { + var generation by mutableIntStateOf(0) + var keyLookups = 0 + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + val generationSnapshot = generation + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> + keyLookups += 1 + index + }, + contentType = { generationSnapshot }, + ) { index -> + Text("Item $index") + } + } + } + } + } + composeRule.waitForIdle() + + keyLookups = 0 + composeRule.runOnIdle { generation = 1 } + composeRule.waitForIdle() + + org.junit.Assert.assertTrue("Model update resolved $keyLookups keys.", keyLookups < 500) + } + + @Test + public fun visibleStableItemRebindsContentWithoutACoordinatorScan() { + var label by mutableStateOf("Before") + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + val labelSnapshot = label + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + item(key = "stable") { + Text(labelSnapshot, modifier = FlareModifier(testTag = "stable-item")) + } + } + } + } + } + composeRule.onNodeWithTag("stable-item").assertTextEquals("Before") + + composeRule.runOnIdle { label = "After" } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("stable-item").assertTextEquals("After") + } + + @Test + public fun lazyRowComposesHorizontalViewport() { + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyRow(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> index }, + ) { index -> + Text( + text = "Item $index", + modifier = FlareModifier(testTag = "row-item-$index"), + ) + } + } + } + } + } + + composeRule.onNodeWithTag("row-item-0").assertTextEquals("Item 0") + composeRule.onAllNodesWithTag("row-item-9999").assertCountEquals(0) + } + + @Test + public fun itemContentPlacesMultipleRootsAlongTheMainAxis() { + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + item(key = "multiple") { + Text( + text = "First", + modifier = FlareModifier(testTag = "first").height(24f), + ) + Text( + text = "Second", + modifier = FlareModifier(testTag = "second").height(36f), + ) + } + } + } + } + } + + val first = composeRule.onNodeWithTag("first").getUnclippedBoundsInRoot() + val second = composeRule.onNodeWithTag("second").getUnclippedBoundsInRoot() + org.junit.Assert.assertTrue(second.top >= first.bottom) + } + + @Test + public fun lazyGeometryUsesContentSizeSpacingAndCenteredCrossAxis() { + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyRow( + modifier = FlareModifier(testTag = "geometry-row").width(200f).height(80f), + spacing = 6f, + verticalAlignment = VerticalAlignment.Center, + ) { + item(key = "first") { + Text( + "First", + modifier = FlareModifier(testTag = "geometry-first").width(40f).height(24f), + ) + } + item(key = "second") { + Text( + "Second", + modifier = FlareModifier(testTag = "geometry-second").width(60f).height(24f), + ) + } + } + } + } + } + + val row = composeRule.onNodeWithTag("geometry-row").getUnclippedBoundsInRoot() + val first = composeRule.onNodeWithTag("geometry-first").getUnclippedBoundsInRoot() + val second = composeRule.onNodeWithTag("geometry-second").getUnclippedBoundsInRoot() + org.junit.Assert.assertEquals(40f, (first.right - first.left).value, 0.5f) + org.junit.Assert.assertEquals(60f, (second.right - second.left).value, 0.5f) + org.junit.Assert.assertEquals(6f, (second.left - first.right).value, 0.5f) + org.junit.Assert.assertEquals(24f, (first.bottom - first.top).value, 0.5f) + org.junit.Assert.assertEquals(28f, (first.top - row.top).value, 0.5f) + org.junit.Assert.assertEquals(28f, (second.top - row.top).value, 0.5f) + } + + @Test + public fun layoutInfoExcludesSpacingFromItemSize() { + val state = LazyListState() + composeRule.setContent { + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyColumn( + modifier = FlareModifier.None.width(200f).height(120f), + state = state, + spacing = 6f, + ) { + item(key = "first") { + Text("First", modifier = FlareModifier(testTag = "stretch-first").height(32f)) + } + item(key = "second") { Text("Second", modifier = FlareModifier.None.height(48f)) } + } + } + } + } + + composeRule.waitForIdle() + val stretched = composeRule.onNodeWithTag("stretch-first").getUnclippedBoundsInRoot() + org.junit.Assert.assertEquals(200f, (stretched.right - stretched.left).value, 0.5f) + val first = state.layoutInfo.visibleItems.single { it.key == "first" } + val second = state.layoutInfo.visibleItems.single { it.key == "second" } + org.junit.Assert.assertEquals(0f, first.offset, 0.5f) + org.junit.Assert.assertEquals(32f, first.size, 0.5f) + org.junit.Assert.assertEquals(38f, second.offset, 0.5f) + org.junit.Assert.assertEquals(48f, second.size, 0.5f) + } + + @Test + public fun variableHeightColumnKeepsExactSpacingBeforeAndAfterScroll() { + val state = LazyListState() + lateinit var scope: CoroutineScope + var itemOffset by mutableIntStateOf(0) + composeRule.setContent { + scope = rememberCoroutineScope() + val snapshotOffset = itemOffset + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyColumn( + modifier = FlareModifier.None.width(200f).height(240f), + state = state, + spacing = 6f, + ) { + items( + count = 100 + snapshotOffset, + key = { index -> index - snapshotOffset }, + contentType = { index -> + if ((index - snapshotOffset) % 5 == 0) "highlight" else "standard" + }, + ) { index -> + val value = index - snapshotOffset + Text( + "Item $value", + modifier = + FlareModifier(testTag = "variable-item-$value") + .height(if (value % 5 == 0) 52f else 36f), + ) + } + } + } + } + } + + composeRule.waitForIdle() + assertVisibleItemSpacing(state, itemOffset) + + composeRule.runOnIdle { itemOffset = 1 } + composeRule.waitForIdle() + assertVisibleItemSpacing(state, itemOffset) + + composeRule.runOnIdle { + scope.launch { state.scrollToItem(37) } + } + composeRule.waitUntil(timeoutMillis = 5_000) { + state.layoutInfo.visibleItems.any { it.index == 37 } + } + assertVisibleItemSpacing(state, itemOffset) + } + + @Test + public fun stateScrollsToAnOffscreenItem() { + val state = LazyListState() + lateinit var scope: CoroutineScope + var result: Result? = null + composeRule.setContent { + scope = rememberCoroutineScope() + MaterialTheme { + FlareComposeHost( + widgetSystem = createAndroidComposeWidgetSystem(AndroidComposeLazyLayoutRendererPlugin), + ) { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(count = 1_000, key = { it }) { index -> + Text( + text = "Item $index", + modifier = FlareModifier(testTag = "scroll-item-$index").height(40f), + ) + } + } + } + } + } + + composeRule.runOnIdle { + scope.launch { + result = runCatching { state.scrollToItem(250) } + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("scroll-item-250").fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNodeWithTag("scroll-item-250").assertTextEquals("Item 250") + composeRule.onAllNodesWithTag("scroll-item-0").assertCountEquals(0) + org.junit.Assert.assertTrue(result?.isSuccess == true) + org.junit.Assert.assertEquals(1_000, state.layoutInfo.totalItemsCount) + } + + private fun assertVisibleItemSpacing( + state: LazyListState, + itemOffset: Int, + ) { + val visibleIndices = + state.layoutInfo.visibleItems + .map { it.index } + .sorted() + visibleIndices.zipWithNext().forEach { (firstIndex, secondIndex) -> + val first = + composeRule + .onNodeWithTag("variable-item-${firstIndex - itemOffset}") + .getUnclippedBoundsInRoot() + val second = + composeRule + .onNodeWithTag("variable-item-${secondIndex - itemOffset}") + .getUnclippedBoundsInRoot() + org.junit.Assert.assertEquals( + "Unexpected gap between items $firstIndex and $secondIndex", + 6f, + (second.top - first.bottom).value, + 0.5f, + ) + } + } + + private data class CustomBusinessKey( + val value: Int, + ) +} diff --git a/flareUI/lazy-layout/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidViewLazyLayoutRendererPlugin.kt b/flareUI/lazy-layout/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidViewLazyLayoutRendererPlugin.kt new file mode 100644 index 0000000000..701077d56b --- /dev/null +++ b/flareUI/lazy-layout/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidViewLazyLayoutRendererPlugin.kt @@ -0,0 +1,467 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.android + +import android.content.Context +import android.graphics.Rect +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller +import androidx.recyclerview.widget.RecyclerView +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.lazy.LazyCollectionCoordinator +import dev.dimension.flare.ui.lazy.LazyCollectionModel +import dev.dimension.flare.ui.lazy.LazyCollectionWidget +import dev.dimension.flare.ui.lazy.LazyCrossAxisAlignment +import dev.dimension.flare.ui.lazy.LazyItemHost +import dev.dimension.flare.ui.lazy.LazyListItemInfo +import dev.dimension.flare.ui.lazy.LazyListLayoutInfo +import dev.dimension.flare.ui.lazy.LazyListOrientation +import dev.dimension.flare.ui.lazy.LazyListScrollRequest +import dev.dimension.flare.ui.lazy.LazyRealizedItemUpdate +import dev.dimension.flare.ui.lazy.R +import dev.dimension.flare.ui.lazy.findIndexByKey +import kotlinx.coroutines.Dispatchers +import kotlin.math.roundToInt + +/** RecyclerView renderer for Flare lazy collections. */ +public object AndroidViewLazyLayoutRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(LazyCollectionWidget::class) { backend -> + AndroidViewLazyCollectionWidget(backend) + } + } +} + +private class AndroidViewLazyCollectionWidget( + backend: AndroidViewBackend, +) : AbstractAndroidWidget( + LayoutInflater + .from(backend.context) + .inflate(R.layout.flare_lazy_recycler_view, null, false) as RecyclerView, + ), + LazyCollectionWidget { + private val density = view.resources.displayMetrics.density + private val layoutManager = LinearLayoutManager(backend.context) + private val spacingDecoration = LazySpacingDecoration() + private var pendingAnimatedScroll: LazyListScrollRequest? = null + private val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = ::applyModel, + onScroll = ::performScroll, + onScrollCancelled = ::cancelScroll, + uiDispatcher = Dispatchers.Main.immediate, + ) + private val lazyAdapter = AndroidLazyAdapter(coordinator, ::currentModel) + private val scrollListener = + object : RecyclerView.OnScrollListener() { + override fun onScrolled( + recyclerView: RecyclerView, + dx: Int, + dy: Int, + ) { + reportLayoutInfo() + } + + override fun onScrollStateChanged( + recyclerView: RecyclerView, + newState: Int, + ) { + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + cancelPendingAnimatedScroll() + } + coordinator.reportScrollInProgress(newState != RecyclerView.SCROLL_STATE_IDLE) + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + completeAnimatedScroll() + } + reportLayoutInfo() + } + } + + init { + view.layoutManager = layoutManager + view.adapter = lazyAdapter + view.addItemDecoration(spacingDecoration) + view.addOnScrollListener(scrollListener) + } + + override fun setModel(model: LazyCollectionModel) { + coordinator.setModel(model) + } + + override fun dispose() { + pendingAnimatedScroll?.cancel() + pendingAnimatedScroll = null + view.removeOnScrollListener(scrollListener) + view.adapter = null + lazyAdapter.dispose() + coordinator.dispose() + } + + private fun currentModel(): LazyCollectionModel = checkNotNull(coordinator.model) { "Android lazy collection has no model." } + + private fun applyModel( + previous: LazyCollectionModel?, + current: LazyCollectionModel, + ): LazyRealizedItemUpdate { + val anchor = previous?.let(::captureAnchor) + val vertical = current.orientation == LazyListOrientation.Vertical + layoutManager.orientation = if (vertical) RecyclerView.VERTICAL else RecyclerView.HORIZONTAL + view.isVerticalScrollBarEnabled = vertical + view.isHorizontalScrollBarEnabled = !vertical + spacingDecoration.orientation = current.orientation + spacingDecoration.spacing = (current.spacing * density).roundToInt() + lazyAdapter.update() + anchor?.let { restoreAnchor(it, current) } + view.invalidateItemDecorations() + view.post(::reportLayoutInfo) + return LazyRealizedItemUpdate.RendererManaged + } + + private fun captureAnchor(model: LazyCollectionModel): AndroidLazyAnchor? { + val position = layoutManager.findFirstVisibleItemPosition() + if (position !in 0 until model.itemProvider.itemCount) return null + val child = layoutManager.findViewByPosition(position) ?: return null + val offset = + when (model.orientation) { + LazyListOrientation.Vertical -> layoutManager.getDecoratedTop(child) - view.paddingTop + LazyListOrientation.Horizontal -> layoutManager.getDecoratedLeft(child) - view.paddingLeft + } + return AndroidLazyAnchor( + key = model.itemProvider.key(position), + index = position, + itemCount = model.itemProvider.itemCount, + offset = offset, + ) + } + + private fun restoreAnchor( + anchor: AndroidLazyAnchor, + model: LazyCollectionModel, + ) { + val index = + model.itemProvider.findIndexByKey( + key = anchor.key, + expectedIndex = anchor.index, + previousItemCount = anchor.itemCount, + ) + if (index >= 0) { + layoutManager.scrollToPositionWithOffset(index, anchor.offset) + } + } + + private fun performScroll(request: LazyListScrollRequest) { + if (request.animated) { + cancelPendingAnimatedScroll(stopScroll = true) + pendingAnimatedScroll = request + val offset = -(request.scrollOffset * density).roundToInt() + layoutManager.startSmoothScroll( + OffsetLinearSmoothScroller(view.context, offset) { + completeAnimatedScroll(request) + }.apply { + targetPosition = request.index + }, + ) + } else { + layoutManager.scrollToPositionWithOffset( + request.index, + -(request.scrollOffset * density).roundToInt(), + ) + view.post { + reportLayoutInfo() + request.complete() + } + } + } + + private fun completeAnimatedScroll() { + completeAnimatedScroll(pendingAnimatedScroll ?: return) + } + + private fun completeAnimatedScroll(request: LazyListScrollRequest) { + if (pendingAnimatedScroll !== request) return + pendingAnimatedScroll = null + val itemCount = coordinator.model?.itemProvider?.itemCount ?: 0 + if (request.isActive && request.index in 0 until itemCount) { + request.complete() + } else { + request.cancel() + } + } + + private fun cancelScroll(request: LazyListScrollRequest) { + if (pendingAnimatedScroll !== request) return + pendingAnimatedScroll = null + view.stopScroll() + coordinator.reportScrollInProgress(false) + } + + private fun cancelPendingAnimatedScroll(stopScroll: Boolean = false) { + val request = pendingAnimatedScroll ?: return + pendingAnimatedScroll = null + if (stopScroll) view.stopScroll() + request.cancel() + } + + private fun reportLayoutInfo() { + val model = coordinator.model ?: return + val provider = model.itemProvider + val visibleItems = + buildList { + repeat(view.childCount) { childIndex -> + val child = view.getChildAt(childIndex) + val index = view.getChildAdapterPosition(child) + if (index !in 0 until provider.itemCount) return@repeat + val offset = + when (model.orientation) { + LazyListOrientation.Vertical -> child.top - view.paddingTop + LazyListOrientation.Horizontal -> child.left - view.paddingLeft + } + val size = + when (model.orientation) { + LazyListOrientation.Vertical -> child.measuredHeight + LazyListOrientation.Horizontal -> child.measuredWidth + } + add( + LazyListItemInfo( + key = provider.key(index), + index = index, + offset = offset / density, + size = size / density, + ), + ) + } + }.sortedBy(LazyListItemInfo::index) + val viewportSize = + when (model.orientation) { + LazyListOrientation.Vertical -> view.height - view.paddingTop - view.paddingBottom + LazyListOrientation.Horizontal -> view.width - view.paddingLeft - view.paddingRight + } + coordinator.reportLayoutInfo( + LazyListLayoutInfo( + totalItemsCount = provider.itemCount, + viewportStartOffset = 0f, + viewportEndOffset = viewportSize / density, + visibleItems = visibleItems, + ), + ) + } +} + +private class AndroidLazyAdapter( + private val coordinator: LazyCollectionCoordinator, + private val model: () -> LazyCollectionModel, +) : RecyclerView.Adapter() { + private val holders = mutableSetOf() + + init { + setHasStableIds(true) + } + + override fun getItemCount(): Int = coordinator.model?.itemProvider?.itemCount ?: 0 + + override fun getItemId(position: Int): Long { + val key = model().itemProvider.key(position) + return coordinator.itemIdentities.idFor(key) + } + + // Every Android View item uses the same LazyItemLinearLayout shell. Keeping native view types + // split by arbitrary business contentType only fragments RecyclerView's pool and retains one + // bucket per type; the Flare item host already resets composition content when it is rebound. + override fun getItemViewType(position: Int): Int = 0 + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): AndroidLazyViewHolder = AndroidLazyViewHolder(LazyItemLinearLayout(parent.context)) + + override fun onBindViewHolder( + holder: AndroidLazyViewHolder, + position: Int, + ) { + holders += holder + holder.bind(coordinator, position, model()) + } + + override fun onViewRecycled(holder: AndroidLazyViewHolder) { + holders -= holder + holder.recycle() + } + + fun update() { + // Provider callbacks may observe Compose snapshot state and cannot safely be diffed on a + // worker thread. Stable IDs let RecyclerView preserve its visible holders while avoiding + // a synchronous walk over every key on each model generation. + notifyDataSetChanged() + } + + fun dispose() { + holders.toList().forEach(AndroidLazyViewHolder::recycle) + holders.clear() + } +} + +private class AndroidLazyViewHolder( + private val root: LazyItemLinearLayout, +) : RecyclerView.ViewHolder(root) { + private var itemHost: LazyItemHost? = null + + fun bind( + coordinator: LazyCollectionCoordinator, + index: Int, + model: LazyCollectionModel, + ) { + root.bindModel(model) + root.layoutParams = + when (model.orientation) { + LazyListOrientation.Vertical -> { + RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + } + + LazyListOrientation.Horizontal -> { + RecyclerView.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + } + val host = itemHost ?: coordinator.createItemHost(AndroidViewChildren(root)).also { itemHost = it } + host.bind(index) + } + + fun recycle() { + itemHost?.dispose() + itemHost = null + } +} + +private class OffsetLinearSmoothScroller( + context: Context, + private val offset: Int, + private val onStopped: () -> Unit, +) : LinearSmoothScroller(context) { + override fun getVerticalSnapPreference(): Int = SNAP_TO_START + + override fun getHorizontalSnapPreference(): Int = SNAP_TO_START + + override fun calculateDtToFit( + viewStart: Int, + viewEnd: Int, + boxStart: Int, + boxEnd: Int, + snapPreference: Int, + ): Int = boxStart + offset - viewStart + + override fun onStop() { + super.onStop() + onStopped() + } +} + +private class LazyItemLinearLayout( + context: android.content.Context, +) : LinearLayout(context) { + private var model: LazyCollectionModel? = null + + fun bindModel(value: LazyCollectionModel) { + model = value + orientation = + when (value.orientation) { + LazyListOrientation.Vertical -> VERTICAL + LazyListOrientation.Horizontal -> HORIZONTAL + } + gravity = + when (value.orientation) { + LazyListOrientation.Vertical -> value.crossAxisAlignment.horizontalGravity() + LazyListOrientation.Horizontal -> value.crossAxisAlignment.verticalGravity() + } + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + model?.let(::applyCrossAxisAlignment) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + private fun applyCrossAxisAlignment(model: LazyCollectionModel) { + repeat(childCount) { index -> + val child = getChildAt(index) + val current = child.layoutParams + val params = + current as? LayoutParams + ?: LayoutParams( + current?.width ?: ViewGroup.LayoutParams.WRAP_CONTENT, + current?.height ?: ViewGroup.LayoutParams.WRAP_CONTENT, + ) + when (model.orientation) { + LazyListOrientation.Vertical -> { + if (model.crossAxisAlignment == LazyCrossAxisAlignment.Stretch) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT + } + params.gravity = model.crossAxisAlignment.horizontalGravity() + } + + LazyListOrientation.Horizontal -> { + if (model.crossAxisAlignment == LazyCrossAxisAlignment.Stretch) { + params.height = ViewGroup.LayoutParams.MATCH_PARENT + } + params.gravity = model.crossAxisAlignment.verticalGravity() + } + } + if (child.layoutParams !== params) { + child.layoutParams = params + } + } + } +} + +private fun LazyCrossAxisAlignment.horizontalGravity(): Int = + when (this) { + LazyCrossAxisAlignment.Start, LazyCrossAxisAlignment.Stretch -> Gravity.START + LazyCrossAxisAlignment.Center -> Gravity.CENTER_HORIZONTAL + LazyCrossAxisAlignment.End -> Gravity.END + } + +private fun LazyCrossAxisAlignment.verticalGravity(): Int = + when (this) { + LazyCrossAxisAlignment.Start, LazyCrossAxisAlignment.Stretch -> Gravity.TOP + LazyCrossAxisAlignment.Center -> Gravity.CENTER_VERTICAL + LazyCrossAxisAlignment.End -> Gravity.BOTTOM + } + +private class LazySpacingDecoration : RecyclerView.ItemDecoration() { + var orientation: LazyListOrientation = LazyListOrientation.Vertical + var spacing: Int = 0 + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + val position = parent.getChildAdapterPosition(view) + if (position < 0 || position >= state.itemCount - 1) return + when (orientation) { + LazyListOrientation.Vertical -> outRect.bottom = spacing + LazyListOrientation.Horizontal -> outRect.right = spacing + } + } +} + +private data class AndroidLazyAnchor( + val key: Any, + val index: Int, + val itemCount: Int, + val offset: Int, +) diff --git a/flareUI/lazy-layout/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeLazyLayoutRendererPlugin.kt b/flareUI/lazy-layout/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeLazyLayoutRendererPlugin.kt new file mode 100644 index 0000000000..0e3b16324e --- /dev/null +++ b/flareUI/lazy-layout/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeLazyLayoutRendererPlugin.kt @@ -0,0 +1,370 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.UiComposable +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.lazy.LazyCollectionCoordinator +import dev.dimension.flare.ui.lazy.LazyCollectionModel +import dev.dimension.flare.ui.lazy.LazyCollectionWidget +import dev.dimension.flare.ui.lazy.LazyCrossAxisAlignment +import dev.dimension.flare.ui.lazy.LazyItemHost +import dev.dimension.flare.ui.lazy.LazyListItemInfo +import dev.dimension.flare.ui.lazy.LazyListLayoutInfo +import dev.dimension.flare.ui.lazy.LazyListOrientation +import dev.dimension.flare.ui.lazy.LazyListScrollRequest +import dev.dimension.flare.ui.lazy.LazyRealizedItemUpdate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +import androidx.compose.foundation.lazy.LazyColumn as ComposeLazyColumn +import androidx.compose.foundation.lazy.LazyListState as ComposeLazyListState +import androidx.compose.foundation.lazy.LazyRow as ComposeLazyRow + +/** Jetpack Compose LazyColumn/LazyRow renderer for Flare lazy collections. */ +public object AndroidComposeLazyLayoutRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(LazyCollectionWidget::class) { _ -> + AndroidComposeLazyCollectionWidget() + } + } +} + +private class AndroidComposeLazyCollectionWidget : + AbstractAndroidComposeWidget(), + LazyCollectionWidget { + private var renderedModel: LazyCollectionModel? by mutableStateOf(null) + private var scrollExecutor: ((LazyListScrollRequest) -> Unit)? = null + private val pendingScrolls = mutableListOf() + private val scrollJobs = mutableMapOf() + private val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = { _, current -> + renderedModel = current + LazyRealizedItemUpdate.RendererManaged + }, + onScroll = ::performScroll, + onScrollCancelled = ::cancelScroll, + uiDispatcher = Dispatchers.Main.immediate, + ) + + override fun setModel(model: LazyCollectionModel) { + coordinator.setModel(model) + } + + @Composable + @UiComposable + override fun Render() { + val model = renderedModel ?: return + val state = remember(model.orientation) { ComposeLazyListState() } + val scope = rememberCoroutineScope() + val density = LocalDensity.current.density + InstallScrollExecutor(state, scope, density) + ReportState(state, model) + + when (model.orientation) { + LazyListOrientation.Vertical -> { + ComposeLazyColumn( + modifier = composeModifier, + state = state, + verticalArrangement = Arrangement.spacedBy(model.spacing.dp), + horizontalAlignment = model.crossAxisAlignment.horizontalAlignment(), + ) { + items( + count = model.itemProvider.itemCount, + key = { index -> coordinator.itemIdentities.idFor(model.itemProvider.key(index)) }, + contentType = model.itemProvider::contentType, + ) { index -> + RenderItem(model, index) + } + } + } + + LazyListOrientation.Horizontal -> { + ComposeLazyRow( + modifier = composeModifier, + state = state, + horizontalArrangement = Arrangement.spacedBy(model.spacing.dp), + verticalAlignment = model.crossAxisAlignment.verticalAlignment(), + ) { + items( + count = model.itemProvider.itemCount, + key = { index -> coordinator.itemIdentities.idFor(model.itemProvider.key(index)) }, + contentType = model.itemProvider::contentType, + ) { index -> + RenderItem(model, index) + } + } + } + } + } + + override fun dispose() { + pendingScrolls.forEach(LazyListScrollRequest::cancel) + pendingScrolls.clear() + scrollJobs.values.toList().forEach(Job::cancel) + scrollJobs.clear() + scrollExecutor = null + renderedModel = null + coordinator.dispose() + } + + @Composable + @UiComposable + private fun RenderItem( + model: LazyCollectionModel, + index: Int, + ) { + val key = model.itemProvider.key(index) + val contentType = model.itemProvider.contentType(index) + val root = remember(key, contentType) { AndroidComposeChildren() } + val itemHost = remember(root) { coordinator.createItemHost(root) } + var realized by remember(root) { mutableStateOf(false) } + + DisposableEffect(itemHost, model.itemProvider, index, key, contentType) { + itemHost.bind(index) + realized = true + onDispose {} + } + DisposableEffect(itemHost) { + onDispose(itemHost::dispose) + } + + val modifier = + when (model.orientation) { + LazyListOrientation.Vertical -> { + Modifier + .fillMaxWidth() + .then(if (realized) Modifier else Modifier.height(INITIAL_ITEM_ESTIMATE)) + } + + LazyListOrientation.Horizontal -> { + Modifier + .fillMaxHeight() + .then(if (realized) Modifier else Modifier.width(INITIAL_ITEM_ESTIMATE)) + } + } + LazyItemRoot( + orientation = model.orientation, + crossAxisAlignment = model.crossAxisAlignment, + modifier = modifier, + ) { + root.Render() + } + } + + @Composable + @UiComposable + private fun InstallScrollExecutor( + state: ComposeLazyListState, + scope: CoroutineScope, + density: Float, + ) { + DisposableEffect(state, scope, density) { + val executor: (LazyListScrollRequest) -> Unit = { request -> + val job = + scope.launch(start = CoroutineStart.LAZY) { + try { + val offset = (request.scrollOffset * density).roundToInt() + if (request.animated) { + state.animateScrollToItem(request.index, offset) + } else { + state.scrollToItem(request.index, offset) + } + request.complete() + } catch (_: Exception) { + request.cancel() + } finally { + scrollJobs.remove(request) + } + } + scrollJobs[request] = job + job.start() + } + scrollExecutor = executor + val pending = pendingScrolls.toList() + pendingScrolls.clear() + pending.forEach(executor) + onDispose { + if (scrollExecutor === executor) { + scrollExecutor = null + } + } + } + } + + @Composable + @UiComposable + private fun ReportState( + state: ComposeLazyListState, + model: LazyCollectionModel, + ) { + val density = LocalDensity.current.density + LaunchedEffect(state, model.itemProvider, density) { + snapshotFlow { state.layoutInfo to state.isScrollInProgress } + .collect { (layoutInfo, scrolling) -> + coordinator.reportScrollInProgress(scrolling) + if (layoutInfo.totalItemsCount != model.itemProvider.itemCount) return@collect + coordinator.reportLayoutInfo( + LazyListLayoutInfo( + totalItemsCount = layoutInfo.totalItemsCount, + viewportStartOffset = layoutInfo.viewportStartOffset / density, + viewportEndOffset = layoutInfo.viewportEndOffset / density, + visibleItems = + layoutInfo.visibleItemsInfo.mapNotNull { item -> + val rendererId = item.key as? Long + val businessKey = rendererId?.let(coordinator.itemIdentities::keyFor) + if (businessKey == null) return@mapNotNull null + LazyListItemInfo( + key = businessKey, + index = item.index, + offset = item.offset / density, + size = item.size / density, + ) + }, + ), + ) + } + } + } + + private fun performScroll(request: LazyListScrollRequest) { + val executor = scrollExecutor + if (executor == null) { + pendingScrolls += request + } else { + executor(request) + } + } + + private fun cancelScroll(request: LazyListScrollRequest) { + pendingScrolls.remove(request) + scrollJobs.remove(request)?.cancel() + } +} + +private fun LazyCrossAxisAlignment.horizontalAlignment(): Alignment.Horizontal = + when (this) { + LazyCrossAxisAlignment.Start, LazyCrossAxisAlignment.Stretch -> Alignment.Start + LazyCrossAxisAlignment.Center -> Alignment.CenterHorizontally + LazyCrossAxisAlignment.End -> Alignment.End + } + +private fun LazyCrossAxisAlignment.verticalAlignment(): Alignment.Vertical = + when (this) { + LazyCrossAxisAlignment.Start, LazyCrossAxisAlignment.Stretch -> Alignment.Top + LazyCrossAxisAlignment.Center -> Alignment.CenterVertically + LazyCrossAxisAlignment.End -> Alignment.Bottom + } + +@Composable +@UiComposable +private fun LazyItemRoot( + orientation: LazyListOrientation, + crossAxisAlignment: LazyCrossAxisAlignment, + modifier: Modifier, + content: AndroidComposeContent, +) { + Layout( + content = content, + modifier = modifier, + ) { measurables, constraints -> + when (orientation) { + LazyListOrientation.Vertical -> { + val childConstraints = + Constraints( + minWidth = + if (crossAxisAlignment == LazyCrossAxisAlignment.Stretch && constraints.hasBoundedWidth) { + constraints.maxWidth + } else { + 0 + }, + maxWidth = constraints.maxWidth, + minHeight = 0, + maxHeight = Constraints.Infinity, + ) + val placeables = measurables.map { it.measure(childConstraints) } + val width = (placeables.maxOfOrNull { it.width } ?: 0).coerceIn(constraints.minWidth, constraints.maxWidth) + val height = placeables.sumOf { it.height }.coerceIn(constraints.minHeight, constraints.maxHeight) + layout(width, height) { + var y = 0 + placeables.forEach { placeable -> + placeable.placeRelative( + x = crossAxisAlignment.position(width, placeable.width), + y = y, + ) + y += placeable.height + } + } + } + + LazyListOrientation.Horizontal -> { + val childConstraints = + Constraints( + minWidth = 0, + maxWidth = Constraints.Infinity, + minHeight = + if (crossAxisAlignment == LazyCrossAxisAlignment.Stretch && constraints.hasBoundedHeight) { + constraints.maxHeight + } else { + 0 + }, + maxHeight = constraints.maxHeight, + ) + val placeables = measurables.map { it.measure(childConstraints) } + val width = placeables.sumOf { it.width }.coerceIn(constraints.minWidth, constraints.maxWidth) + val height = (placeables.maxOfOrNull { it.height } ?: 0).coerceIn(constraints.minHeight, constraints.maxHeight) + layout(width, height) { + var x = 0 + placeables.forEach { placeable -> + placeable.placeRelative( + x = x, + y = crossAxisAlignment.position(height, placeable.height), + ) + x += placeable.width + } + } + } + } + } +} + +private fun LazyCrossAxisAlignment.position( + available: Int, + child: Int, +): Int = + when (this) { + LazyCrossAxisAlignment.Start, LazyCrossAxisAlignment.Stretch -> 0 + LazyCrossAxisAlignment.Center -> (available - child) / 2 + LazyCrossAxisAlignment.End -> available - child + } + +// ponytail: One conservative first-frame estimate prevents zero-sized items from realizing the +// whole data set. Add per-contentType estimates only when heterogeneous production lists need them. +private val INITIAL_ITEM_ESTIMATE = 48.dp diff --git a/flareUI/lazy-layout/src/androidMain/res/layout/flare_lazy_recycler_view.xml b/flareUI/lazy-layout/src/androidMain/res/layout/flare_lazy_recycler_view.xml new file mode 100644 index 0000000000..bdf2987b5b --- /dev/null +++ b/flareUI/lazy-layout/src/androidMain/res/layout/flare_lazy_recycler_view.xml @@ -0,0 +1,5 @@ + + diff --git a/flareUI/lazy-layout/src/appleTest/kotlin/dev/dimension/flare/ui/lazy/AppleTestSupport.kt b/flareUI/lazy-layout/src/appleTest/kotlin/dev/dimension/flare/ui/lazy/AppleTestSupport.kt new file mode 100644 index 0000000000..005f6e63a0 --- /dev/null +++ b/flareUI/lazy-layout/src/appleTest/kotlin/dev/dimension/flare/ui/lazy/AppleTestSupport.kt @@ -0,0 +1,21 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.lazy + +import androidx.compose.runtime.snapshots.Snapshot +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +internal fun awaitAppleUi( + message: String, + condition: () -> Boolean, +) { + val startedAt = TimeSource.Monotonic.markNow() + while (!condition() && startedAt.elapsedNow() < 5.seconds) { + Snapshot.sendApplyNotifications() + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, true) + } + check(condition()) { message } +} diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/AdaptiveLazyScrollPolicy.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/AdaptiveLazyScrollPolicy.kt new file mode 100644 index 0000000000..c0a533abf9 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/AdaptiveLazyScrollPolicy.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.ui.lazy + +import kotlin.math.abs + +internal fun needsAdaptiveLazyScrollCorrection( + current: Double, + target: Double, + tolerance: Double = 0.5, +): Boolean = abs(current - target) > tolerance diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/InvalidatingLazyItemChildren.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/InvalidatingLazyItemChildren.kt new file mode 100644 index 0000000000..00e19f247f --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/InvalidatingLazyItemChildren.kt @@ -0,0 +1,14 @@ +package dev.dimension.flare.ui.lazy + +import dev.dimension.flare.ui.FlareChildren + +/** Invalidates native item geometry after one child-composition apply transaction completes. */ +internal class InvalidatingLazyItemChildren( + private val delegate: FlareChildren, + private val onContentChanged: () -> Unit, +) : FlareChildren by delegate { + override fun onEndChanges() { + delegate.onEndChanges() + onContentChanged() + } +} diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyCollectionCoordinator.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyCollectionCoordinator.kt new file mode 100644 index 0000000000..37497c719c --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyCollectionCoordinator.kt @@ -0,0 +1,260 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import kotlinx.coroutines.CoroutineDispatcher +import androidx.compose.runtime.key as composeKey + +internal class LazyCollectionCoordinator( + private val owner: Any, + private val onModelChanged: ( + previous: LazyCollectionModel?, + current: LazyCollectionModel, + ) -> LazyRealizedItemUpdate, + private val onScroll: (LazyListScrollRequest) -> Unit, + private val onScrollCancelled: (LazyListScrollRequest) -> Unit = {}, + private val uiDispatcher: CoroutineDispatcher, +) { + private val itemHosts = mutableSetOf() + private val realizedKeys = mutableMapOf() + internal val itemIdentities = LazyItemIdentityRegistry() + var model: LazyCollectionModel? = null + private set + + fun setModel(value: LazyCollectionModel) { + val previous = model + val previouslyRealizedHosts = itemHosts.toList() + if (previous?.state !== value.state) { + previous?.state?.detach(owner) + } + model = value + if (value.itemProvider.itemCount <= MAX_EAGER_RENDERER_IDENTITY_PRUNE_ITEMS) { + itemIdentities.removeMissingKeys(value.itemProvider) + } + value.state.attach( + owner = owner, + itemCount = value.itemProvider.itemCount, + onScroll = onScroll, + onScrollCancelled = onScrollCancelled, + uiDispatcher = uiDispatcher, + ) + val realizedItemUpdate = onModelChanged(previous, value) + if (previous != null && realizedItemUpdate == LazyRealizedItemUpdate.Rebind) { + rebindPreviouslyRealizedHosts(previouslyRealizedHosts, value.itemProvider) + } + } + + fun reportLayoutInfo(value: LazyListLayoutInfo) { + model?.state?.updateLayoutInfo(owner, value) + } + + fun reportScrollInProgress(value: Boolean) { + model?.state?.updateScrollInProgress(owner, value) + } + + fun createItemHost(root: FlareChildren): LazyItemHost { + checkNotNull(model) { "A lazy collection model must be set before realizing items." } + return LazyItemHost(this, root).also(itemHosts::add) + } + + fun realizedItemsMatch(provider: LazyItemProvider): Boolean = + itemHosts.all { host -> + val key = host.key ?: return@all true + host.index in 0 until provider.itemCount && provider.key(host.index) == key + } + + internal fun requireModel(): LazyCollectionModel = checkNotNull(model) { "The lazy collection has already been disposed." } + + internal fun bindKey( + host: LazyItemHost, + previousKey: Any?, + key: Any, + index: Int, + updateContentSynchronously: Boolean, + ) { + if (previousKey != null && realizedKeys[previousKey] === host) { + realizedKeys.remove(previousKey) + } + val previousHost = realizedKeys[key] + if (previousHost != null && previousHost !== host) { + val provider = requireModel().itemProvider + val previousIndexStillOwnsKey = + previousHost.index in 0 until provider.itemCount && + provider.key(previousHost.index) == key + check(previousHost.index == index || !previousIndexStillOwnsKey) { + "Lazy list key $key is used by more than one realized item." + } + if (previousHost.index == index || previousHost.index !in 0 until provider.itemCount) { + previousHost.dispose() + } else { + // Move the old holder to the key now owned by its current index before giving this + // key to the replacement. This preserves its composition and avoids both a visible + // blank and overlapping SaveableStateProvider instances for the same stable key. + previousHost.bind(previousHost.index, updateContentSynchronously) + } + } + realizedKeys[key] = host + } + + internal fun release( + host: LazyItemHost, + key: Any?, + ) { + itemHosts.remove(host) + if (key != null && realizedKeys[key] === host) { + realizedKeys.remove(key) + } + } + + fun dispose() { + model?.state?.detach(owner) + model = null + val hosts = itemHosts.toList() + itemHosts.clear() + realizedKeys.clear() + hosts.forEach(LazyItemHost::disposeFromCoordinator) + } + + private fun rebindPreviouslyRealizedHosts( + hosts: List, + provider: LazyItemProvider, + ) { + val activeHosts = hosts.filter { it in itemHosts && it.key != null } + if (activeHosts.isEmpty()) return + val requestedKeys = activeHosts.mapNotNull(LazyItemHost::key).toSet() + val indicesByKey = mutableMapOf() + repeat(provider.itemCount) { index -> + val key = provider.key(index) + if (key in requestedKeys) { + check(indicesByKey.put(key, index) == null) { + "Lazy list key $key occurs more than once in the updated item provider." + } + } + } + activeHosts.forEach { host -> + val nextIndex = indicesByKey[host.key] + if (nextIndex == null) { + host.dispose() + } else { + host.bind(nextIndex, updateContentSynchronously = false) + } + } + } +} + +private const val MAX_EAGER_RENDERER_IDENTITY_PRUNE_ITEMS: Int = 1_000 + +internal enum class LazyRealizedItemUpdate { + Rebind, + RendererManaged, +} + +internal class LazyItemHost( + private val coordinator: LazyCollectionCoordinator, + private val root: FlareChildren, +) { + private var composition: FlareSubcomposition? = null + private var compositionFactory: FlareSubcompositionFactory? = null + private var content: FlareContent? = null + private var contentVersion: MutableState? = null + private var boundModel: LazyCollectionModel? = null + private var disposed: Boolean = false + private val hostedContent: FlareContent = { + contentVersion?.value + checkNotNull(content).invoke() + } + + internal val isDisposed: Boolean + get() = disposed + + var index: Int = -1 + private set + + var key: Any? = null + private set + + fun bind(index: Int) { + bind(index, updateContentSynchronously = true) + } + + internal fun bind( + index: Int, + updateContentSynchronously: Boolean, + ) { + check(!disposed) { "Lazy item host is already disposed." } + val model = coordinator.requireModel() + val provider = model.itemProvider + require(index in 0 until provider.itemCount) { + "Lazy list index $index is outside 0 until ${provider.itemCount}." + } + val nextKey = provider.key(index) + if (composition != null && boundModel === model && this.index == index && key == nextKey) { + return + } + coordinator.bindKey(this, key, nextKey, index, updateContentSynchronously) + val nextContent: FlareContent = { + composeKey(nextKey) { + provider.Item(index) + } + } + + if (composition == null || compositionFactory !== model.subcompositions) { + composition?.dispose() + compositionFactory = model.subcompositions + content = nextContent + contentVersion = mutableStateOf(0) + composition = + model.subcompositions.create(root).also { nextComposition -> + nextComposition.setContent(hostedContent) + } + } else { + content = nextContent + if (updateContentSynchronously) { + checkNotNull(composition).setContent { + contentVersion?.value + checkNotNull(content).invoke() + } + } else { + val version = checkNotNull(contentVersion) + version.value += 1 + } + } + + this.index = index + key = nextKey + boundModel = model + } + + fun dispose() { + if (disposed) return + disposed = true + composition?.dispose() + composition = null + compositionFactory = null + content = null + contentVersion = null + boundModel = null + coordinator.release(this, key) + key = null + index = -1 + } + + internal fun disposeFromCoordinator() { + if (disposed) return + disposed = true + composition?.dispose() + composition = null + compositionFactory = null + content = null + contentVersion = null + boundModel = null + key = null + index = -1 + } +} diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyCollectionWidget.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyCollectionWidget.kt new file mode 100644 index 0000000000..bde173b4e9 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyCollectionWidget.kt @@ -0,0 +1,93 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget +import dev.dimension.flare.ui.LowLevelFlareApi + +/** Scroll direction shared by [LazyColumn] and [LazyRow]. */ +public enum class LazyListOrientation { + Vertical, + Horizontal, +} + +/** Cross-axis placement requested by a lazy collection. */ +public enum class LazyCrossAxisAlignment { + Start, + Center, + End, + Stretch, +} + +/** Deferred item source consumed by a renderer-provided virtual collection. */ +@LowLevelFlareApi +public interface LazyItemProvider { + public val itemCount: Int + + public fun key(index: Int): Any + + public fun contentType(index: Int): Any? + + /** Invalidates a cached measurement when layout-affecting content changes under a stable key. */ + public fun layoutVersion(index: Int): Any? = Unit + + @Composable + @FlareUiComposable + public fun Item(index: Int) +} + +/** Assigns renderer-safe identities to business keys for one renderer session. */ +internal class LazyItemIdentityRegistry { + private var idsByKey = mutableMapOf() + private var keysById = mutableMapOf() + private var nextId: Long = 0L + + fun idFor(key: Any): Long { + idsByKey[key]?.let { return it } + check(nextId < Long.MAX_VALUE) { + "A lazy-list identity session exhausted the available renderer identities." + } + val id = nextId++ + idsByKey[key] = id + keysById[id] = key + return id + } + + /** Resolves a renderer identity without consulting a possibly newer provider generation. */ + fun keyFor(id: Long): Any? = keysById[id] + + fun removeMissingKeys(provider: LazyItemProvider) { + if (idsByKey.isEmpty()) return + val retainedIdsByKey = mutableMapOf() + val retainedKeysById = mutableMapOf() + repeat(provider.itemCount) { index -> + val key = provider.key(index) + idsByKey[key]?.let { id -> + retainedIdsByKey[key] = id + retainedKeysById[id] = key + } + } + idsByKey = retainedIdsByKey + keysById = retainedKeysById + } +} + +/** Atomic model handed from the Flare composition to one platform collection renderer. */ +@LowLevelFlareApi +public data class LazyCollectionModel( + public val orientation: LazyListOrientation, + public val spacing: Float, + public val crossAxisAlignment: LazyCrossAxisAlignment, + public val itemProvider: LazyItemProvider, + public val subcompositions: FlareSubcompositionFactory, + public val state: LazyListState, +) + +/** Renderer seam implemented by the platform lazy-list adapters. */ +@LowLevelFlareApi +public interface LazyCollectionWidget : FlareWidget { + public fun setModel(model: LazyCollectionModel) +} diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyItemKeyLookup.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyItemKeyLookup.kt new file mode 100644 index 0000000000..c85a96fcce --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyItemKeyLookup.kt @@ -0,0 +1,51 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +/** + * Resolves an anchor key with O(1) append/prepend and nearby-move fast paths. + * + * The final scan preserves stable-key anchor semantics for arbitrary reorder until providers expose + * an inverse key index. It is intentionally reached only after the common candidates miss. + */ +internal fun LazyItemProvider.findIndexByKey( + key: Any, + expectedIndex: Int, + previousItemCount: Int, +): Int { + if (itemCount == 0) return -1 + + val shiftedIndex = expectedIndex.toLong() + itemCount.toLong() - previousItemCount.toLong() + if (shiftedIndex in 0L until itemCount.toLong()) { + val index = shiftedIndex.toInt() + if (this.key(index) == key) return index + } + + val center = expectedIndex.coerceIn(0, itemCount - 1) + if (center.toLong() != shiftedIndex && this.key(center) == key) return center + + fun searchNear(candidate: Int): Int { + repeat(minOf(LOCAL_KEY_SEARCH_DISTANCE, itemCount)) { distanceOffset -> + val distance = distanceOffset + 1 + val before = candidate - distance + if (before >= 0 && this.key(before) == key) return before + val after = candidate.toLong() + distance + if (after < itemCount.toLong() && this.key(after.toInt()) == key) return after.toInt() + } + return -1 + } + + if (shiftedIndex in 0L until itemCount.toLong()) { + searchNear(shiftedIndex.toInt()).takeIf { it >= 0 }?.let { return it } + } + if (center.toLong() != shiftedIndex) { + searchNear(center).takeIf { it >= 0 }?.let { return it } + } + + repeat(itemCount) { index -> + if (this.key(index) == key) return index + } + return -1 +} + +private const val LOCAL_KEY_SEARCH_DISTANCE: Int = 64 diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyItemReusePool.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyItemReusePool.kt new file mode 100644 index 0000000000..52e46401a4 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyItemReusePool.kt @@ -0,0 +1,141 @@ +package dev.dimension.flare.ui.lazy + +/** + * A bounded stable-key/content-type pool shared by the adaptive Apple renderers. + * + * Exact-key lookup and compatible LIFO fallback are both O(1) amortized. Type buckets retain lazy + * tombstones after an exact-key take, then compact once those tombstones exceed a small bound. + */ +internal class LazyItemReusePool( + maxSize: Int, + private val onEvicted: (T) -> Unit, +) { + private class Entry( + val contentType: Any, + val key: Any, + val value: T, + var active: Boolean = true, + ) + + private class TypeBucket { + var entries = ArrayDeque>() + var activeCount: Int = 0 + } + + private val entriesByKey = linkedMapOf>() + private val entriesByType = mutableMapOf>() + private var maxSize = maxSize + + init { + require(maxSize >= 0) { "Lazy item reuse pool size must be non-negative." } + } + + val size: Int + get() = entriesByKey.size + + fun resize(maxSize: Int) { + require(maxSize >= 0) { "Lazy item reuse pool size must be non-negative." } + this.maxSize = maxSize + trimToSize() + } + + fun put( + contentType: Any, + key: Any, + value: T, + ) { + entriesByKey.remove(key)?.let { previous -> + deactivate(previous) + onEvicted(previous.value) + } + val entry = Entry(contentType, key, value) + entriesByKey[key] = entry + entriesByType.getOrPut(contentType, ::TypeBucket).apply { + entries.addLast(entry) + activeCount += 1 + compactIfNeeded() + } + trimToSize() + } + + fun take( + contentType: Any, + key: Any, + ): T? { + entriesByKey.remove(key)?.let { exact -> + deactivate(exact) + if (exact.contentType == contentType) return exact.value + onEvicted(exact.value) + } + + val bucket = entriesByType[contentType] ?: return null + while (bucket.entries.isNotEmpty()) { + val fallback = bucket.entries.removeLast() + if (!fallback.active) continue + check(entriesByKey.remove(fallback.key) === fallback) { + "Lazy item reuse pool indices diverged for key ${fallback.key}." + } + deactivate(fallback) + return fallback.value + } + error("Lazy item reuse pool type bucket lost its active entries.") + } + + fun clear() { + val retained = entriesByKey.values.map(Entry::value) + entriesByKey.clear() + entriesByType.clear() + evictAll(retained) + } + + private fun trimToSize() { + var evicted: MutableList? = null + while (entriesByKey.size > maxSize) { + val oldest = entriesByKey.entries.first() + val oldestKey = oldest.key + val oldestEntry = oldest.value + entriesByKey.remove(oldestKey) + deactivate(oldestEntry) + if (evicted == null) evicted = mutableListOf() + evicted += oldestEntry.value + } + evicted?.let(::evictAll) + } + + private fun deactivate(entry: Entry) { + if (!entry.active) return + entry.active = false + val bucket = checkNotNull(entriesByType[entry.contentType]) + bucket.activeCount -= 1 + check(bucket.activeCount >= 0) { "Lazy item reuse pool type count became negative." } + if (bucket.activeCount == 0) { + entriesByType.remove(entry.contentType) + } else { + bucket.compactIfNeeded() + } + } + + private fun TypeBucket.compactIfNeeded() { + val retainedCapacity = maxOf(MIN_TYPE_BUCKET_CAPACITY, activeCount * 2) + if (entries.size <= retainedCapacity) return + val compacted = ArrayDeque>() + entries.forEach { entry -> + if (entry.active) compacted.addLast(entry) + } + entries = compacted + } + + private fun evictAll(values: List) { + var failure: Throwable? = null + values.forEach { value -> + try { + onEvicted(value) + } catch (error: Throwable) { + if (failure == null) failure = error + } + } + failure?.let { throw it } + } +} + +private const val MIN_TYPE_BUCKET_CAPACITY: Int = 16 diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyList.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyList.kt new file mode 100644 index 0000000000..d2c2723204 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyList.kt @@ -0,0 +1,186 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.foundation.HorizontalAlignment +import dev.dimension.flare.ui.foundation.VerticalAlignment +import dev.dimension.flare.ui.rememberFlareSubcompositionFactory + +/** + * A vertically scrolling collection which composes item content only while its native cell is + * realized. Every item key must be unique and stable across updates. + * + * The list's main axis must receive a bounded size from its parent or [modifier]. + */ +@Composable +@FlareUiComposable +public fun LazyColumn( + modifier: FlareModifier = FlareModifier.None, + state: LazyListState = rememberLazyListState(), + spacing: Float = 0f, + horizontalAlignment: HorizontalAlignment = HorizontalAlignment.Stretch, + content: LazyListScope.() -> Unit, +) { + LazyList( + orientation = LazyListOrientation.Vertical, + modifier = modifier, + state = state, + spacing = spacing, + crossAxisAlignment = horizontalAlignment.toLazyAlignment(), + content = content, + ) +} + +/** + * A horizontally scrolling collection which composes item content only while its native cell is + * realized. Every item key must be unique and stable across updates. + * + * The list's main axis must receive a bounded size from its parent or [modifier]. + */ +@Composable +@FlareUiComposable +public fun LazyRow( + modifier: FlareModifier = FlareModifier.None, + state: LazyListState = rememberLazyListState(), + spacing: Float = 0f, + verticalAlignment: VerticalAlignment = VerticalAlignment.Stretch, + content: LazyListScope.() -> Unit, +) { + LazyList( + orientation = LazyListOrientation.Horizontal, + modifier = modifier, + state = state, + spacing = spacing, + crossAxisAlignment = verticalAlignment.toLazyAlignment(), + content = content, + ) +} + +@Composable +@FlareUiComposable +private fun LazyList( + orientation: LazyListOrientation, + modifier: FlareModifier, + state: LazyListState, + spacing: Float, + crossAxisAlignment: LazyCrossAxisAlignment, + content: LazyListScope.() -> Unit, +) { + require(spacing.isFinite() && spacing >= 0f) { + "Lazy list spacing must be a finite, non-negative value." + } + val scope = IntervalLazyListScope().apply(content) + val saveableStateHolder = rememberSaveableStateHolder() + val saveableKeys = remember { LazySaveableKeyRegistry() } + val itemProvider = + SaveableLazyItemProvider( + delegate = scope.build(), + stateHolder = saveableStateHolder, + stateKeys = saveableKeys, + ) + SideEffect { + if (itemProvider.itemCount <= MAX_EAGER_SAVEABLE_KEY_PRUNE_ITEMS) { + saveableKeys.removeMissingKeys(itemProvider, saveableStateHolder) + } + } + val model = + LazyCollectionModel( + orientation = orientation, + spacing = spacing, + crossAxisAlignment = crossAxisAlignment, + itemProvider = itemProvider, + subcompositions = rememberFlareSubcompositionFactory(), + state = state, + ) + EmitFlareWidget( + componentType = LazyCollectionWidget::class, + modifier = modifier, + update = { + set(model, LazyCollectionWidget::setModel) + }, + ) +} + +private class SaveableLazyItemProvider( + private val delegate: LazyItemProvider, + private val stateHolder: SaveableStateHolder, + private val stateKeys: LazySaveableKeyRegistry, +) : LazyItemProvider by delegate { + @Composable + @FlareUiComposable + override fun Item(index: Int) { + val key = delegate.key(index) + val stateKey = stateKeys.idFor(delegate, index, key) + stateHolder.SaveableStateProvider(stateKey) { + delegate.Item(index) + } + } +} + +private class LazySaveableKeyRegistry { + private val stateIds = mutableMapOf() + private val validatedIndices = mutableMapOf() + private var nextStateId: Long = 0L + + fun idFor( + provider: LazyItemProvider, + index: Int, + key: Any, + ): Long { + val previousIndex = validatedIndices[key] + val duplicateInCurrentProvider = + previousIndex != null && + previousIndex != index && + previousIndex in 0 until provider.itemCount && + provider.key(previousIndex) == key + check(!duplicateInCurrentProvider) { + "Lazy list key $key occurs at both index $previousIndex and $index in one provider generation." + } + validatedIndices[key] = index + return stateIds.getOrPut(key) { nextStateId++ } + } + + fun removeMissingKeys( + provider: LazyItemProvider, + stateHolder: SaveableStateHolder, + ) { + if (stateIds.isEmpty() || stateIds.size > MAX_EAGER_SAVEABLE_KEY_PRUNE_ITEMS) return + val retainedKeys = mutableSetOf() + repeat(provider.itemCount) { index -> + val key = provider.key(index) + if (key in stateIds) retainedKeys += key + } + val removed = stateIds.keys - retainedKeys + removed.forEach { key -> + val stateId = checkNotNull(stateIds.remove(key)) + validatedIndices.remove(key) + stateHolder.removeState(stateId) + } + } +} + +private const val MAX_EAGER_SAVEABLE_KEY_PRUNE_ITEMS: Int = 1_000 + +private fun HorizontalAlignment.toLazyAlignment(): LazyCrossAxisAlignment = + when (this) { + HorizontalAlignment.Start -> LazyCrossAxisAlignment.Start + HorizontalAlignment.Center -> LazyCrossAxisAlignment.Center + HorizontalAlignment.End -> LazyCrossAxisAlignment.End + HorizontalAlignment.Stretch -> LazyCrossAxisAlignment.Stretch + } + +private fun VerticalAlignment.toLazyAlignment(): LazyCrossAxisAlignment = + when (this) { + VerticalAlignment.Top -> LazyCrossAxisAlignment.Start + VerticalAlignment.Center -> LazyCrossAxisAlignment.Center + VerticalAlignment.Bottom -> LazyCrossAxisAlignment.End + VerticalAlignment.Stretch -> LazyCrossAxisAlignment.Stretch + } diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListChangeSet.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListChangeSet.kt new file mode 100644 index 0000000000..7a483bb518 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListChangeSet.kt @@ -0,0 +1,108 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +internal data class LazyListMove( + val fromIndex: Int, + val toIndex: Int, +) + +internal data class LazyListChangeSet( + val removedIndices: List, + val insertedIndices: List, + val moves: List, + val reloadedIndices: List, +) { + val isEmpty: Boolean + get() = + removedIndices.isEmpty() && + insertedIndices.isEmpty() && + moves.isEmpty() && + reloadedIndices.isEmpty() +} + +internal fun calculateLazyListChangeSet( + previous: LazyItemProvider, + current: LazyItemProvider, +): LazyListChangeSet { + val oldSnapshot = previous.snapshot("previous") + val newSnapshot = current.snapshot("current") + val removed = oldSnapshot.keys.indices.filter { oldSnapshot.keys[it] !in newSnapshot.indicesByKey } + val inserted = newSnapshot.keys.indices.filter { newSnapshot.keys[it] !in oldSnapshot.indicesByKey } + val retainedKeys = oldSnapshot.keys.filter { it in newSnapshot.indicesByKey } + val targetRetainedKeys = newSnapshot.keys.filter { it in oldSnapshot.indicesByKey } + val moves = calculateMoves(oldSnapshot, newSnapshot, retainedKeys, targetRetainedKeys) + val reloaded = + retainedKeys.mapNotNull { key -> + val oldIndex = checkNotNull(oldSnapshot.indicesByKey[key]) + val newIndex = checkNotNull(newSnapshot.indicesByKey[key]) + if (oldSnapshot.contentTypes[oldIndex] == newSnapshot.contentTypes[newIndex] && + oldSnapshot.layoutVersions[oldIndex] == newSnapshot.layoutVersions[newIndex] + ) { + null + } else { + newIndex + } + } + return LazyListChangeSet( + removedIndices = removed, + insertedIndices = inserted, + moves = moves, + reloadedIndices = reloaded, + ) +} + +private fun calculateMoves( + oldSnapshot: LazyListSnapshot, + newSnapshot: LazyListSnapshot, + retainedKeys: List, + targetRetainedKeys: List, +): List { + // Appending, prepending, removing, and content-only updates all retain relative order. Avoid + // the indexed-list simulation in that overwhelmingly common path: it is otherwise O(n²). + if (retainedKeys == targetRetainedKeys) return emptyList() + + val workingKeys = retainedKeys.toMutableList() + return buildList { + targetRetainedKeys.forEachIndexed { targetIndex, key -> + val currentIndex = workingKeys.indexOf(key) + check(currentIndex >= 0) + if (currentIndex != targetIndex) { + add( + LazyListMove( + fromIndex = checkNotNull(oldSnapshot.indicesByKey[key]), + toIndex = checkNotNull(newSnapshot.indicesByKey[key]), + ), + ) + workingKeys.removeAt(currentIndex) + workingKeys.add(targetIndex, key) + } + } + } +} + +private fun LazyItemProvider.snapshot(label: String): LazyListSnapshot { + val keys = ArrayList(itemCount) + val contentTypes = ArrayList(itemCount) + val layoutVersions = ArrayList(itemCount) + val indicesByKey = HashMap(itemCount) + repeat(itemCount) { index -> + val key = key(index) + val previousIndex = indicesByKey.put(key, index) + check(previousIndex == null) { + "Lazy list key $key occurs more than once in the $label item provider " + + "(indices $previousIndex and $index)." + } + keys += key + contentTypes += contentType(index) + layoutVersions += layoutVersion(index) + } + return LazyListSnapshot(keys, contentTypes, layoutVersions, indicesByKey) +} + +private class LazyListSnapshot( + val keys: List, + val contentTypes: List, + val layoutVersions: List, + val indicesByKey: Map, +) diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListScope.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListScope.kt new file mode 100644 index 0000000000..d1dca77044 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListScope.kt @@ -0,0 +1,225 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) +@file:Suppress("ktlint:standard:annotation") + +package dev.dimension.flare.ui.lazy + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareUiComposable + +@DslMarker +public annotation class LazyListScopeMarker + +public typealias LazyIndexedContent = @Composable @FlareUiComposable (index: Int) -> Unit + +public typealias LazyItemContent = @Composable @FlareUiComposable (item: T) -> Unit + +public typealias LazyIndexedItemContent = @Composable @FlareUiComposable (index: Int, item: T) -> Unit + +/** Declarative item builder which records intervals without composing their contents. */ +@LazyListScopeMarker +public interface LazyListScope { + /** + * Adds one item. [key] identifies its state, [contentType] identifies reuse compatibility, and + * [layoutVersion] invalidates a cached native measurement without changing the stable key. + */ + public fun item( + key: Any, + contentType: Any? = null, + layoutVersion: Any? = Unit, + content: FlareContent, + ) + + /** Adds [count] deferred items without composing any item while the interval is declared. */ + public fun items( + count: Int, + key: (index: Int) -> Any, + contentType: (index: Int) -> Any? = { null }, + layoutVersion: (index: Int) -> Any? = { Unit }, + itemContent: LazyIndexedContent, + ) +} + +/** + * Adds [items] by reference without copying it. Treat the collection as immutable until a new + * provider generation is composed; replace the collection to publish an update atomically. + */ +public fun LazyListScope.items( + items: List, + key: (T) -> Any, + contentType: (T) -> Any? = { null }, + layoutVersion: (T) -> Any? = { Unit }, + itemContent: LazyItemContent, +) { + items( + count = items.size, + key = { index -> key(items[index]) }, + contentType = { index -> contentType(items[index]) }, + layoutVersion = { index -> layoutVersion(items[index]) }, + itemContent = { index -> itemContent(items[index]) }, + ) +} + +/** Array variant of [items]; replace the array rather than mutating it in place. */ +public fun LazyListScope.items( + items: Array, + key: (T) -> Any, + contentType: (T) -> Any? = { null }, + layoutVersion: (T) -> Any? = { Unit }, + itemContent: LazyItemContent, +) { + items( + count = items.size, + key = { index -> key(items[index]) }, + contentType = { index -> contentType(items[index]) }, + layoutVersion = { index -> layoutVersion(items[index]) }, + itemContent = { index -> itemContent(items[index]) }, + ) +} + +/** Indexed list variant of [items] with the same zero-copy collection contract. */ +public fun LazyListScope.itemsIndexed( + items: List, + key: (index: Int, item: T) -> Any, + contentType: (index: Int, item: T) -> Any? = { _, _ -> null }, + layoutVersion: (index: Int, item: T) -> Any? = { _, _ -> Unit }, + itemContent: LazyIndexedItemContent, +) { + items( + count = items.size, + key = { index -> key(index, items[index]) }, + contentType = { index -> contentType(index, items[index]) }, + layoutVersion = { index -> layoutVersion(index, items[index]) }, + itemContent = { index -> itemContent(index, items[index]) }, + ) +} + +/** Indexed array variant of [items] with the same zero-copy collection contract. */ +public fun LazyListScope.itemsIndexed( + items: Array, + key: (index: Int, item: T) -> Any, + contentType: (index: Int, item: T) -> Any? = { _, _ -> null }, + layoutVersion: (index: Int, item: T) -> Any? = { _, _ -> Unit }, + itemContent: LazyIndexedItemContent, +) { + items( + count = items.size, + key = { index -> key(index, items[index]) }, + contentType = { index -> contentType(index, items[index]) }, + layoutVersion = { index -> layoutVersion(index, items[index]) }, + itemContent = { index -> itemContent(index, items[index]) }, + ) +} + +internal class IntervalLazyListScope : LazyListScope { + private val intervals = mutableListOf() + private var itemCount: Int = 0 + + override fun item( + key: Any, + contentType: Any?, + layoutVersion: Any?, + content: FlareContent, + ) { + addInterval( + count = 1, + key = { key }, + contentType = { contentType }, + layoutVersion = { layoutVersion }, + itemContent = { content() }, + ) + } + + override fun items( + count: Int, + key: (index: Int) -> Any, + contentType: (index: Int) -> Any?, + layoutVersion: (index: Int) -> Any?, + itemContent: LazyIndexedContent, + ) { + require(count >= 0) { "Lazy list item count must be non-negative." } + if (count == 0) return + addInterval(count, key, contentType, layoutVersion, itemContent) + } + + fun build(): LazyItemProvider = IntervalLazyItemProvider(intervals.toList(), itemCount) + + private fun addInterval( + count: Int, + key: (index: Int) -> Any, + contentType: (index: Int) -> Any?, + layoutVersion: (index: Int) -> Any?, + itemContent: LazyIndexedContent, + ) { + require(count <= Int.MAX_VALUE - itemCount) { + "Lazy list item count exceeds ${Int.MAX_VALUE}." + } + intervals += + LazyItemInterval( + startIndex = itemCount, + count = count, + key = key, + contentType = contentType, + layoutVersion = layoutVersion, + itemContent = itemContent, + ) + itemCount += count + } +} + +private class LazyItemInterval( + val startIndex: Int, + val count: Int, + val key: (Int) -> Any, + val contentType: (Int) -> Any?, + val layoutVersion: (Int) -> Any?, + val itemContent: LazyIndexedContent, +) { + val endIndex: Int + get() = startIndex + count +} + +private class IntervalLazyItemProvider( + private val intervals: List, + override val itemCount: Int, +) : LazyItemProvider { + override fun key(index: Int): Any { + val interval = intervalAt(index) + return interval.key(index - interval.startIndex) + } + + override fun contentType(index: Int): Any? { + val interval = intervalAt(index) + return interval.contentType(index - interval.startIndex) + } + + override fun layoutVersion(index: Int): Any? { + val interval = intervalAt(index) + return interval.layoutVersion(index - interval.startIndex) + } + + @Composable + @FlareUiComposable + override fun Item(index: Int) { + val interval = intervalAt(index) + interval.itemContent(index - interval.startIndex) + } + + private fun intervalAt(index: Int): LazyItemInterval { + require(index in 0 until itemCount) { + "Lazy list index $index is outside 0 until $itemCount." + } + val intervalIndex = + intervals.binarySearch { interval -> + when { + interval.endIndex <= index -> -1 + interval.startIndex > index -> 1 + else -> 0 + } + } + check(intervalIndex >= 0) { + "Unable to resolve lazy list index $index." + } + return intervals[intervalIndex] + } +} diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListState.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListState.kt new file mode 100644 index 0000000000..9191374d47 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/LazyListState.kt @@ -0,0 +1,195 @@ +package dev.dimension.flare.ui.lazy + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.dimension.flare.ui.FlareUiComposable +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext + +@Immutable +public data class LazyListItemInfo( + public val key: Any, + public val index: Int, + public val offset: Float, + public val size: Float, +) + +@Immutable +public data class LazyListLayoutInfo( + public val totalItemsCount: Int = 0, + public val viewportStartOffset: Float = 0f, + public val viewportEndOffset: Float = 0f, + public val visibleItems: List = emptyList(), +) + +/** Observable viewport state and programmatic scrolling controller for one attached lazy list. */ +@Stable +public class LazyListState internal constructor() { + /** The most recent viewport and visible-item snapshot reported by the platform renderer. */ + public var layoutInfo: LazyListLayoutInfo by mutableStateOf(LazyListLayoutInfo()) + internal set + + /** Whether the native list is currently being dragged, flung, or programmatically animated. */ + public var isScrollInProgress: Boolean by mutableStateOf(false) + internal set + + private val attachment = MutableStateFlow(null) + private var activeRequest: LazyListScrollRequest? = null + + /** Immediately positions [index], with positive [scrollOffset] scrolling farther forward. */ + public suspend fun scrollToItem( + index: Int, + scrollOffset: Float = 0f, + ) { + requestScroll(index, scrollOffset, animated = false) + } + + /** Animates to [index], with positive [scrollOffset] scrolling farther forward. */ + public suspend fun animateScrollToItem( + index: Int, + scrollOffset: Float = 0f, + ) { + requestScroll(index, scrollOffset, animated = true) + } + + internal fun attach( + owner: Any, + itemCount: Int, + onScroll: (LazyListScrollRequest) -> Unit, + onScrollCancelled: (LazyListScrollRequest) -> Unit = {}, + uiDispatcher: CoroutineDispatcher, + ) { + val current = attachment.value + check(current == null || current.owner === owner) { + "A LazyListState cannot control more than one lazy collection at the same time." + } + attachment.value = LazyListStateAttachment(owner, itemCount, onScroll, onScrollCancelled, uiDispatcher) + layoutInfo = + layoutInfo.copy( + totalItemsCount = itemCount, + visibleItems = layoutInfo.visibleItems.filter { it.index < itemCount }, + ) + if (activeRequest?.index?.let { it >= itemCount } == true) { + cancelActiveRequest() + } + } + + internal fun detach(owner: Any) { + if (attachment.value?.owner !== owner) return + cancelActiveRequest() + attachment.value = null + isScrollInProgress = false + } + + internal fun updateLayoutInfo( + owner: Any, + value: LazyListLayoutInfo, + ) { + if (attachment.value?.owner === owner) { + layoutInfo = value + } + } + + internal fun updateScrollInProgress( + owner: Any, + value: Boolean, + ) { + if (attachment.value?.owner === owner) { + isScrollInProgress = value + } + } + + private suspend fun requestScroll( + index: Int, + scrollOffset: Float, + animated: Boolean, + ) { + require(index >= 0) { "Lazy list scroll index must be non-negative." } + require(scrollOffset.isFinite()) { "Lazy list scroll offset must be finite." } + val target = + checkNotNull(attachment.value) { + "LazyListState is not attached to a lazy collection." + } + withContext(target.uiDispatcher) { + check(attachment.value === target) { + "LazyListState is no longer attached to the requested lazy collection." + } + require(index < target.itemCount) { + "Lazy list scroll index $index is outside 0 until ${target.itemCount}." + } + cancelActiveRequest() + val request = LazyListScrollRequest(index, scrollOffset, animated) + activeRequest = request + try { + target.onScroll(request) + request.awaitCompletion() + } finally { + withContext(NonCancellable) { + if (activeRequest === request) { + if (request.isActive) { + cancelActiveRequest() + } else { + activeRequest = null + } + } + } + } + } + } + + private fun cancelActiveRequest() { + val request = activeRequest ?: return + activeRequest = null + try { + if (request.isActive) { + attachment.value?.onScrollCancelled?.invoke(request) + } + } finally { + request.cancel() + } + } +} + +/** Remembers a [LazyListState] scoped to the current Flare composition. */ +@Composable +@FlareUiComposable +public fun rememberLazyListState(): LazyListState = remember { LazyListState() } + +private class LazyListStateAttachment( + val owner: Any, + val itemCount: Int, + val onScroll: (LazyListScrollRequest) -> Unit, + val onScrollCancelled: (LazyListScrollRequest) -> Unit, + val uiDispatcher: CoroutineDispatcher, +) + +internal class LazyListScrollRequest( + val index: Int, + val scrollOffset: Float, + val animated: Boolean, +) { + private val completion = CompletableDeferred() + + val isActive: Boolean + get() = completion.isActive + + fun complete() { + completion.complete(Unit) + } + + fun cancel() { + completion.cancel() + } + + suspend fun awaitCompletion() { + completion.await() + } +} diff --git a/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/VariableExtentLayoutState.kt b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/VariableExtentLayoutState.kt new file mode 100644 index 0000000000..4f62d60351 --- /dev/null +++ b/flareUI/lazy-layout/src/commonMain/kotlin/dev/dimension/flare/ui/lazy/VariableExtentLayoutState.kt @@ -0,0 +1,339 @@ +package dev.dimension.flare.ui.lazy + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * Sparse main-axis geometry for a variable-size lazy list. + * + * Unknown items use an estimate. Real measurements are retained by stable key across data-set + * resets, while the sparse Fenwick tree keeps measured-size corrections and viewport endpoint + * lookup O(log itemCount). + */ +internal class VariableExtentLayoutState( + private val defaultEstimatedExtent: Double = DEFAULT_ESTIMATED_EXTENT, + private val measurementTolerance: Double = DEFAULT_MEASUREMENT_TOLERANCE, + private val maxCachedMeasurements: Int = DEFAULT_MEASUREMENT_CACHE_SIZE, +) { + private val extentDeltas = SparseFenwickTree() + private val assignedExtents = mutableMapOf() + private val exactMeasurements = LinkedHashMap() + private val estimators = mutableMapOf() + private var environment: Any? = UnsetEnvironment + + var itemCount: Int = 0 + private set + + var spacing: Double = 0.0 + private set + + val contentExtent: Double + get() { + if (itemCount == 0) return 0.0 + return itemCount * defaultEstimatedExtent + + (itemCount - 1) * spacing + + extentDeltas.prefixSum(itemCount) + } + + init { + require(defaultEstimatedExtent.isFinite() && defaultEstimatedExtent > 0.0) { + "The default lazy item estimate must be finite and positive." + } + require(measurementTolerance.isFinite() && measurementTolerance >= 0.0) { + "The lazy item measurement tolerance must be finite and non-negative." + } + require(maxCachedMeasurements > 0) { + "The lazy item measurement cache must retain at least one entry." + } + } + + /** Starts a new index space while retaining compatible stable-key measurements. */ + fun reset( + itemCount: Int, + spacing: Double, + environment: Any?, + ) { + require(itemCount >= 0) { "Lazy list item count must be non-negative." } + require(spacing.isFinite() && spacing >= 0.0) { + "Lazy list spacing must be finite and non-negative." + } + if (this.environment != environment) { + this.environment = environment + exactMeasurements.clear() + estimators.clear() + } + this.itemCount = itemCount + this.spacing = spacing + assignedExtents.clear() + extentDeltas.reset(itemCount) + } + + /** Applies an exact cached extent or a content-type estimate to one item. */ + fun resolve( + index: Int, + key: Any, + layoutVersion: Any?, + contentType: Any?, + ): ExtentChange? { + requireIndex(index) + val measurementKey = MeasurementKey(key, layoutVersion) + val assigned = assignedExtents[index] + if (assigned?.measurementKey == measurementKey) return null + val extent = + exactMeasurement(measurementKey) + ?: estimators[contentType.cacheKey()]?.median + ?: defaultEstimatedExtent + return assign(index, measurementKey, extent) + } + + /** Records a native measurement and returns the local main-axis correction, if any. */ + fun record( + index: Int, + key: Any, + layoutVersion: Any?, + contentType: Any?, + extent: Double, + ): ExtentChange? { + requireIndex(index) + if (!extent.isFinite() || extent <= 0.0) return null + val measurementKey = MeasurementKey(key, layoutVersion) + val previousMeasurement = exactMeasurements[measurementKey] + cacheMeasurement(measurementKey, extent) + val estimatorKey = contentType.cacheKey() + if (previousMeasurement == null || + abs(previousMeasurement - extent) > measurementTolerance || + estimatorKey !in estimators + ) { + estimators.getOrPut(estimatorKey) { RollingMedian() }.record(extent) + } + return assign(index, measurementKey, extent) + } + + fun itemExtent(index: Int): Double { + requireIndex(index) + return assignedExtents[index]?.extent ?: defaultEstimatedExtent + } + + fun hasExactMeasurement( + key: Any, + layoutVersion: Any?, + ): Boolean = MeasurementKey(key, layoutVersion) in exactMeasurements + + fun itemStart(index: Int): Double { + requireIndex(index) + return index * (defaultEstimatedExtent + spacing) + extentDeltas.prefixSum(index) + } + + fun visibleRange( + viewportStart: Double, + viewportEnd: Double, + overscan: Double = 0.0, + ): IntRange = calculateVisibleRange(viewportStart, viewportEnd, overscan) {} + + /** Test-only complexity seam; production calls inline an empty node-visit callback. */ + internal fun visibleRangeWithSearchNodeVisitsForTesting( + viewportStart: Double, + viewportEnd: Double, + overscan: Double = 0.0, + ): Pair { + var nodeVisits = 0 + val range = + calculateVisibleRange(viewportStart, viewportEnd, overscan) { + nodeVisits += 1 + } + return range to nodeVisits + } + + private inline fun calculateVisibleRange( + viewportStart: Double, + viewportEnd: Double, + overscan: Double, + onSearchNodeVisited: () -> Unit, + ): IntRange { + if (itemCount == 0) return IntRange.EMPTY + require(overscan.isFinite() && overscan >= 0.0) { + "Lazy list overscan must be finite and non-negative." + } + val start = max(0.0, min(viewportStart, viewportEnd) - overscan) + val end = max(viewportStart, viewportEnd) + overscan + val first = indexAtOffset(start, onSearchNodeVisited) + val last = indexAtOffset(end, onSearchNodeVisited) + return first..last + } + + private inline fun indexAtOffset( + offset: Double, + onSearchNodeVisited: () -> Unit, + ): Int = + extentDeltas.indexAtOrBefore( + offset = offset, + estimatedStride = defaultEstimatedExtent + spacing, + onNodeVisited = onSearchNodeVisited, + ) + + private fun assign( + index: Int, + measurementKey: MeasurementKey, + extent: Double, + ): ExtentChange? { + val previous = assignedExtents[index]?.extent ?: defaultEstimatedExtent + val delta = extent - previous + if (abs(delta) <= measurementTolerance) { + assignedExtents[index] = AssignedExtent(measurementKey, previous) + return null + } + assignedExtents[index] = AssignedExtent(measurementKey, extent) + extentDeltas.add(index, delta) + return ExtentChange(index, previous, extent) + } + + private fun exactMeasurement(key: MeasurementKey): Double? { + val value = exactMeasurements.remove(key) ?: return null + exactMeasurements[key] = value + return value + } + + private fun cacheMeasurement( + key: MeasurementKey, + extent: Double, + ) { + exactMeasurements.remove(key) + exactMeasurements[key] = extent + while (exactMeasurements.size > maxCachedMeasurements) { + val oldest = exactMeasurements.keys.first() + exactMeasurements.remove(oldest) + } + } + + private fun requireIndex(index: Int) { + require(index in 0 until itemCount) { + "Lazy list index $index is outside 0 until $itemCount." + } + } +} + +internal data class ExtentChange( + val index: Int, + val previous: Double, + val current: Double, +) { + val delta: Double + get() = current - previous +} + +private data class MeasurementKey( + val key: Any, + val layoutVersion: Any?, +) + +private data class AssignedExtent( + val measurementKey: MeasurementKey, + val extent: Double, +) + +private class SparseFenwickTree { + private val nodes = mutableMapOf() + private var size: Int = 0 + + fun reset(size: Int) { + this.size = size + nodes.clear() + } + + fun add( + index: Int, + delta: Double, + ) { + var node = index + 1 + while (node <= size) { + val value = (nodes[node] ?: 0.0) + delta + if (abs(value) <= SPARSE_ZERO_TOLERANCE) { + nodes.remove(node) + } else { + nodes[node] = value + } + val increment = node and -node + if (node > size - increment) break + node += increment + } + } + + fun prefixSum(endExclusive: Int): Double { + var node = endExclusive + var result = 0.0 + while (node > 0) { + result += nodes[node] ?: 0.0 + node -= node and -node + } + return result + } + + /** Returns the greatest item start at or before [offset] in one Fenwick descent. */ + inline fun indexAtOrBefore( + offset: Double, + estimatedStride: Double, + onNodeVisited: () -> Unit, + ): Int { + if (size == 0) return 0 + + var step = 1 + while (step <= size / 2) { + step = step shl 1 + } + + var prefixLength = 0 + var prefixDelta = 0.0 + while (step > 0) { + if (step <= size - prefixLength) { + val candidate = prefixLength + step + onNodeVisited() + val candidateDelta = prefixDelta + (nodes[candidate] ?: 0.0) + val candidateOffset = candidate * estimatedStride + candidateDelta + if (candidateOffset <= offset) { + prefixLength = candidate + prefixDelta = candidateDelta + } + } + step = step ushr 1 + } + return min(prefixLength, size - 1) + } +} + +private class RollingMedian( + private val capacity: Int = DEFAULT_ESTIMATOR_SAMPLE_SIZE, +) { + private val samples = DoubleArray(capacity) + private var nextIndex: Int = 0 + private var sampleCount: Int = 0 + + val median: Double + get() { + val sorted = samples.copyOf(sampleCount).apply(DoubleArray::sort) + val middle = sampleCount / 2 + return if (sampleCount % 2 == 1) { + sorted[middle] + } else { + (sorted[middle - 1] + sorted[middle]) / 2.0 + } + } + + fun record(value: Double) { + samples[nextIndex] = value + nextIndex = (nextIndex + 1) % capacity + sampleCount = min(sampleCount + 1, capacity) + } +} + +private fun Any?.cacheKey(): Any = this ?: NullContentType + +private data object NullContentType + +private data object UnsetEnvironment + +private const val DEFAULT_ESTIMATED_EXTENT = 48.0 +private const val DEFAULT_MEASUREMENT_TOLERANCE = 0.5 +private const val DEFAULT_MEASUREMENT_CACHE_SIZE = 4_096 +private const val DEFAULT_ESTIMATOR_SAMPLE_SIZE = 15 +private const val SPARSE_ZERO_TOLERANCE = 0.000_001 diff --git a/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/AdaptiveLazyScrollPolicyTest.kt b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/AdaptiveLazyScrollPolicyTest.kt new file mode 100644 index 0000000000..de59ef5c31 --- /dev/null +++ b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/AdaptiveLazyScrollPolicyTest.kt @@ -0,0 +1,13 @@ +package dev.dimension.flare.ui.lazy + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AdaptiveLazyScrollPolicyTest { + @Test + fun settlingStopsInsideTheHalfPointTolerance() { + assertFalse(needsAdaptiveLazyScrollCorrection(current = 100.0, target = 100.5)) + assertTrue(needsAdaptiveLazyScrollCorrection(current = 100.0, target = 100.51)) + } +} diff --git a/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyItemKeyLookupTest.kt b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyItemKeyLookupTest.kt new file mode 100644 index 0000000000..6c615710f2 --- /dev/null +++ b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyItemKeyLookupTest.kt @@ -0,0 +1,72 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LazyItemKeyLookupTest { + @Test + fun countDeltaFindsALargePrependWithoutScanningTheProvider() { + var keyLookups = 0 + val provider = + provider(count = 10_100) { index -> + keyLookups += 1 + index - 100 + } + + val index = + provider.findIndexByKey( + key = 9_000, + expectedIndex = 9_000, + previousItemCount = 10_000, + ) + + assertEquals(9_100, index) + assertTrue(keyLookups <= 2, "Large prepend resolved $keyLookups keys.") + } + + @Test + fun arbitraryFarReorderKeepsTheStableKeyThroughTheCorrectnessFallback() { + val keys = (1 until 200).toList() + 0 + val provider = provider(keys.size, keys::get) + + assertEquals( + 199, + provider.findIndexByKey( + key = 0, + expectedIndex = 0, + previousItemCount = 200, + ), + ) + } + + @Test + fun localSearchDoesNotOverflowNearTheMaximumItemCount() { + val expectedIndex = Int.MAX_VALUE - 2 + val targetIndex = expectedIndex - 4 + val anchor = "anchor" + val provider = + provider(Int.MAX_VALUE) { index -> + if (index == targetIndex) anchor else index + } + + assertEquals( + targetIndex, + provider.findIndexByKey( + key = anchor, + expectedIndex = expectedIndex, + previousItemCount = Int.MAX_VALUE, + ), + ) + } + + private fun provider( + count: Int, + key: (Int) -> Any, + ): LazyItemProvider = + IntervalLazyListScope() + .apply { items(count = count, key = key) {} } + .build() +} diff --git a/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyItemReusePoolTest.kt b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyItemReusePoolTest.kt new file mode 100644 index 0000000000..116119a133 --- /dev/null +++ b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyItemReusePoolTest.kt @@ -0,0 +1,78 @@ +package dev.dimension.flare.ui.lazy + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LazyItemReusePoolTest { + @Test + fun evictsOldestBindingsAcrossContentTypesAtTheGlobalLimit() { + val evicted = mutableListOf() + val pool = LazyItemReusePool(maxSize = 2, onEvicted = evicted::add) + + pool.put(contentType = "type-a", key = "key-a", value = "value-a") + pool.put(contentType = "type-b", key = "key-b", value = "value-b") + pool.put(contentType = "type-c", key = "key-c", value = "value-c") + + assertEquals(2, pool.size) + assertEquals(listOf("value-a"), evicted) + assertEquals(null, pool.take(contentType = "type-a", key = "key-a")) + } + + @Test + fun prefersTheStableKeyThenUsesACompatibleLifoFallback() { + val evicted = mutableListOf() + val pool = LazyItemReusePool(maxSize = 4, onEvicted = evicted::add) + + pool.put(contentType = "card", key = "one", value = "first") + pool.put(contentType = "card", key = "two", value = "second") + pool.put(contentType = "avatar", key = "three", value = "third") + + assertEquals("first", pool.take(contentType = "card", key = "one")) + assertEquals("second", pool.take(contentType = "card", key = "missing")) + assertEquals(null, pool.take(contentType = "card", key = "missing-again")) + assertEquals(emptyList(), evicted) + } + + @Test + fun incompatibleExactKeyIsEvictedBeforeACompatibleFallbackIsReused() { + val evicted = mutableListOf() + val pool = LazyItemReusePool(maxSize = 4, onEvicted = evicted::add) + pool.put(contentType = "card", key = "fallback", value = "card-root") + pool.put(contentType = "avatar", key = "stable", value = "avatar-root") + + assertEquals("card-root", pool.take(contentType = "card", key = "stable")) + assertEquals(listOf("avatar-root"), evicted) + assertEquals(0, pool.size) + } + + @Test + fun clearEvictsEveryRetainedValue() { + val evicted = mutableListOf() + val pool = LazyItemReusePool(maxSize = 3, onEvicted = evicted::add) + pool.put(contentType = "row", key = 1, value = "one") + pool.put(contentType = "row", key = 2, value = "two") + + pool.clear() + + assertEquals(0, pool.size) + assertEquals(listOf("one", "two"), evicted) + } + + @Test + fun clearAttemptsEveryEvictionBeforeRethrowing() { + val evicted = mutableListOf() + val pool = + LazyItemReusePool(maxSize = 3) { value -> + evicted += value + if (value == "one") error("dispose failed") + } + pool.put(contentType = "row", key = 1, value = "one") + pool.put(contentType = "row", key = 2, value = "two") + + assertFailsWith { pool.clear() } + + assertEquals(0, pool.size) + assertEquals(listOf("one", "two"), evicted) + } +} diff --git a/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyListChangeSetTest.kt b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyListChangeSetTest.kt new file mode 100644 index 0000000000..ba407df293 --- /dev/null +++ b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/LazyListChangeSetTest.kt @@ -0,0 +1,122 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +public class LazyListChangeSetTest { + @Test + public fun resolvesManyIntervalsAndRejectsTotalCountOverflow() { + val scope = IntervalLazyListScope() + repeat(1_000) { interval -> + scope.item(key = "item-$interval") {} + } + + val provider = scope.build() + assertEquals("item-0", provider.key(0)) + assertEquals("item-511", provider.key(511)) + assertEquals("item-999", provider.key(999)) + + assertFailsWith { + IntervalLazyListScope().apply { + items(count = Int.MAX_VALUE, key = { it }) {} + item(key = "overflow") {} + } + } + } + + @Test + public fun reportsInsertRemoveMoveAndContentTypeChangeInNativeIndexSpaces() { + val previous = provider("a" to "row", "b" to "row", "c" to "row") + val current = provider("b" to "row", "a" to "featured", "d" to "row") + + val changes = calculateLazyListChangeSet(previous, current) + + assertEquals(listOf(2), changes.removedIndices) + assertEquals(listOf(2), changes.insertedIndices) + assertEquals( + listOf(LazyListMove(fromIndex = 1, toIndex = 0)), + changes.moves, + ) + assertEquals(listOf(1), changes.reloadedIndices) + } + + @Test + public fun layoutVersionChangeReloadsAnOtherwiseStableItem() { + val previous = versionedProvider("post" to 1) + val current = versionedProvider("post" to 2) + + val changes = calculateLazyListChangeSet(previous, current) + + assertEquals(emptyList(), changes.removedIndices) + assertEquals(emptyList(), changes.insertedIndices) + assertEquals(emptyList(), changes.moves) + assertEquals(listOf(0), changes.reloadedIndices) + } + + @Test + public fun duplicateKeysInAnUpdatedSnapshotFailDeterministically() { + val previous = provider("a" to null) + val current = provider("duplicate" to null, "duplicate" to null) + + assertFailsWith { + calculateLazyListChangeSet(previous, current) + } + } + + @Test + public fun prependDiffComparesKeysInLinearTime() { + val comparisons = ComparisonCounter() + val previousEntries = (0 until 2_000).map { CountingKey(it, comparisons) to null } + val currentEntries = + listOf(CountingKey(-1, comparisons) to null) + + (0 until 2_000).map { CountingKey(it, comparisons) to null } + + val changes = calculateLazyListChangeSet(provider(previousEntries), provider(currentEntries)) + + assertEquals(listOf(0), changes.insertedIndices) + assertEquals(emptyList(), changes.moves) + assertTrue(comparisons.value < 50_000, "Prepend diff used ${comparisons.value} equality checks.") + } + + private fun provider(vararg entries: Pair): LazyItemProvider = provider(entries.toList()) + + private fun provider(entries: List>): LazyItemProvider = + IntervalLazyListScope() + .apply { + items( + count = entries.size, + key = { index -> entries[index].first }, + contentType = { index -> entries[index].second }, + ) {} + }.build() + + private fun versionedProvider(vararg entries: Pair): LazyItemProvider = + IntervalLazyListScope() + .apply { + items( + count = entries.size, + key = { index -> entries[index].first }, + layoutVersion = { index -> entries[index].second }, + ) {} + }.build() + + private class ComparisonCounter { + var value: Int = 0 + } + + private class CountingKey( + private val value: Int, + private val comparisons: ComparisonCounter, + ) { + override fun equals(other: Any?): Boolean { + comparisons.value += 1 + return other is CountingKey && value == other.value + } + + override fun hashCode(): Int = value + } +} diff --git a/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/VariableExtentLayoutStateTest.kt b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/VariableExtentLayoutStateTest.kt new file mode 100644 index 0000000000..9e696f4dd5 --- /dev/null +++ b/flareUI/lazy-layout/src/commonTest/kotlin/dev/dimension/flare/ui/lazy/VariableExtentLayoutStateTest.kt @@ -0,0 +1,251 @@ +package dev.dimension.flare.ui.lazy + +import kotlin.math.max +import kotlin.math.min +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +public class VariableExtentLayoutStateTest { + @Test + public fun measuredExtentOnlyMovesFollowingItems() { + val state = VariableExtentLayoutState(defaultEstimatedExtent = 48.0) + state.reset(itemCount = 4, spacing = 6.0, environment = "portrait") + + assertEquals(0.0, state.itemStart(0)) + assertEquals(54.0, state.itemStart(1)) + assertEquals(108.0, state.itemStart(2)) + assertEquals(210.0, state.contentExtent) + + val change = + state.record( + index = 1, + key = "second", + layoutVersion = Unit, + contentType = "text", + extent = 80.0, + ) + + assertEquals(32.0, change?.delta) + assertEquals(0.0, state.itemStart(0)) + assertEquals(54.0, state.itemStart(1)) + assertEquals(140.0, state.itemStart(2)) + assertEquals(242.0, state.contentExtent) + assertEquals(1..1, state.visibleRange(viewportStart = 55.0, viewportEnd = 139.0)) + } + + @Test + public fun exactMeasurementsFollowStableKeysAcrossResetAndReorder() { + val state = VariableExtentLayoutState(defaultEstimatedExtent = 48.0) + state.reset(itemCount = 3, spacing = 0.0, environment = "portrait") + state.record( + index = 1, + key = "stable-b", + layoutVersion = Unit, + contentType = "text", + extent = 92.0, + ) + + state.reset(itemCount = 4, spacing = 0.0, environment = "portrait") + val restored = + state.resolve( + index = 3, + key = "stable-b", + layoutVersion = Unit, + contentType = "text", + ) + + assertEquals(44.0, restored?.delta) + assertEquals(92.0, state.itemExtent(3)) + } + + @Test + public fun contentTypeMedianPredictsUnseenItemsWithoutBecomingFixedSizing() { + val state = VariableExtentLayoutState(defaultEstimatedExtent = 48.0) + state.reset(itemCount = 4, spacing = 0.0, environment = "portrait") + state.record(0, "image-1", Unit, "image", 120.0) + state.record(1, "image-2", Unit, "image", 180.0) + state.record(2, "image-3", Unit, "image", 160.0) + repeat(20) { + state.record(0, "image-1", Unit, "image", 120.0) + } + + state.reset(itemCount = 5, spacing = 0.0, environment = "portrait") + state.resolve(4, "image-4", Unit, "image") + + assertEquals(160.0, state.itemExtent(4)) + state.record(4, "image-4", Unit, "image", 210.0) + assertEquals(210.0, state.itemExtent(4)) + } + + @Test + public fun layoutVersionAndEnvironmentPreventStaleExactMeasurements() { + val state = VariableExtentLayoutState(defaultEstimatedExtent = 48.0) + state.reset(itemCount = 1, spacing = 0.0, environment = "width-320") + state.record(0, "post", layoutVersion = 1, contentType = "expanded", extent = 140.0) + + state.reset(itemCount = 1, spacing = 0.0, environment = "width-320") + state.resolve(0, "post", layoutVersion = 2, contentType = "collapsed") + assertEquals(48.0, state.itemExtent(0)) + + state.record(0, "post", layoutVersion = 2, contentType = "collapsed", extent = 72.0) + state.reset(itemCount = 1, spacing = 0.0, environment = "width-480") + state.resolve(0, "post", layoutVersion = 2, contentType = "collapsed") + assertEquals(48.0, state.itemExtent(0)) + } + + @Test + public fun repeatedSubToleranceChangesAreComparedWithAppliedGeometry() { + val state = + VariableExtentLayoutState( + defaultEstimatedExtent = 48.0, + measurementTolerance = 0.5, + ) + state.reset(itemCount = 2, spacing = 0.0, environment = "portrait") + + state.record(0, "dynamic", Unit, "post", 48.4) + assertEquals(48.0, state.itemExtent(0)) + assertEquals(48.0, state.itemStart(1)) + + state.record(0, "dynamic", Unit, "post", 48.8) + assertEquals(48.8, state.itemExtent(0)) + assertEquals(48.8, state.itemStart(1)) + } + + @Test + public fun visibleRangeMatchesLinearOracleForSparseHeterogeneousExtents() { + val random = Random(0xF1A2E) + + repeat(40) { scenario -> + val itemCount = random.nextInt(from = 1, until = 200) + val spacing = random.nextDouble(from = 0.0, until = 24.0) + val extents = MutableList(itemCount) { 48.0 } + val measuredIndices = + extents.indices + .shuffled(random) + .take(max(1, itemCount / 4)) + measuredIndices.forEach { index -> + extents[index] = random.nextDouble(from = 1.0, until = 240.0) + } + val starts = extents.itemStarts(spacing) + val state = + VariableExtentLayoutState( + defaultEstimatedExtent = 48.0, + measurementTolerance = 0.0, + ) + state.reset(itemCount, spacing, environment = "scenario-$scenario") + measuredIndices.shuffled(random).forEach { index -> + state.record( + index = index, + key = "item-$index", + layoutVersion = Unit, + contentType = index % 5, + extent = extents[index], + ) + } + + repeat(50) { + val viewportStart = + random.nextDouble(from = -100.0, until = state.contentExtent + 100.0) + val viewportEnd = + random.nextDouble(from = -100.0, until = state.contentExtent + 100.0) + val overscan = random.nextDouble(from = 0.0, until = 100.0) + val queryStart = max(0.0, min(viewportStart, viewportEnd) - overscan) + val queryEnd = max(viewportStart, viewportEnd) + overscan + + assertEquals( + starts.indexAtOrBefore(queryStart)..starts.indexAtOrBefore(queryEnd), + state.visibleRange(viewportStart, viewportEnd, overscan), + "scenario=$scenario, viewport=$viewportStart..$viewportEnd, overscan=$overscan", + ) + } + } + } + + @Test + public fun visibleRangePreservesSpacingBoundariesAndEmptyState() { + val empty = VariableExtentLayoutState(defaultEstimatedExtent = 48.0) + empty.reset(itemCount = 0, spacing = 7.0, environment = Unit) + assertEquals(IntRange.EMPTY, empty.visibleRange(0.0, 1_000.0)) + + val state = + VariableExtentLayoutState( + defaultEstimatedExtent = 48.0, + measurementTolerance = 0.0, + ) + state.reset(itemCount = 4, spacing = 7.0, environment = Unit) + listOf(10.0, 20.0, 5.0, 100.0).forEachIndexed { index, extent -> + state.record(index, "item-$index", Unit, null, extent) + } + + assertEquals(0..0, state.visibleRange(10.0, 16.999)) + assertEquals(0..1, state.visibleRange(10.0, 17.0)) + assertEquals(1..2, state.visibleRange(50.0, 43.999)) + assertEquals(0..3, state.visibleRange(56.0, 56.0, overscan = 1_000.0)) + } + + @Test + public fun sparseGeometrySupportsMaximumItemCountWithoutIndexOverflow() { + val itemCount = Int.MAX_VALUE + val lastIndex = itemCount - 1 + val state = + VariableExtentLayoutState( + defaultEstimatedExtent = 48.0, + measurementTolerance = 0.0, + ) + state.reset(itemCount, spacing = 1.0, environment = Unit) + state.record(0, "first", Unit, null, 96.0) + state.record(lastIndex, "last", Unit, null, 72.0) + + val lastItemStart = lastIndex * 49.0 + 48.0 + assertEquals(lastIndex..lastIndex, state.visibleRange(lastItemStart, Double.MAX_VALUE)) + assertEquals(lastItemStart, state.itemStart(lastIndex)) + } + + @Test + public fun visibleRangeUsesOneFenwickDescentPerEndpoint() { + val itemCount = 1_000_000 + val state = + VariableExtentLayoutState( + defaultEstimatedExtent = 48.0, + measurementTolerance = 0.0, + ) + state.reset(itemCount, spacing = 3.0, environment = Unit) + listOf(0, 7, 1_024, 65_535, 500_000, itemCount - 1).forEach { index -> + state.record(index, "item-$index", Unit, null, 24.0 + index % 97) + } + + val (range, nodeVisits) = + state.visibleRangeWithSearchNodeVisitsForTesting( + viewportStart = state.contentExtent * 0.45, + viewportEnd = state.contentExtent * 0.55, + overscan = 500.0, + ) + + assertEquals( + state.visibleRange(state.contentExtent * 0.45, state.contentExtent * 0.55, 500.0), + range, + ) + assertTrue(nodeVisits <= 40, "Expected O(log N) node visits, but observed $nodeVisits") + } +} + +private fun List.itemStarts(spacing: Double): List { + var start = 0.0 + return map { extent -> + start.also { start += extent + spacing } + } +} + +private fun List.indexAtOrBefore(offset: Double): Int { + var result = 0 + forEachIndexed { index, start -> + if (start <= offset) { + result = index + } else { + return result + } + } + return result +} diff --git a/flareUI/lazy-layout/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitAdaptiveLazyCollectionWidget.kt b/flareUI/lazy-layout/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitAdaptiveLazyCollectionWidget.kt new file mode 100644 index 0000000000..5577501c79 --- /dev/null +++ b/flareUI/lazy-layout/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitAdaptiveLazyCollectionWidget.kt @@ -0,0 +1,833 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.lazy.InvalidatingLazyItemChildren +import dev.dimension.flare.ui.lazy.LazyCollectionCoordinator +import dev.dimension.flare.ui.lazy.LazyCollectionModel +import dev.dimension.flare.ui.lazy.LazyCollectionWidget +import dev.dimension.flare.ui.lazy.LazyCrossAxisAlignment +import dev.dimension.flare.ui.lazy.LazyItemHost +import dev.dimension.flare.ui.lazy.LazyItemReusePool +import dev.dimension.flare.ui.lazy.LazyListItemInfo +import dev.dimension.flare.ui.lazy.LazyListLayoutInfo +import dev.dimension.flare.ui.lazy.LazyListOrientation +import dev.dimension.flare.ui.lazy.LazyListScrollRequest +import dev.dimension.flare.ui.lazy.LazyRealizedItemUpdate +import dev.dimension.flare.ui.lazy.VariableExtentLayoutState +import dev.dimension.flare.ui.lazy.findIndexByKey +import dev.dimension.flare.ui.lazy.needsAdaptiveLazyScrollCorrection +import kotlinx.cinterop.useContents +import kotlinx.coroutines.Dispatchers +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSizeMake +import platform.UIKit.UILayoutConstraintAxisHorizontal +import platform.UIKit.UILayoutConstraintAxisVertical +import platform.UIKit.UILayoutFittingCompressedSize +import platform.UIKit.UILayoutPriorityFittingSizeLevel +import platform.UIKit.UILayoutPriorityRequired +import platform.UIKit.UIScrollView +import platform.UIKit.UIScrollViewContentInsetAdjustmentBehavior.UIScrollViewContentInsetAdjustmentNever +import platform.UIKit.UIScrollViewDelegateProtocol +import platform.UIKit.UIStackView +import platform.UIKit.UIStackViewAlignmentBottom +import platform.UIKit.UIStackViewAlignmentCenter +import platform.UIKit.UIStackViewAlignmentFill +import platform.UIKit.UIStackViewAlignmentLeading +import platform.UIKit.UIStackViewAlignmentTop +import platform.UIKit.UIStackViewAlignmentTrailing +import platform.darwin.NSObject +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.round + +/** Variable-extent UIKit renderer which keeps native scrolling but owns linear virtualization. */ +internal class UIKitAdaptiveLazyCollectionWidget : + AbstractUIKitWidget(UIKitAdaptiveLazyScrollView()), + LazyCollectionWidget { + private val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = ::applyModel, + onScroll = ::performScroll, + onScrollCancelled = ::cancelScroll, + uiDispatcher = Dispatchers.Main.immediate, + ) + private val bridge = UIKitAdaptiveLazyBridge(view, coordinator) + private var pendingAnchor: UIKitAdaptiveAnchor? = null + + init { + view.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever + view.delegate = bridge + view.onLayout = bridge::scheduleLayout + } + + override fun setModel(model: LazyCollectionModel) { + pendingAnchor = coordinator.model?.let(bridge::captureAnchor) + try { + coordinator.setModel(model) + } finally { + pendingAnchor = null + } + } + + override fun dispose() { + view.onLayout = null + view.delegate = null + var failure: Throwable? = null + try { + bridge.dispose() + } catch (error: Throwable) { + failure = error + } + try { + coordinator.dispose() + } catch (error: Throwable) { + if (failure == null) failure = error + } + failure?.let { throw it } + } + + private fun applyModel( + previous: LazyCollectionModel?, + current: LazyCollectionModel, + ): LazyRealizedItemUpdate { + val vertical = current.orientation == LazyListOrientation.Vertical + view.alwaysBounceVertical = vertical + view.alwaysBounceHorizontal = !vertical + view.showsVerticalScrollIndicator = vertical + view.showsHorizontalScrollIndicator = !vertical + bridge.setModel(current, pendingAnchor) + return LazyRealizedItemUpdate.RendererManaged + } + + private fun performScroll(request: LazyListScrollRequest) { + bridge.performScroll(request) + } + + private fun cancelScroll(request: LazyListScrollRequest) { + bridge.cancelScroll(request) + } +} + +private class UIKitAdaptiveLazyBridge( + private val scrollView: UIKitAdaptiveLazyScrollView, + private val coordinator: LazyCollectionCoordinator, +) : NSObject(), + UIScrollViewDelegateProtocol { + private val geometry = VariableExtentLayoutState() + private val realized = mutableMapOf() + private val allBindings = mutableSetOf() + private val pooled = + LazyItemReusePool(MIN_RETAINED_BINDINGS) { binding -> + allBindings.remove(binding) + binding.dispose() + } + private var environment: UIKitExtentEnvironment? = null + private var layoutScheduled: Boolean = false + private var layingOut: Boolean = false + private var disposed: Boolean = false + private var pendingAnchor: UIKitAdaptiveAnchor? = null + private var pendingScroll: LazyListScrollRequest? = null + private var modelResetPending: Boolean = false + private var programmaticScrollInProgress: Boolean = false + private var physicalScrollInProgress: Boolean = false + + override fun scrollViewDidScroll(scrollView: UIScrollView) { + layoutVisibleItems() + } + + override fun scrollViewWillBeginDragging(scrollView: UIScrollView) { + physicalScrollInProgress = true + cancelPendingScroll() + coordinator.reportScrollInProgress(true) + } + + override fun scrollViewDidEndDecelerating(scrollView: UIScrollView) { + finishPhysicalScroll() + } + + override fun scrollViewDidEndDragging( + scrollView: UIScrollView, + willDecelerate: Boolean, + ) { + if (!willDecelerate) finishPhysicalScroll() + } + + override fun scrollViewDidEndScrollingAnimation(scrollView: UIScrollView) { + val request = pendingScroll ?: return + val settled = settleScrollRequest(request) + if (pendingScroll === request) pendingScroll = null + if (settled) { + request.complete() + } else { + request.cancel() + } + coordinator.reportScrollInProgress(false) + } + + fun setModel( + model: LazyCollectionModel, + anchor: UIKitAdaptiveAnchor?, + ) { + // A second model can arrive before the scheduled layout has rebuilt any bindings. Keep the + // last real viewport anchor instead of replacing it with the resulting null capture. + pendingAnchor = + anchor + ?.let { candidate -> + if (isPhysicalScrollInProgress()) { + candidate.copy(preserveViewportDelta = true) + } else { + candidate + } + } ?: pendingAnchor + modelResetPending = true + scheduleLayout() + } + + fun captureAnchor(model: LazyCollectionModel): UIKitAdaptiveAnchor? { + val viewportStart = scrollView.mainAxisOffset(model.orientation) + var binding: UIKitAdaptiveItemBinding? = null + var bindingStart = Double.POSITIVE_INFINITY + realized.values.forEach { candidate -> + if (candidate.index !in 0 until model.itemProvider.itemCount) return@forEach + val start = geometry.itemStart(candidate.index) + if (start + geometry.itemExtent(candidate.index) > viewportStart && start < bindingStart) { + binding = candidate + bindingStart = start + } + } + val anchorBinding = binding ?: return null + val key = anchorBinding.key ?: model.itemProvider.key(anchorBinding.index) + return UIKitAdaptiveAnchor( + key = key, + index = anchorBinding.index, + itemCount = model.itemProvider.itemCount, + offset = bindingStart - viewportStart, + viewportOffset = viewportStart, + orientation = model.orientation, + ) + } + + fun scheduleLayout() { + if (disposed || layoutScheduled || layingOut) return + layoutScheduled = true + dispatch_async(dispatch_get_main_queue()) { + layoutScheduled = false + if (!disposed) layoutVisibleItems() + } + } + + fun performScroll(request: LazyListScrollRequest) { + val model = + coordinator.model ?: run { + request.cancel() + return + } + if (modelResetPending) layoutVisibleItems() + if (request.index !in 0 until model.itemProvider.itemCount) { + request.cancel() + return + } + resolveExtent(model, request.index) + val target = geometry.itemStart(request.index) + request.scrollOffset + if (request.animated) { + cancelPendingScroll(stopAnimation = true) + pendingScroll = request + coordinator.reportScrollInProgress(true) + scrollView.setContentOffset(model.mainAxisPoint(target), animated = true) + } else { + programmaticScrollInProgress = true + try { + scrollView.setContentOffset(model.mainAxisPoint(target), animated = false) + if (!settleScrollRequest(request)) { + request.cancel() + return + } + } finally { + programmaticScrollInProgress = false + } + request.complete() + } + } + + fun cancelScroll(request: LazyListScrollRequest) { + if (pendingScroll !== request) return + pendingScroll = null + scrollView.setContentOffset(scrollView.contentOffset, animated = false) + coordinator.reportScrollInProgress(false) + } + + fun dispose() { + disposed = true + pendingScroll?.cancel() + pendingScroll = null + var failure: Throwable? = null + try { + pooled.clear() + } catch (error: Throwable) { + failure = error + } + val remainingBindings = allBindings.toList() + allBindings.clear() + realized.clear() + remainingBindings.forEach { binding -> + try { + binding.dispose() + } catch (error: Throwable) { + if (failure == null) failure = error + } + } + failure?.let { throw it } + } + + private fun layoutVisibleItems() { + val model = coordinator.model ?: return + if (disposed || layingOut) return + layingOut = true + try { + val environmentAnchor = + if (modelResetPending) { + modelResetPending = false + if (canApplyModelInPlace(model)) { + resetGeometry(model) + rebindRealized(model) + } else { + recycleAll() + resetGeometry(model) + } + null + } else { + ensureEnvironment(model) + } + val deferOffsetCorrection = isPhysicalScrollInProgress() + if (deferOffsetCorrection) { + if (pendingAnchor == null) { + pendingAnchor = environmentAnchor?.copy(preserveViewportDelta = true) + } else if (pendingAnchor?.preserveViewportDelta == false) { + pendingAnchor = pendingAnchor?.copy(preserveViewportDelta = true) + } + } + val modelAnchor = if (deferOffsetCorrection) null else pendingAnchor ?: environmentAnchor + val restoredModelAnchor = + modelAnchor?.let { anchor -> + val restored = restoreAnchor(model, anchor) + pendingAnchor = null + restored + } + val measurementAnchor = + restoredModelAnchor ?: if (!deferOffsetCorrection && pendingScroll == null && !programmaticScrollInProgress) { + captureAnchor(model) + } else { + null + } + var geometryChanged = false + var pass = 0 + while (pass < MAX_LAYOUT_PASSES) { + val viewportStart = scrollView.mainAxisOffset(model.orientation) + val viewportSize = scrollView.mainAxisViewport(model.orientation) + val desired = + geometry.visibleRange( + viewportStart = viewportStart, + viewportEnd = viewportStart + viewportSize, + overscan = viewportSize * OVERSCAN_VIEWPORTS, + ) + desired.forEach { index -> + geometryChanged = resolveExtent(model, index) || geometryChanged + } + reconcileBindings(model, desired) + placeRealized(model) + val measured = measurePendingBindings(model) + geometryChanged = measured || geometryChanged + updateContentSize(model) + if (!measured) break + pass += 1 + } + placeRealized(model) + updateContentSize(model) + if (geometryChanged && measurementAnchor != null) { + restoreAnchor(model, measurementAnchor) + placeRealized(model) + } + reportLayoutInfo(model) + } finally { + layingOut = false + } + } + + private fun ensureEnvironment(model: LazyCollectionModel): UIKitAdaptiveAnchor? { + val next = UIKitExtentEnvironment(model.orientation, round(scrollView.crossAxisExtent(model.orientation) * 2.0) / 2.0) + if (environment == next) return null + val anchor = captureAnchor(model) + environment = next + recycleAll() + geometry.reset(model.itemProvider.itemCount, model.spacing.toDouble(), next) + return anchor + } + + private fun resetGeometry(model: LazyCollectionModel) { + val next = UIKitExtentEnvironment(model.orientation, round(scrollView.crossAxisExtent(model.orientation) * 2.0) / 2.0) + environment = next + geometry.reset(model.itemProvider.itemCount, model.spacing.toDouble(), next) + } + + private fun canApplyModelInPlace(model: LazyCollectionModel): Boolean { + val next = UIKitExtentEnvironment(model.orientation, round(scrollView.crossAxisExtent(model.orientation) * 2.0) / 2.0) + if (environment != next || geometry.itemCount != model.itemProvider.itemCount) return false + if (geometry.spacing != model.spacing.toDouble()) return false + val provider = model.itemProvider + return realized.all { (index, binding) -> + index in 0 until provider.itemCount && provider.key(index) == binding.key + } + } + + private fun rebindRealized(model: LazyCollectionModel) { + val provider = model.itemProvider + realized.forEach { (index, binding) -> + val previous = binding.boundModel + val contentType = provider.contentType(index) + val measurementCompatible = + previous != null && + previous.orientation == model.orientation && + previous.crossAxisAlignment == model.crossAxisAlignment && + previous.subcompositions === model.subcompositions && + previous.itemProvider.contentType(index) == contentType && + previous.itemProvider.layoutVersion(index) == provider.layoutVersion(index) + binding.needsMeasurement = binding.needsMeasurement || !measurementCompatible + binding.bind(model, index, contentType) + } + } + + private fun resolveExtent( + model: LazyCollectionModel, + index: Int, + ): Boolean { + val provider = model.itemProvider + return geometry.resolve( + index = index, + key = provider.key(index), + layoutVersion = provider.layoutVersion(index), + contentType = provider.contentType(index), + ) != null + } + + private fun reconcileBindings( + model: LazyCollectionModel, + desired: IntRange, + ) { + pooled.resize(maxOf(MIN_RETAINED_BINDINGS, desired.count())) + realized.keys.toList().forEach { index -> + if (index !in desired) recycle(index) + } + desired.forEach { index -> + if (index in realized) return@forEach + val provider = model.itemProvider + val key = provider.key(index) + val layoutVersion = provider.layoutVersion(index) + val contentType = provider.contentType(index) + val binding = takeBinding(contentType, key) + val hasExactMeasurement = geometry.hasExactMeasurement(key, layoutVersion) + binding.needsMeasurement = binding.needsMeasurement || !hasExactMeasurement + binding.root.onExtentInvalidated = { + if (!binding.suppressExtentInvalidation) { + binding.needsMeasurement = true + if (realized[binding.index] === binding) scheduleLayout() + } + } + binding.suppressExtentInvalidation = hasExactMeasurement && binding.boundModel === model + try { + binding.bind(model, index, contentType) + } finally { + binding.suppressExtentInvalidation = false + } + realized[index] = binding + scrollView.addSubview(binding.root) + } + } + + private fun takeBinding( + contentType: Any?, + key: Any, + ): UIKitAdaptiveItemBinding { + val typeKey = contentType.cacheKey() + pooled.take(typeKey, key)?.let { return it } + val root = UIKitAdaptiveItemStackView() + return UIKitAdaptiveItemBinding( + root = root, + itemHost = + coordinator.createItemHost( + InvalidatingLazyItemChildren(UIKitChildren(root), root::invalidateExtent), + ), + ).also(allBindings::add) + } + + private fun recycle(index: Int) { + val binding = realized.remove(index) ?: return + binding.root.removeFromSuperview() + val key = binding.key + if (key == null) { + allBindings.remove(binding) + binding.dispose() + } else { + pooled.put(binding.contentType.cacheKey(), key, binding) + } + } + + private fun recycleAll() { + realized.keys.toList().forEach(::recycle) + } + + private fun placeRealized(model: LazyCollectionModel) { + val crossExtent = scrollView.crossAxisExtent(model.orientation) + realized.forEach { (index, binding) -> + val start = geometry.itemStart(index) + val extent = geometry.itemExtent(index) + binding.root.setFrame(model.itemFrame(start, extent, crossExtent)) + } + } + + private fun measurePendingBindings(model: LazyCollectionModel): Boolean { + var changed = false + realized.values.forEach { binding -> + if (!binding.needsMeasurement) return@forEach + binding.needsMeasurement = false + binding.root.layoutIfNeeded() + val extent = binding.root.measuredExtent(model.orientation, scrollView.crossAxisExtent(model.orientation)) + val provider = model.itemProvider + val index = binding.index + if (index !in 0 until provider.itemCount || provider.key(index) != binding.key) return@forEach + changed = + geometry.record( + index = index, + key = checkNotNull(binding.key), + layoutVersion = provider.layoutVersion(index), + contentType = binding.contentType, + extent = extent, + ) != null || changed + } + return changed + } + + private fun updateContentSize(model: LazyCollectionModel) { + val bounds = scrollView.bounds.useContents { size.width to size.height } + val next = + when (model.orientation) { + LazyListOrientation.Vertical -> CGSizeMake(bounds.first, max(bounds.second, geometry.contentExtent)) + LazyListOrientation.Horizontal -> CGSizeMake(max(bounds.first, geometry.contentExtent), bounds.second) + } + val nextDimensions = next.useContents { width to height } + val changed = + scrollView.contentSize.useContents { + abs(width - nextDimensions.first) > CONTENT_SIZE_TOLERANCE || + abs(height - nextDimensions.second) > CONTENT_SIZE_TOLERANCE + } + if (changed) scrollView.setContentSize(next) + } + + private fun restoreAnchor( + model: LazyCollectionModel, + anchor: UIKitAdaptiveAnchor, + ): UIKitAdaptiveAnchor? { + val index = + model.itemProvider.findIndexByKey( + key = anchor.key, + expectedIndex = anchor.index, + previousItemCount = anchor.itemCount, + ) + if (index !in 0 until model.itemProvider.itemCount) return null + resolveExtent(model, index) + val itemStart = geometry.itemStart(index) + val currentViewportOffset = scrollView.mainAxisOffset(model.orientation) + val target = + restoredUIKitLazyViewportOffset( + anchorTargetAtCapture = itemStart - anchor.offset, + capturedViewportOffset = anchor.viewportOffset, + currentViewportOffset = currentViewportOffset, + preserveViewportDelta = anchor.preserveViewportDelta && anchor.orientation == model.orientation, + ) + if (needsAdaptiveLazyScrollCorrection(currentViewportOffset, target)) { + scrollView.setContentOffset(model.mainAxisPoint(target), animated = false) + } + val restoredViewportOffset = scrollView.mainAxisOffset(model.orientation) + return UIKitAdaptiveAnchor( + key = anchor.key, + index = index, + itemCount = model.itemProvider.itemCount, + offset = itemStart - restoredViewportOffset, + viewportOffset = restoredViewportOffset, + orientation = model.orientation, + ) + } + + private fun settleScrollRequest(request: LazyListScrollRequest): Boolean { + val model = coordinator.model ?: return false + var pass = 0 + while (pass < MAX_PROGRAMMATIC_SCROLL_CORRECTIONS) { + if (!request.isActive || request.index !in 0 until model.itemProvider.itemCount) return false + layoutVisibleItems() + resolveExtent(model, request.index) + val target = geometry.itemStart(request.index) + request.scrollOffset + val current = scrollView.mainAxisOffset(model.orientation) + if (!needsAdaptiveLazyScrollCorrection(current, target)) break + scrollView.setContentOffset(model.mainAxisPoint(target), animated = false) + pass += 1 + } + reportLayoutInfo(model) + return request.isActive && request.index in 0 until model.itemProvider.itemCount + } + + private fun cancelPendingScroll(stopAnimation: Boolean = false) { + val request = pendingScroll ?: return + pendingScroll = null + if (stopAnimation) { + scrollView.setContentOffset(scrollView.contentOffset, animated = false) + } + request.cancel() + } + + private fun finishPhysicalScroll() { + physicalScrollInProgress = false + coordinator.reportScrollInProgress(false) + layoutVisibleItems() + } + + private fun isPhysicalScrollInProgress(): Boolean = + physicalScrollInProgress || + shouldDeferUIKitLazyOffsetCorrection( + isTracking = scrollView.tracking, + isDragging = scrollView.dragging, + isDecelerating = scrollView.decelerating, + ) + + private fun reportLayoutInfo(model: LazyCollectionModel) { + val viewportStart = scrollView.mainAxisOffset(model.orientation) + val viewportEnd = viewportStart + scrollView.mainAxisViewport(model.orientation) + val visible = + realized.values + .filter { binding -> + val start = geometry.itemStart(binding.index) + val end = start + geometry.itemExtent(binding.index) + end > viewportStart && start < viewportEnd + }.sortedBy(UIKitAdaptiveItemBinding::index) + .map { binding -> + val start = geometry.itemStart(binding.index) + LazyListItemInfo( + key = checkNotNull(binding.key), + index = binding.index, + offset = (start - viewportStart).toFloat(), + size = geometry.itemExtent(binding.index).toFloat(), + ) + } + coordinator.reportLayoutInfo( + LazyListLayoutInfo( + totalItemsCount = model.itemProvider.itemCount, + viewportStartOffset = 0f, + viewportEndOffset = scrollView.mainAxisViewport(model.orientation).toFloat(), + visibleItems = visible, + ), + ) + } +} + +private class UIKitAdaptiveItemBinding( + val root: UIKitAdaptiveItemStackView, + private val itemHost: LazyItemHost, +) { + var index: Int = -1 + private set + var contentType: Any? = null + private set + var needsMeasurement: Boolean = true + var suppressExtentInvalidation: Boolean = false + var boundModel: LazyCollectionModel? = null + private set + + val key: Any? + get() = itemHost.key + + fun bind( + model: LazyCollectionModel, + index: Int, + contentType: Any?, + ) { + root.configure(model) + itemHost.bind(index) + this.index = index + this.contentType = contentType + boundModel = model + } + + fun dispose() { + root.onExtentInvalidated = null + itemHost.dispose() + root.removeFromSuperview() + } +} + +internal class UIKitAdaptiveLazyScrollView : UIScrollView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + var onLayout: (() -> Unit)? = null + + override fun layoutSubviews() { + super.layoutSubviews() + onLayout?.invoke() + } +} + +private class UIKitAdaptiveItemStackView : UIStackView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + var onExtentInvalidated: (() -> Unit)? = null + private var lazyOrientation: LazyListOrientation = LazyListOrientation.Vertical + + init { + translatesAutoresizingMaskIntoConstraints = true + } + + fun configure(model: LazyCollectionModel) { + val nextAxis = + when (model.orientation) { + LazyListOrientation.Vertical -> UILayoutConstraintAxisVertical + LazyListOrientation.Horizontal -> UILayoutConstraintAxisHorizontal + } + val nextAlignment = + when (model.orientation) { + LazyListOrientation.Vertical -> model.crossAxisAlignment.horizontalAlignment() + LazyListOrientation.Horizontal -> model.crossAxisAlignment.verticalAlignment() + } + if (lazyOrientation == model.orientation && axis == nextAxis && alignment == nextAlignment) return + lazyOrientation = model.orientation + axis = nextAxis + alignment = nextAlignment + } + + fun invalidateExtent() { + setNeedsLayout() + onExtentInvalidated?.invoke() + } + + fun measuredExtent( + orientation: LazyListOrientation, + crossAxisExtent: Double, + ): Double { + val target = + when (orientation) { + LazyListOrientation.Vertical -> CGSizeMake(crossAxisExtent, UILayoutFittingCompressedSize.height) + LazyListOrientation.Horizontal -> CGSizeMake(UILayoutFittingCompressedSize.width, crossAxisExtent) + } + return systemLayoutSizeFittingSize( + targetSize = target, + withHorizontalFittingPriority = + if (orientation == LazyListOrientation.Vertical) UILayoutPriorityRequired else UILayoutPriorityFittingSizeLevel, + verticalFittingPriority = + if (orientation == LazyListOrientation.Horizontal) UILayoutPriorityRequired else UILayoutPriorityFittingSizeLevel, + ).useContents { + when (orientation) { + LazyListOrientation.Vertical -> height + LazyListOrientation.Horizontal -> width + } + } + } +} + +private data class UIKitExtentEnvironment( + val orientation: LazyListOrientation, + val crossAxisExtent: Double, +) + +private data class UIKitAdaptiveAnchor( + val key: Any, + val index: Int, + val itemCount: Int, + val offset: Double, + val viewportOffset: Double, + val orientation: LazyListOrientation, + val preserveViewportDelta: Boolean = false, +) + +private fun LazyCollectionModel.itemFrame( + start: Double, + extent: Double, + crossExtent: Double, +) = when (orientation) { + LazyListOrientation.Vertical -> CGRectMake(0.0, start, crossExtent, extent) + LazyListOrientation.Horizontal -> CGRectMake(start, 0.0, extent, crossExtent) +} + +private fun LazyCollectionModel.mainAxisPoint(offset: Double) = + when (orientation) { + LazyListOrientation.Vertical -> CGPointMake(0.0, offset) + LazyListOrientation.Horizontal -> CGPointMake(offset, 0.0) + } + +private fun UIScrollView.mainAxisOffset(orientation: LazyListOrientation): Double = + contentOffset.useContents { + when (orientation) { + LazyListOrientation.Vertical -> y + LazyListOrientation.Horizontal -> x + } + } + +private fun UIScrollView.mainAxisViewport(orientation: LazyListOrientation): Double = + bounds.useContents { + when (orientation) { + LazyListOrientation.Vertical -> size.height + LazyListOrientation.Horizontal -> size.width + } + } + +private fun UIScrollView.crossAxisExtent(orientation: LazyListOrientation): Double = + bounds + .useContents { + when (orientation) { + LazyListOrientation.Vertical -> size.width + LazyListOrientation.Horizontal -> size.height + } + }.coerceAtLeast(1.0) + +private fun LazyCrossAxisAlignment.horizontalAlignment(): Long = + when (this) { + LazyCrossAxisAlignment.Start -> UIStackViewAlignmentLeading + LazyCrossAxisAlignment.Center -> UIStackViewAlignmentCenter + LazyCrossAxisAlignment.End -> UIStackViewAlignmentTrailing + LazyCrossAxisAlignment.Stretch -> UIStackViewAlignmentFill + } + +private fun LazyCrossAxisAlignment.verticalAlignment(): Long = + when (this) { + LazyCrossAxisAlignment.Start -> UIStackViewAlignmentTop + LazyCrossAxisAlignment.Center -> UIStackViewAlignmentCenter + LazyCrossAxisAlignment.End -> UIStackViewAlignmentBottom + LazyCrossAxisAlignment.Stretch -> UIStackViewAlignmentFill + } + +private fun Any?.cacheKey(): Any = this ?: UIKitNullContentType + +private data object UIKitNullContentType + +internal fun shouldDeferUIKitLazyOffsetCorrection( + isTracking: Boolean, + isDragging: Boolean, + isDecelerating: Boolean, +): Boolean = isTracking || isDragging || isDecelerating + +internal fun restoredUIKitLazyViewportOffset( + anchorTargetAtCapture: Double, + capturedViewportOffset: Double, + currentViewportOffset: Double, + preserveViewportDelta: Boolean, +): Double = + if (preserveViewportDelta) { + anchorTargetAtCapture + (currentViewportOffset - capturedViewportOffset) + } else { + anchorTargetAtCapture + } + +private const val OVERSCAN_VIEWPORTS = 0.5 +private const val MAX_LAYOUT_PASSES = 2 +private const val MAX_PROGRAMMATIC_SCROLL_CORRECTIONS = 3 +private const val MIN_RETAINED_BINDINGS = 32 +private const val CONTENT_SIZE_TOLERANCE = 0.5 diff --git a/flareUI/lazy-layout/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyLayoutRendererPlugin.kt b/flareUI/lazy-layout/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyLayoutRendererPlugin.kt new file mode 100644 index 0000000000..286f51a62a --- /dev/null +++ b/flareUI/lazy-layout/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyLayoutRendererPlugin.kt @@ -0,0 +1,16 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.lazy.LazyCollectionWidget + +/** Adaptive UIScrollView renderer for Flare lazy collections. */ +public object UIKitLazyLayoutRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(LazyCollectionWidget::class) { _ -> + UIKitAdaptiveLazyCollectionWidget() + } + } +} diff --git a/flareUI/lazy-layout/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyListTest.kt b/flareUI/lazy-layout/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyListTest.kt new file mode 100644 index 0000000000..16a1e3fcde --- /dev/null +++ b/flareUI/lazy-layout/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyListTest.kt @@ -0,0 +1,655 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.uikit + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.foundation.Column +import dev.dimension.flare.ui.foundation.NativeButton +import dev.dimension.flare.ui.foundation.Text +import dev.dimension.flare.ui.foundation.VerticalAlignment +import dev.dimension.flare.ui.lazy.LazyColumn +import dev.dimension.flare.ui.lazy.LazyListState +import dev.dimension.flare.ui.lazy.LazyRow +import dev.dimension.flare.ui.lazy.awaitAppleUi +import dev.dimension.flare.ui.lazy.items +import kotlinx.cinterop.useContents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSThread +import platform.UIKit.UILabel +import platform.UIKit.UIScrollView +import platform.UIKit.UIStackView +import platform.UIKit.UIWindow +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +public class UIKitLazyListTest { + @Test + public fun adaptiveRecyclerMeasuresMainAxisWithoutAFixedItemContract() { + val state = LazyListState() + withLazyHost { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + item(key = "dynamic") { + Text("Dynamic", modifier = FlareModifier.None.height(73f)) + } + } + } + + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit adaptive item was not measured.") { + host.view.layoutIfNeeded() + scroll.layoutIfNeeded() + state.layoutInfo.visibleItems + .singleOrNull() + ?.size == 73f + } + + assertEquals( + 73f, + state.layoutInfo.visibleItems + .single() + .size, + absoluteTolerance = 0.5f, + ) + assertEquals( + 73.0, + scroll + .itemRoots() + .single() + .frame + .useContents { size.height }, + absoluteTolerance = 0.5, + ) + } + } + + @Test + public fun incrementThenScrollUpdatesTheLazyModelWithoutCompositionReentryOrBlankItems() { + var count by mutableIntStateOf(0) + var keyLookups = 0 + var increment: () -> Unit = {} + val state = LazyListState() + val content: FlareContent = { + val itemOffset = count + increment = { count += 1 } + NativeButton(label = "Increase", onClick = increment) + Text("Count $count") + LazyColumn( + modifier = FlareModifier.None.width(320f).height(240f), + state = state, + ) { + items( + count = 10_000 + itemOffset, + key = { index -> + keyLookups += 1 + index - itemOffset + }, + contentType = { index -> + if ((index - itemOffset) % 5 == 0) "highlight" else "standard" + }, + ) { index -> + val value = index - itemOffset + Text( + "Item $value", + modifier = FlareModifier.None.height(if (value % 5 == 0) 52f else 36f), + ) + } + } + } + + withLazyHost { host, _ -> + val render: (Int) -> Unit = { revision -> + host.setContent { + check(revision >= 0) + content() + } + } + render(0) + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit increment fixture was not ready.") { + host.view.layoutIfNeeded() + scroll.layoutIfNeeded() + state.layoutInfo.totalItemsCount == 10_000 && state.layoutInfo.visibleItems.isNotEmpty() + } + + runBlocking { state.scrollToItem(538) } + awaitAppleUi("UIKit did not realize the deep anchor before the increment.") { + scroll.layoutIfNeeded() + state.layoutInfo.visibleItems.any { it.index == 538 } + } + val anchor = state.layoutInfo.visibleItems.first { it.offset + it.size > 0f } + keyLookups = 0 + increment() + render(1) + awaitAppleUi("UIKit did not preserve the deep stable-key anchor after the prepend.") { + state.layoutInfo.totalItemsCount == 10_001 && + state.layoutInfo.visibleItems.singleOrNull { it.key == anchor.key }?.let { + it.index == anchor.index + 1 && abs(it.offset - anchor.offset) < 1f + } == true && + host.view.arrangedSubviews + .filterIsInstance() + .any { it.text == "Count 1" } + } + assertTrue(keyLookups < 500, "Deep prepend resolved $keyLookups keys instead of using the local anchor.") + assertVisibleContentMatchesLayout(scroll, state, itemOffset = 1) + + val offset = scroll.contentOffset.useContents { y } + scroll.setContentOffset(CGPointMake(0.0, offset + 12.0), animated = false) + listOf(24, 900, 40, 538).forEach { position -> + runBlocking { state.scrollToItem(position) } + awaitAppleUi("UIKit did not realize item $position after the increment and scroll.") { + scroll.layoutIfNeeded() + state.layoutInfo.visibleItems.any { it.index == position } + } + assertVisibleContentMatchesLayout(scroll, state, itemOffset = 1) + } + } + } + + @Test + public fun firstViewportDoesNotResolveEveryItemKey() { + var keyLookups = 0 + withLazyHost { host, _ -> + host.setContent { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> + keyLookups += 1 + index + }, + ) { index -> Text("Item $index") } + } + } + + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit lazy viewport did not realize items.") { + host.view.layoutIfNeeded() + scroll.layoutIfNeeded() + scroll.itemRoots().isNotEmpty() + } + + assertTrue(keyLookups < 500, "First viewport resolved $keyLookups of 10,000 keys.") + assertTrue(scroll.itemRoots().size < 100, "The adaptive recycler realized too much overscan.") + } + } + + @Test + public fun shrinkingTheModelCancelsAnInFlightNativeAnimation() { + var count by mutableIntStateOf(1_000) + val state = LazyListState() + var result: Result? = null + val content: FlareContent = { + val countSnapshot = count + LazyColumn(modifier = FlareModifier.None.fillMaxSize(), state = state) { + items(count = countSnapshot, key = { it }) { index -> + Text("Item $index", modifier = FlareModifier.None.height(36f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit cancellation fixture was not ready.") { + state.layoutInfo.totalItemsCount == 1_000 && state.layoutInfo.visibleItems.isNotEmpty() + } + + CoroutineScope(Dispatchers.Unconfined).launch { + result = runCatching { state.animateScrollToItem(999) } + } + count = 1 + host.setContent(content) + + awaitAppleUi("UIKit did not cancel the outdated native animation.") { + result?.isFailure == true && + state.layoutInfo.totalItemsCount == 1 && + state.layoutInfo.visibleItems.map { it.index } == listOf(0) + } + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.4, false) + assertEquals(listOf(0), state.layoutInfo.visibleItems.map { it.index }) + assertEquals(0.0, scroll.contentOffset.useContents { y }, absoluteTolerance = 0.5) + } + } + + @Test + public fun nativeScrollViewSupportsBothLazyDirections() { + assertTrue(NSThread.isMainThread) + assertDirection(vertical = true) { + LazyColumn { + items(count = 10_000, key = { it }) { index -> Text("Item $index") } + } + } + assertDirection(vertical = false) { + LazyRow { + items(count = 10_000, key = { it }) { index -> Text("Item $index") } + } + } + } + + @Test + public fun variableExtentsAndLayoutVersionUpdatesAreMeasuredIndividually() { + var expanded by mutableStateOf(false) + val state = LazyListState() + val content: FlareContent = { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + item(key = "short") { Text("Short", modifier = FlareModifier.None.height(32f)) } + item(key = "dynamic", layoutVersion = expanded) { + Text("Dynamic", modifier = FlareModifier.None.height(if (expanded) 126f else 88f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + host.awaitScrollView() + awaitAppleUi("UIKit variable lazy items were not measured.") { + state.layoutInfo.visibleItems.map { it.size } == listOf(32f, 88f) + } + + expanded = true + host.setContent(content) + awaitAppleUi("UIKit did not invalidate the changed layout version.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == "dynamic" } + ?.size == 126f + } + assertEquals( + 126f, + state.layoutInfo.visibleItems + .single { it.key == "dynamic" } + .size, + ) + } + } + + @Test + public fun visibleItemRemeasuresWhenItsIntrinsicContentChanges() { + var expanded by mutableStateOf(false) + val state = LazyListState() + val content: FlareContent = { + val expandedSnapshot = expanded + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + item(key = "timeline-post") { + Column(spacing = 4f) { + Text("Timeline title") + if (expandedSnapshot) { + Text("First dynamic body line") + Text("Second dynamic body line") + Text("Third dynamic body line") + } + } + } + } + } + withLazyHost { host, _ -> + host.setContent(content) + host.awaitScrollView() + awaitAppleUi("UIKit intrinsic timeline item was not measured.") { + state.layoutInfo.visibleItems + .singleOrNull() + ?.size + ?.let { it > 0f } == true + } + val collapsedSize = + state.layoutInfo.visibleItems + .single() + .size + + expanded = true + host.setContent(content) + awaitAppleUi("UIKit did not remeasure intrinsic content after recomposition.") { + state.layoutInfo.visibleItems + .singleOrNull() + ?.size + ?.let { it > collapsedSize + 20f } == true + } + } + } + + @Test + public fun lazyGeometryMatchesTheSharedSpacingAndAlignmentContract() { + val columnState = LazyListState() + withLazyHost(width = 200.0, height = 120.0) { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.width(200f).height(120f), + state = columnState, + spacing = 6f, + ) { + item(key = "first") { Text("First", modifier = FlareModifier.None.height(32f)) } + item(key = "second") { Text("Second", modifier = FlareModifier.None.height(48f)) } + } + } + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit column geometry did not settle.") { + val items = columnState.layoutInfo.visibleItems + items.size == 2 && items[0].size == 32f && items[1].offset == 38f && items[1].size == 48f + } + + assertTrue(scroll.alwaysBounceVertical) + assertTrue(!scroll.alwaysBounceHorizontal) + val firstRoot = scroll.itemRoots().minBy { it.frame.useContents { origin.y } } + val firstLabel = firstRoot.arrangedSubviews.single() as UILabel + assertEquals(200.0, firstLabel.frame.useContents { size.width }, absoluteTolerance = 1.0) + } + + val rowState = LazyListState() + withLazyHost(width = 200.0, height = 80.0) { host, _ -> + host.setContent { + LazyRow( + modifier = FlareModifier.None.width(200f).height(80f), + state = rowState, + spacing = 6f, + verticalAlignment = VerticalAlignment.Center, + ) { + item(key = "first") { Text("First", modifier = FlareModifier.None.width(40f).height(24f)) } + item(key = "second") { Text("Second", modifier = FlareModifier.None.width(60f).height(24f)) } + } + } + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit row geometry did not settle.") { + val items = rowState.layoutInfo.visibleItems + items.size == 2 && items[0].size == 40f && items[1].offset == 46f && items[1].size == 60f + } + + assertTrue(!scroll.alwaysBounceVertical) + assertTrue(scroll.alwaysBounceHorizontal) + val firstRoot = scroll.itemRoots().minBy { it.frame.useContents { origin.x } } + assertEquals(80.0, firstRoot.frame.useContents { size.height }, absoluteTolerance = 0.5) + val firstLabel = firstRoot.arrangedSubviews.single() as UILabel + assertEquals(28.0, firstLabel.frame.useContents { origin.y }, absoluteTolerance = 1.0) + } + } + + @Test + public fun prependKeepsTheStableKeyAnchorWithVariableExtents() { + var items by mutableStateOf((0 until 100).toList()) + val state = LazyListState() + val content: FlareContent = { + val reverseContentTypes = items.size > 100 + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items( + items = items, + key = { it }, + contentType = { if ((it % 2 == 0) xor reverseContentTypes) "even" else "odd" }, + ) { item -> + Text("Item $item", modifier = FlareModifier.None.height(if (item % 2 == 0) 36f else 64f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + host.awaitScrollView() + awaitAppleUi("UIKit lazy list was not ready for prepend.") { + state.layoutInfo.totalItemsCount == 100 + } + runBlocking { state.scrollToItem(index = 20, scrollOffset = 17f) } + awaitAppleUi("UIKit anchor did not settle before prepend.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + + items = listOf(-2, -1) + items + host.setContent(content) + awaitAppleUi("UIKit did not restore the stable-key anchor after prepend.") { + state.layoutInfo.totalItemsCount == 102 && + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + } + } + + @Test + public fun crossAxisResizeKeepsTheDeepStableKeyAnchor() { + val state = LazyListState() + withLazyHost { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(count = 200, key = { it }) { index -> + Text("Item $index", modifier = FlareModifier.None.height(if (index % 2 == 0) 36f else 64f)) + } + } + } + + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit resize fixture was not ready.") { + state.layoutInfo.totalItemsCount == 200 && state.layoutInfo.visibleItems.isNotEmpty() + } + runBlocking { state.scrollToItem(index = 80, scrollOffset = 17f) } + awaitAppleUi("UIKit resize anchor did not settle.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == 80 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + + scroll.setFrame(CGRectMake(0.0, 0.0, 220.0, 480.0)) + scroll.setNeedsLayout() + awaitAppleUi("UIKit cross-axis resize changed the deep stable-key anchor.") { + scroll.layoutIfNeeded() + state.layoutInfo.visibleItems + .singleOrNull { it.key == 80 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + } + } + + @Test + public fun modelUpdateDuringDragPreservesSubsequentPhysicalScrollDelta() { + var items by mutableStateOf((0 until 100).toList()) + val state = LazyListState() + val content: FlareContent = { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(items = items, key = { it }) { item -> + Text("Item $item", modifier = FlareModifier.None.height(if (item % 2 == 0) 36f else 64f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit drag-update fixture was not ready.") { + state.layoutInfo.totalItemsCount == 100 + } + runBlocking { state.scrollToItem(index = 20, scrollOffset = 17f) } + awaitAppleUi("UIKit drag-update anchor did not settle.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + + val delegate = checkNotNull(scroll.delegate) + delegate.scrollViewWillBeginDragging(scroll) + val offsetAtUpdate = scroll.contentOffset.useContents { y } + items = listOf(-2, -1) + items + host.setContent(content) + scroll.setContentOffset(CGPointMake(0.0, offsetAtUpdate + 12.0), animated = false) + delegate.scrollViewDidEndDragging(scroll, willDecelerate = false) + + awaitAppleUi("UIKit model update discarded the drag delta.") { + state.layoutInfo.totalItemsCount == 102 && + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.let { it.index == 22 && abs(it.offset + 29f) < 1f } == true + } + } + } + + @Test + public fun contentModelUpdateKeepsTheRealizedNativeRoot() { + var label by mutableStateOf("Before") + val content: FlareContent = { + val labelSnapshot = label + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + item(key = "stable", contentType = labelSnapshot, layoutVersion = Unit) { + Text(labelSnapshot, modifier = FlareModifier.None.height(40f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + lateinit var originalRoot: UIStackView + awaitAppleUi("UIKit content-update fixture was not ready.") { + originalRoot = scroll.itemRoots().singleOrNull() ?: return@awaitAppleUi false + (originalRoot.arrangedSubviews.singleOrNull() as? UILabel)?.text == "Before" + } + + label = "After" + host.setContent(content) + + lateinit var updatedRoot: UIStackView + awaitAppleUi("UIKit content-only update did not reach the realized item.") { + updatedRoot = scroll.itemRoots().singleOrNull() ?: return@awaitAppleUi false + (updatedRoot.arrangedSubviews.singleOrNull() as? UILabel)?.text == "After" + } + assertTrue(updatedRoot === originalRoot, "UIKit recycled the native root for an in-place model update.") + } + } + + @Test + public fun stateScrollsToAnUnmeasuredItemWithOffsetAndReportsTheViewport() { + val state = LazyListState() + withLazyHost { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(count = 100, key = { it }, contentType = { it % 3 }) { index -> + Text("Item $index", modifier = FlareModifier.None.height((28 + index % 3 * 17).toFloat())) + } + } + } + host.awaitScrollView() + awaitAppleUi("UIKit lazy list was not ready for programmatic scrolling.") { + state.layoutInfo.totalItemsCount == 100 + } + + runBlocking { state.scrollToItem(index = 40, scrollOffset = 13f) } + awaitAppleUi("UIKit did not settle the requested dynamic item offset.") { + state.layoutInfo.visibleItems + .singleOrNull { it.index == 40 } + ?.offset + ?.let { abs(it + 13f) < 1f } == true + } + assertEquals(100, state.layoutInfo.totalItemsCount) + } + } + + private fun assertDirection( + vertical: Boolean, + content: FlareContent, + ) { + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + awaitAppleUi("UIKit lazy direction did not settle.") { + scroll.layoutIfNeeded() + scroll.itemRoots().isNotEmpty() + } + assertEquals(vertical, scroll.alwaysBounceVertical) + assertEquals(!vertical, scroll.alwaysBounceHorizontal) + val contentSize = scroll.contentSize.useContents { width to height } + if (vertical) { + assertTrue(contentSize.second > scroll.bounds.useContents { size.height }) + } else { + assertTrue(contentSize.first > scroll.bounds.useContents { size.width }) + } + assertTrue(scroll.itemRoots().size < 100) + } + } + + private fun assertVisibleContentMatchesLayout( + scroll: UIScrollView, + state: LazyListState, + itemOffset: Int, + ) { + val labels = + scroll + .itemRoots() + .mapNotNull { it.arrangedSubviews.singleOrNull() as? UILabel } + .mapNotNull { it.text } + .toSet() + state.layoutInfo.visibleItems.forEach { item -> + assertTrue( + "Item ${item.index - itemOffset}" in labels, + "Visible item ${item.index} rendered a blank or stale view. labels=$labels", + ) + } + } + + private fun withLazyHost( + width: Double = 320.0, + height: Double = 480.0, + block: (FlareUIKitHost, UIWindow) -> Unit, + ) { + val window = UIWindow(frame = CGRectMake(0.0, 0.0, width, height)) + val host = FlareUIKitHost(createUIKitWidgetSystem(UIKitLazyLayoutRendererPlugin)) + try { + host.view.setFrame(window.bounds) + window.addSubview(host.view) + window.hidden = false + block(host, window) + } finally { + host.dispose() + window.hidden = true + } + } + + private fun FlareUIKitHost.awaitScrollView(): UIScrollView { + var scroll: UIScrollView? = null + awaitAppleUi("UIKit adaptive lazy scroll view was not created.") { + view.layoutIfNeeded() + scroll = view.arrangedSubviews.filterIsInstance().singleOrNull() + scroll?.setFrame(view.bounds) + scroll?.layoutIfNeeded() + scroll != null + } + return checkNotNull(scroll) + } + + private fun UIScrollView.itemRoots(): List = subviews.filterIsInstance() +} diff --git a/flareUI/lazy-layout/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyScrollCorrectionPolicyTest.kt b/flareUI/lazy-layout/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyScrollCorrectionPolicyTest.kt new file mode 100644 index 0000000000..463a5d6f4c --- /dev/null +++ b/flareUI/lazy-layout/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitLazyScrollCorrectionPolicyTest.kt @@ -0,0 +1,64 @@ +package dev.dimension.flare.ui.uikit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +public class UIKitLazyScrollCorrectionPolicyTest { + @Test + public fun physicalScrollDefersContentOffsetCorrections() { + assertFalse( + shouldDeferUIKitLazyOffsetCorrection( + isTracking = false, + isDragging = false, + isDecelerating = false, + ), + ) + assertTrue( + shouldDeferUIKitLazyOffsetCorrection( + isTracking = true, + isDragging = false, + isDecelerating = false, + ), + ) + assertTrue( + shouldDeferUIKitLazyOffsetCorrection( + isTracking = false, + isDragging = true, + isDecelerating = false, + ), + ) + assertTrue( + shouldDeferUIKitLazyOffsetCorrection( + isTracking = false, + isDragging = false, + isDecelerating = true, + ), + ) + } + + @Test + public fun deferredCorrectionPreservesThePhysicalViewportDelta() { + assertEquals( + expected = 146.0, + actual = + restoredUIKitLazyViewportOffset( + anchorTargetAtCapture = 130.0, + capturedViewportOffset = 100.0, + currentViewportOffset = 116.0, + preserveViewportDelta = true, + ), + ) + assertEquals( + expected = 130.0, + actual = + restoredUIKitLazyViewportOffset( + anchorTargetAtCapture = 130.0, + capturedViewportOffset = 100.0, + currentViewportOffset = 116.0, + preserveViewportDelta = false, + ), + ) + } +} diff --git a/flareUI/lazy-layout/src/jvmTest/kotlin/dev/dimension/flare/ui/lazy/LazyListDslTest.kt b/flareUI/lazy-layout/src/jvmTest/kotlin/dev/dimension/flare/ui/lazy/LazyListDslTest.kt new file mode 100644 index 0000000000..d42a96b689 --- /dev/null +++ b/flareUI/lazy-layout/src/jvmTest/kotlin/dev/dimension/flare/ui/lazy/LazyListDslTest.kt @@ -0,0 +1,915 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.lazy + +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import dev.dimension.flare.ui.AbstractFlareWidget +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareBackend +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareComposition +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.FlareWidgetSystem +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.concurrent.Executors +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LazyListDslTest { + @Test + fun rebindingTheSameItemInTheSameModelKeepsItsComposition() { + var contentUpdates = 0 + val factory = + object : FlareSubcompositionFactory { + override fun create(root: FlareChildren): FlareSubcomposition = + object : FlareSubcomposition { + override fun setContent(content: FlareContent) { + contentUpdates += 1 + } + + override fun deactivate() = Unit + + override fun dispose() = Unit + } + } + val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = { _, _ -> LazyRealizedItemUpdate.RendererManaged }, + onScroll = {}, + uiDispatcher = Dispatchers.Unconfined, + ) + val model = + LazyCollectionModel( + orientation = LazyListOrientation.Vertical, + spacing = 0f, + crossAxisAlignment = LazyCrossAxisAlignment.Stretch, + itemProvider = + IntervalLazyListScope() + .apply { item(key = "stable") { TestLeaf("content") } } + .build(), + subcompositions = factory, + state = LazyListState(), + ) + + coordinator.setModel(model) + val itemHost = coordinator.createItemHost(RecordingChildren()) + itemHost.bind(0) + itemHost.bind(0) + + assertEquals(1, contentUpdates) + coordinator.dispose() + } + + @Test + fun updatingAModelDoesNotSynchronouslyReenterARealizedItemComposition() { + var applyingParentModel = false + var contentUpdates = 0 + val factory = + object : FlareSubcompositionFactory { + override fun create(root: FlareChildren): FlareSubcomposition = + object : FlareSubcomposition { + override fun setContent(content: FlareContent) { + check(!applyingParentModel) { + "A realized item composition was updated while its parent model was being applied." + } + contentUpdates += 1 + } + + override fun deactivate() = Unit + + override fun dispose() = Unit + } + } + val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = { _, _ -> LazyRealizedItemUpdate.Rebind }, + onScroll = {}, + uiDispatcher = Dispatchers.Unconfined, + ) + val model = { label: String -> + LazyCollectionModel( + orientation = LazyListOrientation.Vertical, + spacing = 0f, + crossAxisAlignment = LazyCrossAxisAlignment.Stretch, + itemProvider = + IntervalLazyListScope() + .apply { item(key = "stable") { TestLeaf(label) } } + .build(), + subcompositions = factory, + state = LazyListState(), + ) + } + coordinator.setModel(model("before")) + coordinator.createItemHost(RecordingChildren()).bind(0) + + applyingParentModel = true + try { + coordinator.setModel(model("after")) + } finally { + applyingParentModel = false + coordinator.dispose() + } + + assertEquals(1, contentUpdates) + } + + @Test + fun largeListBuildsProviderWithoutComposingItems() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + val system = testWidgetSystem(widget) + var compositions = 0 + + HeadlessTestHost(root, system).use { host -> + host.setContent { + LazyColumn { + items( + count = 100_000, + key = { index -> "item-$index" }, + contentType = { index -> index % 2 }, + ) { + compositions += 1 + } + } + } + + val model = checkNotNull(widget.currentModel) + assertEquals(LazyListOrientation.Vertical, model.orientation) + assertEquals(100_000, model.itemProvider.itemCount) + assertEquals("item-99", model.itemProvider.key(99)) + assertEquals(1, model.itemProvider.contentType(99)) + assertEquals(0, compositions) + } + } + + @Test + fun largeListUpdateDoesNotScanEveryKeyForSaveableStateCleanup() { + var generation by mutableStateOf(0) + var keyLookups = 0 + val widget = RecordingLazyCollectionWidget(LazyRealizedItemUpdate.RendererManaged) + + HeadlessTestHost(RecordingChildren(), testWidgetSystem(widget)).use { host -> + host.setContent { + val generationSnapshot = generation + LazyColumn { + items( + count = 10_000, + key = { index -> + keyLookups += 1 + index + }, + contentType = { generationSnapshot }, + ) { index -> TestLeaf("Item $index") } + } + } + val itemHost = widget.coordinator.createItemHost(RecordingChildren()) + itemHost.bind(0) + host.awaitIdle() + + keyLookups = 0 + generation = 1 + host.awaitIdle() + + assertTrue(keyLookups < 500, "Large-list update resolved $keyLookups keys for saveable-state cleanup.") + itemHost.dispose() + } + } + + @Test + fun emptyListProducesAnEmptyProviderWithoutRealizingContent() { + val widget = RecordingLazyCollectionWidget() + + HeadlessTestHost(RecordingChildren(), testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + items(count = 0, key = { it }) { + error("An empty lazy list must not compose item content.") + } + } + } + + assertEquals(0, checkNotNull(widget.currentModel).itemProvider.itemCount) + } + } + + @Test + fun stateForwardsImmediateAndAnimatedScrollCommands() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + val system = testWidgetSystem(widget) + val state = LazyListState() + + HeadlessTestHost(root, system).use { host -> + host.setContent { + LazyRow(state = state) { + items( + count = 100, + key = { index -> index }, + ) {} + } + } + + runBlocking { + state.scrollToItem(index = 37, scrollOffset = 4f) + state.animateScrollToItem(index = 82, scrollOffset = 9f) + } + + assertEquals( + listOf( + RecordedScroll(index = 37, offset = 4f, animated = false), + RecordedScroll(index = 82, offset = 9f, animated = true), + ), + widget.scrolls, + ) + } + } + + @Test + fun shrinkingTheProviderCancelsTheRendererScrollRequest() { + val state = LazyListState() + val owner = Any() + var pendingRequest: LazyListScrollRequest? = null + val cancelledRequests = mutableListOf() + var result: Result? = null + val onScroll: (LazyListScrollRequest) -> Unit = { pendingRequest = it } + val onScrollCancelled: (LazyListScrollRequest) -> Unit = cancelledRequests::add + state.attach(owner, itemCount = 10, onScroll, onScrollCancelled, Dispatchers.Unconfined) + + CoroutineScope(Dispatchers.Unconfined).launch { + result = runCatching { state.animateScrollToItem(9) } + } + state.attach(owner, itemCount = 1, onScroll, onScrollCancelled, Dispatchers.Unconfined) + + assertEquals(listOf(pendingRequest), cancelledRequests) + assertTrue(result?.isFailure == true) + } + + @Test + fun cancellingTheCallingCoroutineCancelsTheRendererScrollRequest() { + val state = LazyListState() + val owner = Any() + var pendingRequest: LazyListScrollRequest? = null + val cancelledRequests = mutableListOf() + state.attach( + owner = owner, + itemCount = 10, + onScroll = { pendingRequest = it }, + onScrollCancelled = cancelledRequests::add, + uiDispatcher = Dispatchers.Unconfined, + ) + + val job = + CoroutineScope(Dispatchers.Unconfined).launch { + state.animateScrollToItem(9) + } + job.cancel() + runBlocking { job.join() } + + assertEquals(listOf(pendingRequest), cancelledRequests) + } + + @Test + fun cancellationDuringUiDispatcherResultHandoffStillCancelsTheRendererRequest() { + Executors + .newSingleThreadExecutor { runnable -> Thread(runnable, "lazy-list-ui-handoff") } + .asCoroutineDispatcher() + .use { uiDispatcher -> + val state = LazyListState() + val owner = Any() + var callerJob: Job? = null + var pendingRequest: LazyListScrollRequest? = null + val cancelledRequests = mutableListOf() + state.attach( + owner = owner, + itemCount = 10, + onScroll = { request -> + pendingRequest = request + checkNotNull(callerJob).cancel() + }, + onScrollCancelled = cancelledRequests::add, + uiDispatcher = uiDispatcher, + ) + + val job = + CoroutineScope(Dispatchers.Default).launch { + callerJob = coroutineContext[Job] + state.animateScrollToItem(9) + } + runBlocking { job.join() } + + assertEquals(listOf(pendingRequest), cancelledRequests) + } + } + + @Test + fun stateSerializesScrollAndCancellationOnTheAttachedUiDispatcher() { + Executors + .newSingleThreadExecutor { runnable -> Thread(runnable, "lazy-list-ui") } + .asCoroutineDispatcher() + .use { uiDispatcher -> + val state = LazyListState() + val owner = Any() + val scrollThread = CompletableDeferred() + val cancellationThread = CompletableDeferred() + val requestStarted = CompletableDeferred() + state.attach( + owner = owner, + itemCount = 10, + onScroll = { request -> + scrollThread.complete(Thread.currentThread()) + requestStarted.complete(Unit) + }, + onScrollCancelled = { + cancellationThread.complete(Thread.currentThread()) + }, + uiDispatcher = uiDispatcher, + ) + val expectedThread = runBlocking(uiDispatcher) { Thread.currentThread() } + val job = + CoroutineScope(Dispatchers.Default).launch { + state.animateScrollToItem(9) + } + + runBlocking { + requestStarted.await() + job.cancelAndJoin() + } + + assertEquals(expectedThread, runBlocking { scrollThread.await() }) + assertEquals(expectedThread, runBlocking { cancellationThread.await() }) + } + } + + @Test + fun realizesOnlyRequestedItemAndDisposesItsSubtree() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + val system = testWidgetSystem(widget) + + HeadlessTestHost(root, system).use { host -> + host.setContent { + LazyColumn { + items( + count = 100_000, + key = { index -> "item-$index" }, + ) { index -> + TestLeaf("Item $index") + } + } + } + + val itemRoot = RecordingChildren() + val itemHost = widget.coordinator.createItemHost(itemRoot) + itemHost.bind(42) + host.awaitIdle() + + assertEquals(1, itemRoot.widgets.size) + assertEquals("Item 42", (itemRoot.widgets.single() as RecordingLeafWidget).renderedText) + + itemHost.bind(84) + host.awaitIdle() + assertEquals("Item 84", (itemRoot.widgets.single() as RecordingLeafWidget).renderedText) + + itemHost.dispose() + assertEquals(emptyList(), itemRoot.widgets) + } + } + + @Test + fun oneItemMayEmitMultipleRootPrimitives() { + val widget = RecordingLazyCollectionWidget() + + HeadlessTestHost(RecordingChildren(), testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + item(key = "multiple") { + TestLeaf("First") + TestLeaf("Second") + } + } + } + val itemRoot = RecordingChildren() + widget.coordinator.createItemHost(itemRoot).bind(0) + host.awaitIdle() + + assertEquals( + listOf("First", "Second"), + itemRoot.widgets.map { (it as RecordingLeafWidget).renderedText }, + ) + } + } + + @Test + fun lazyListsStretchAcrossTheirCrossAxisByDefault() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + + HeadlessTestHost(root, testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + item(key = "column") {} + } + } + assertEquals(LazyCrossAxisAlignment.Stretch, widget.currentModel?.crossAxisAlignment) + + host.setContent { + LazyRow { + item(key = "row") {} + } + } + assertEquals(LazyCrossAxisAlignment.Stretch, widget.currentModel?.crossAxisAlignment) + } + } + + @Test + fun realizedItemFollowsItsStableKeyAcrossContentChangesAndReorder() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + var items by mutableStateOf(listOf(TestItem("a", "A"), TestItem("b", "B"))) + + HeadlessTestHost(root, testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + items( + items = items, + key = TestItem::id, + ) { item -> + TestLeaf(item.label) + } + } + } + val itemRoot = RecordingChildren() + val itemHost = widget.coordinator.createItemHost(itemRoot) + itemHost.bind(0) + host.awaitIdle() + val originalLeaf = itemRoot.widgets.single() as RecordingLeafWidget + assertEquals("A", originalLeaf.renderedText) + + items = listOf(TestItem("b", "B2"), TestItem("a", "A2")) + host.awaitIdle() + + assertEquals("a", itemHost.key) + assertEquals(1, itemHost.index) + val updatedLeaf = itemRoot.widgets.single() as RecordingLeafWidget + assertEquals("A2", updatedLeaf.renderedText) + assertTrue(updatedLeaf === originalLeaf) + } + } + + @Test + fun stateUsesUpdatedItemCountAfterRecomposition() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + val state = LazyListState() + var count by mutableStateOf(1) + + HeadlessTestHost(root, testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn(state = state) { + items(count = count, key = { it }) {} + } + } + count = 3 + host.awaitIdle() + + runBlocking { + state.scrollToItem(2) + } + assertEquals(2, widget.scrolls.single().index) + } + } + + @Test + fun stateRejectsTargetsOutsideTheAttachedProvider() { + val widget = RecordingLazyCollectionWidget() + val state = LazyListState() + + HeadlessTestHost(RecordingChildren(), testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn(state = state) { + item(key = "only") {} + } + } + + assertFailsWith { + runBlocking { state.scrollToItem(1) } + } + } + } + + @Test + fun duplicateRealizedKeysFailBeforeStateCanBeShared() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + + HeadlessTestHost(root, testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + items(count = 2, key = { "duplicate" }) { index -> + TestLeaf("Item $index") + } + } + } + widget.coordinator.createItemHost(RecordingChildren()).bind(0) + + assertFailsWith { + widget.coordinator.createItemHost(RecordingChildren()).bind(1) + } + } + } + + @Test + fun duplicateKeysRealizedAtDifferentTimesCannotShareSaveableState() { + val widget = RecordingLazyCollectionWidget() + + HeadlessTestHost(RecordingChildren(), testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + items(count = 2, key = { "duplicate" }) { index -> + rememberSaveable { index } + TestLeaf("Item $index") + } + } + } + widget.coordinator.createItemHost(RecordingChildren()).apply { + bind(0) + dispose() + } + + assertFailsWith { + widget.coordinator.createItemHost(RecordingChildren()).bind(1) + } + } + } + + @Test + fun duplicateKeysRemainDetectableAcrossProviderGenerations() { + val widget = RecordingLazyCollectionWidget() + var generation by mutableStateOf(0) + + HeadlessTestHost(RecordingChildren(), testWidgetSystem(widget)).use { host -> + host.setContent { + val generationSnapshot = generation + LazyColumn { + items( + count = 2, + key = { index -> + if (index == 0 || generationSnapshot > 0) "duplicate" else "other" + }, + ) { index -> + rememberSaveable { index } + TestLeaf("Item $index") + } + } + } + widget.coordinator.createItemHost(RecordingChildren()).apply { + bind(0) + dispose() + } + generation = 1 + host.awaitIdle() + + assertFailsWith { + widget.coordinator.createItemHost(RecordingChildren()).bind(1) + } + } + } + + @Test + fun staleRealizedKeyOwnershipTransfersWithoutBlankingThePreviousHost() { + var keyOffset = 0 + val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = { _, _ -> LazyRealizedItemUpdate.Rebind }, + onScroll = {}, + uiDispatcher = Dispatchers.Unconfined, + ) + val provider = + IntervalLazyListScope() + .apply { + items( + count = 1_000, + key = { index -> index - keyOffset }, + ) {} + }.build() + coordinator.setModel( + LazyCollectionModel( + orientation = LazyListOrientation.Vertical, + spacing = 0f, + crossAxisAlignment = LazyCrossAxisAlignment.Stretch, + itemProvider = provider, + subcompositions = NoOpSubcompositions, + state = LazyListState(), + ), + ) + val staleHost = coordinator.createItemHost(RecordingChildren()) + val currentHost = coordinator.createItemHost(RecordingChildren()) + try { + staleHost.bind(537) + + // The DSL callback can observe snapshot state directly. Its old realized cell still + // caches key 537, but the current provider now assigns that key to index 538. + keyOffset = 1 + assertEquals(false, coordinator.realizedItemsMatch(provider)) + currentHost.bind(538) + + assertEquals(537, staleHost.index) + assertEquals(536, staleHost.key) + assertEquals(538, currentHost.index) + assertEquals(537, currentHost.key) + } finally { + coordinator.dispose() + } + } + + @Test + fun arrayAndIndexedListOverloadsKeepKeysAndContentTypes() { + val scope = IntervalLazyListScope() + scope.items( + items = arrayOf("a", "b"), + key = { value -> "array-$value" }, + contentType = { "array" }, + ) {} + scope.itemsIndexed( + items = listOf("c", "d"), + key = { index, value -> "list-$index-$value" }, + contentType = { index, _ -> index }, + ) { _, _ -> } + + val provider = scope.build() + assertEquals(4, provider.itemCount) + assertEquals("array-b", provider.key(1)) + assertEquals("array", provider.contentType(1)) + assertEquals("list-1-d", provider.key(3)) + assertEquals(1, provider.contentType(3)) + } + + @Test + fun itemProviderForwardsLayoutVersions() { + val scope = IntervalLazyListScope() + scope.items( + count = 3, + key = { index -> "key-$index" }, + layoutVersion = { index -> "revision-$index" }, + ) {} + + val provider = scope.build() + + assertEquals("revision-0", provider.layoutVersion(0)) + assertEquals("revision-2", provider.layoutVersion(2)) + } + + @Test + fun rememberSaveableStateReturnsWhenAStableKeyIsRealizedAgain() { + val root = RecordingChildren() + val widget = RecordingLazyCollectionWidget() + var increment: () -> Unit = {} + + HeadlessTestHost(root, testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + item(key = "stable") { + var count by rememberSaveable { mutableStateOf(0) } + increment = { count += 1 } + TestLeaf("Count $count") + } + } + } + val firstRoot = RecordingChildren() + val firstHost = widget.coordinator.createItemHost(firstRoot) + firstHost.bind(0) + host.awaitIdle() + increment() + host.awaitIdle() + assertEquals("Count 1", (firstRoot.widgets.single() as RecordingLeafWidget).renderedText) + + firstHost.dispose() + val secondRoot = RecordingChildren() + widget.coordinator.createItemHost(secondRoot).bind(0) + host.awaitIdle() + + assertEquals("Count 1", (secondRoot.widgets.single() as RecordingLeafWidget).renderedText) + } + } + + @Test + fun itemApplyTransactionNotifiesTheNativeMeasurementOwner() { + val widget = RecordingLazyCollectionWidget() + var expand: () -> Unit = {} + var appliedTransactions = 0 + + HeadlessTestHost(RecordingChildren(), testWidgetSystem(widget)).use { host -> + host.setContent { + LazyColumn { + item(key = "timeline-post") { + var expanded by rememberSaveable { mutableStateOf(false) } + expand = { expanded = true } + TestLeaf(if (expanded) "expanded body" else "title") + } + } + } + val itemRoot = RecordingChildren() + val invalidatingRoot = + InvalidatingLazyItemChildren(itemRoot) { + appliedTransactions += 1 + } + val itemHost = widget.coordinator.createItemHost(invalidatingRoot) + itemHost.bind(0) + host.awaitIdle() + val initialTransactions = appliedTransactions + + expand() + host.awaitIdle() + + assertTrue(appliedTransactions > initialTransactions) + assertEquals("expanded body", (itemRoot.widgets.single() as RecordingLeafWidget).renderedText) + } + } +} + +private data class TestItem( + val id: String, + val label: String, +) + +private data object TestBackend : FlareBackend + +private data object NoOpSubcompositions : FlareSubcompositionFactory { + override fun create(root: FlareChildren): FlareSubcomposition = + object : FlareSubcomposition { + override fun setContent(content: FlareContent) = Unit + + override fun deactivate() = Unit + + override fun dispose() = Unit + } +} + +private class RecordingLazyCollectionWidget( + private val realizedItemUpdate: LazyRealizedItemUpdate = LazyRealizedItemUpdate.Rebind, +) : AbstractFlareWidget(), + LazyCollectionWidget { + var currentModel: LazyCollectionModel? = null + val scrolls = mutableListOf() + val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = { _, _ -> realizedItemUpdate }, + onScroll = { request -> + scrolls += RecordedScroll(request.index, request.scrollOffset, request.animated) + request.complete() + }, + uiDispatcher = Dispatchers.Unconfined, + ) + + override fun setModel(model: LazyCollectionModel) { + currentModel = model + coordinator.setModel(model) + } + + override fun dispose() { + coordinator.dispose() + } +} + +private data class RecordedScroll( + val index: Int, + val offset: Float, + val animated: Boolean, +) + +private fun testWidgetSystem(widget: RecordingLazyCollectionWidget): FlareWidgetSystem = + FlareWidgetSystem( + object : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(LazyCollectionWidget::class) { _ -> widget } + registrar.register(TestLeafWidget::class) { _ -> RecordingLeafWidget() } + } + }, + ) + +private interface TestLeafWidget : FlareWidget { + fun setText(value: String) +} + +@Composable +@FlareUiComposable +private fun TestLeaf(text: String) { + EmitFlareWidget( + componentType = TestLeafWidget::class, + update = { + set(text, TestLeafWidget::setText) + }, + ) +} + +private class RecordingLeafWidget : + AbstractFlareWidget(), + TestLeafWidget { + var renderedText: String = "" + + override fun setText(value: String) { + renderedText = value + } +} + +private class RecordingChildren : FlareChildren { + val widgets = mutableListOf() + + override fun insert( + index: Int, + widget: FlareWidget, + ) { + widgets.add(index, widget) + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + val moved = widgets.subList(fromIndex, fromIndex + count).toList() + widgets.subList(fromIndex, fromIndex + count).clear() + val destination = if (fromIndex > toIndex) toIndex else toIndex - count + widgets.addAll(destination, moved) + } + + override fun remove( + index: Int, + count: Int, + ) { + widgets.subList(index, index + count).clear() + } +} + +private class HeadlessTestHost( + root: FlareChildren, + widgetSystem: FlareWidgetSystem, +) : AutoCloseable { + private var frameTimeNanos: Long = 0L + private val frameClock = createFrameClock() + private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob() + frameClock) + private val recomposer = Recomposer(scope.coroutineContext) + private val composition = FlareComposition(root, widgetSystem, TestBackend, recomposer) + + init { + scope.launch { + recomposer.runRecomposeAndApplyChanges() + } + } + + fun setContent(content: FlareContent) { + composition.setContent(content) + awaitIdle() + } + + fun awaitIdle() { + Snapshot.sendApplyNotifications() + runBlocking { + recomposer.awaitIdle() + } + } + + override fun close() { + composition.dispose() + recomposer.cancel() + scope.cancel() + } + + private fun createFrameClock(): BroadcastFrameClock { + lateinit var clock: BroadcastFrameClock + clock = + BroadcastFrameClock { + frameTimeNanos += 16_666_667L + clock.sendFrame(frameTimeNanos) + } + return clock + } +} diff --git a/flareUI/lazy-layout/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitAdaptiveLazyCollectionWidget.kt b/flareUI/lazy-layout/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitAdaptiveLazyCollectionWidget.kt new file mode 100644 index 0000000000..f56be35142 --- /dev/null +++ b/flareUI/lazy-layout/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitAdaptiveLazyCollectionWidget.kt @@ -0,0 +1,916 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareWidget +import dev.dimension.flare.ui.lazy.InvalidatingLazyItemChildren +import dev.dimension.flare.ui.lazy.LazyCollectionCoordinator +import dev.dimension.flare.ui.lazy.LazyCollectionModel +import dev.dimension.flare.ui.lazy.LazyCollectionWidget +import dev.dimension.flare.ui.lazy.LazyCrossAxisAlignment +import dev.dimension.flare.ui.lazy.LazyItemHost +import dev.dimension.flare.ui.lazy.LazyItemReusePool +import dev.dimension.flare.ui.lazy.LazyListItemInfo +import dev.dimension.flare.ui.lazy.LazyListLayoutInfo +import dev.dimension.flare.ui.lazy.LazyListOrientation +import dev.dimension.flare.ui.lazy.LazyListScrollRequest +import dev.dimension.flare.ui.lazy.LazyRealizedItemUpdate +import dev.dimension.flare.ui.lazy.VariableExtentLayoutState +import dev.dimension.flare.ui.lazy.findIndexByKey +import dev.dimension.flare.ui.lazy.needsAdaptiveLazyScrollCorrection +import kotlinx.cinterop.useContents +import kotlinx.coroutines.Dispatchers +import platform.AppKit.NSAnimationContext +import platform.AppKit.NSLayoutAttributeCenterX +import platform.AppKit.NSLayoutAttributeCenterY +import platform.AppKit.NSLayoutAttributeLeading +import platform.AppKit.NSLayoutAttributeTop +import platform.AppKit.NSLayoutAttributeTrailing +import platform.AppKit.NSLayoutConstraint +import platform.AppKit.NSScrollView +import platform.AppKit.NSScrollViewDidEndLiveScrollNotification +import platform.AppKit.NSScrollViewWillStartLiveScrollNotification +import platform.AppKit.NSStackView +import platform.AppKit.NSUserInterfaceLayoutOrientationHorizontal +import platform.AppKit.NSUserInterfaceLayoutOrientationVertical +import platform.AppKit.NSView +import platform.AppKit.NSViewBoundsDidChangeNotification +import platform.AppKit.fittingSize +import platform.AppKit.heightAnchor +import platform.AppKit.widthAnchor +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.darwin.NSObjectProtocol +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.round + +/** Variable-extent AppKit renderer which keeps NSScrollView and owns linear virtualization. */ +internal class AppKitAdaptiveLazyCollectionWidget : + AbstractAppKitWidget(AppKitAdaptiveLazyScrollView()), + LazyCollectionWidget { + private val canvas = AppKitAdaptiveLazyCanvasView() + private val coordinator = + LazyCollectionCoordinator( + owner = this, + onModelChanged = ::applyModel, + onScroll = ::performScroll, + onScrollCancelled = ::cancelScroll, + uiDispatcher = Dispatchers.Main.immediate, + ) + private val bridge = AppKitAdaptiveLazyBridge(view, canvas, coordinator) + private var pendingAnchor: AppKitAdaptiveAnchor? = null + + init { + view.drawsBackground = false + view.documentView = canvas + view.onLayout = bridge::scheduleLayout + } + + override fun setModel(model: LazyCollectionModel) { + pendingAnchor = coordinator.model?.let(bridge::captureAnchor) + try { + coordinator.setModel(model) + } finally { + pendingAnchor = null + } + } + + override fun dispose() { + view.onLayout = null + var failure: Throwable? = null + try { + bridge.dispose() + } catch (error: Throwable) { + failure = error + } + try { + coordinator.dispose() + } catch (error: Throwable) { + if (failure == null) failure = error + } + failure?.let { throw it } + } + + private fun applyModel( + previous: LazyCollectionModel?, + current: LazyCollectionModel, + ): LazyRealizedItemUpdate { + val vertical = current.orientation == LazyListOrientation.Vertical + view.hasVerticalScroller = vertical + view.hasHorizontalScroller = !vertical + view.autohidesScrollers = true + bridge.setModel(current, pendingAnchor) + return LazyRealizedItemUpdate.RendererManaged + } + + private fun performScroll(request: LazyListScrollRequest) { + bridge.performScroll(request) + } + + private fun cancelScroll(request: LazyListScrollRequest) { + bridge.cancelScroll(request) + } +} + +private class AppKitAdaptiveLazyBridge( + private val scrollView: AppKitAdaptiveLazyScrollView, + private val canvas: AppKitAdaptiveLazyCanvasView, + private val coordinator: LazyCollectionCoordinator, +) { + private val geometry = VariableExtentLayoutState() + private val realized = mutableMapOf() + private val allBindings = mutableSetOf() + private val pooled = + LazyItemReusePool(MIN_RETAINED_BINDINGS) { binding -> + allBindings.remove(binding) + binding.dispose() + } + private val notificationCenter = NSNotificationCenter.defaultCenter + private val notificationTokens = mutableListOf() + private var environment: AppKitExtentEnvironment? = null + private var layoutScheduled: Boolean = false + private var layingOut: Boolean = false + private var disposed: Boolean = false + private var pendingAnchor: AppKitAdaptiveAnchor? = null + private var pendingScroll: LazyListScrollRequest? = null + private var modelResetPending: Boolean = false + private var programmaticScrollInProgress: Boolean = false + private var physicalScrollInProgress: Boolean = false + + init { + scrollView.contentView().postsBoundsChangedNotifications = true + notificationTokens += + notificationCenter.addObserverForName( + name = NSViewBoundsDidChangeNotification, + `object` = scrollView.contentView(), + queue = NSOperationQueue.mainQueue, + ) { + layoutVisibleItems() + } + notificationTokens += + notificationCenter.addObserverForName( + name = NSScrollViewWillStartLiveScrollNotification, + `object` = scrollView, + queue = NSOperationQueue.mainQueue, + ) { + physicalScrollInProgress = true + cancelPendingScroll() + coordinator.reportScrollInProgress(true) + } + notificationTokens += + notificationCenter.addObserverForName( + name = NSScrollViewDidEndLiveScrollNotification, + `object` = scrollView, + queue = NSOperationQueue.mainQueue, + ) { + physicalScrollInProgress = false + coordinator.reportScrollInProgress(false) + layoutVisibleItems() + } + } + + fun setModel( + model: LazyCollectionModel, + anchor: AppKitAdaptiveAnchor?, + ) { + // A second model can arrive before the scheduled layout has rebuilt any bindings. Keep the + // last real viewport anchor instead of replacing it with the resulting null capture. + pendingAnchor = + anchor + ?.let { candidate -> + if (physicalScrollInProgress) { + candidate.copy(preserveViewportDelta = true) + } else { + candidate + } + } ?: pendingAnchor + modelResetPending = true + scheduleLayout() + } + + fun captureAnchor(model: LazyCollectionModel): AppKitAdaptiveAnchor? { + val viewportStart = scrollView.mainAxisOffset(model.orientation) + var binding: AppKitAdaptiveItemBinding? = null + var bindingStart = Double.POSITIVE_INFINITY + realized.values.forEach { candidate -> + if (candidate.index !in 0 until model.itemProvider.itemCount) return@forEach + val start = geometry.itemStart(candidate.index) + if (start + geometry.itemExtent(candidate.index) > viewportStart && start < bindingStart) { + binding = candidate + bindingStart = start + } + } + val anchorBinding = binding ?: return null + val key = anchorBinding.key ?: model.itemProvider.key(anchorBinding.index) + return AppKitAdaptiveAnchor( + key = key, + index = anchorBinding.index, + itemCount = model.itemProvider.itemCount, + offset = bindingStart - viewportStart, + viewportOffset = viewportStart, + orientation = model.orientation, + ) + } + + fun scheduleLayout() { + if (disposed || layoutScheduled || layingOut) return + layoutScheduled = true + dispatch_async(dispatch_get_main_queue()) { + layoutScheduled = false + if (!disposed) layoutVisibleItems() + } + } + + fun performScroll(request: LazyListScrollRequest) { + val model = + coordinator.model ?: run { + request.cancel() + return + } + if (modelResetPending) layoutVisibleItems() + if (request.index !in 0 until model.itemProvider.itemCount) { + request.cancel() + return + } + resolveExtent(model, request.index) + val target = geometry.itemStart(request.index) + request.scrollOffset + if (!request.animated) { + programmaticScrollInProgress = true + try { + scrollToMainAxisOffset(model.orientation, target) + if (!settleScrollRequest(request)) { + request.cancel() + return + } + } finally { + programmaticScrollInProgress = false + } + request.complete() + return + } + cancelPendingScroll(stopAnimation = true) + pendingScroll = request + coordinator.reportScrollInProgress(true) + NSAnimationContext.runAnimationGroup( + changes = { context -> + context?.duration = DEFAULT_ANIMATION_DURATION + scrollView.contentView().animator().setBoundsOrigin(model.mainAxisPoint(target)) + }, + completionHandler = { + if (pendingScroll === request) { + val settled = settleScrollRequest(request) + if (pendingScroll === request) pendingScroll = null + if (settled) { + request.complete() + } else { + request.cancel() + } + coordinator.reportScrollInProgress(false) + } + }, + ) + } + + fun cancelScroll(request: LazyListScrollRequest) { + if (pendingScroll !== request) return + pendingScroll = null + stopAnimatedScroll() + coordinator.reportScrollInProgress(false) + } + + fun dispose() { + disposed = true + pendingScroll?.cancel() + pendingScroll = null + notificationTokens.forEach(notificationCenter::removeObserver) + notificationTokens.clear() + var failure: Throwable? = null + try { + pooled.clear() + } catch (error: Throwable) { + failure = error + } + val remainingBindings = allBindings.toList() + allBindings.clear() + realized.clear() + remainingBindings.forEach { binding -> + try { + binding.dispose() + } catch (error: Throwable) { + if (failure == null) failure = error + } + } + failure?.let { throw it } + } + + private fun layoutVisibleItems() { + val model = coordinator.model ?: return + if (disposed || layingOut) return + layingOut = true + try { + val environmentAnchor = + if (modelResetPending) { + modelResetPending = false + if (canApplyModelInPlace(model)) { + resetGeometry(model) + rebindRealized(model) + } else { + recycleAll() + resetGeometry(model) + } + null + } else { + ensureEnvironment(model) + } + val deferOffsetCorrection = shouldDeferAppKitLazyOffsetCorrection(physicalScrollInProgress) + if (deferOffsetCorrection) { + if (pendingAnchor == null) { + pendingAnchor = environmentAnchor?.copy(preserveViewportDelta = true) + } else if (pendingAnchor?.preserveViewportDelta == false) { + pendingAnchor = pendingAnchor?.copy(preserveViewportDelta = true) + } + } + val modelAnchor = if (deferOffsetCorrection) null else pendingAnchor ?: environmentAnchor + val restoredModelAnchor = + modelAnchor?.let { anchor -> + val restored = restoreAnchor(model, anchor) + pendingAnchor = null + restored + } + val measurementAnchor = + restoredModelAnchor ?: if (!deferOffsetCorrection && pendingScroll == null && !programmaticScrollInProgress) { + captureAnchor(model) + } else { + null + } + var geometryChanged = false + var pass = 0 + while (pass < MAX_LAYOUT_PASSES) { + val viewportStart = scrollView.mainAxisOffset(model.orientation) + val viewportSize = scrollView.mainAxisViewport(model.orientation) + val desired = + geometry.visibleRange( + viewportStart = viewportStart, + viewportEnd = viewportStart + viewportSize, + overscan = viewportSize * OVERSCAN_VIEWPORTS, + ) + desired.forEach { index -> + geometryChanged = resolveExtent(model, index) || geometryChanged + } + reconcileBindings(model, desired) + placeRealized(model) + val measured = measurePendingBindings(model) + geometryChanged = measured || geometryChanged + updateCanvasSize(model) + if (!measured) break + pass += 1 + } + placeRealized(model) + updateCanvasSize(model) + if (geometryChanged && measurementAnchor != null) { + restoreAnchor(model, measurementAnchor) + placeRealized(model) + } + canvas.layoutSubtreeIfNeeded() + reportLayoutInfo(model) + } finally { + layingOut = false + } + } + + private fun ensureEnvironment(model: LazyCollectionModel): AppKitAdaptiveAnchor? { + val next = AppKitExtentEnvironment(model.orientation, round(scrollView.crossAxisExtent(model.orientation) * 2.0) / 2.0) + if (environment == next) return null + val anchor = captureAnchor(model) + environment = next + recycleAll() + geometry.reset(model.itemProvider.itemCount, model.spacing.toDouble(), next) + return anchor + } + + private fun resetGeometry(model: LazyCollectionModel) { + val next = AppKitExtentEnvironment(model.orientation, round(scrollView.crossAxisExtent(model.orientation) * 2.0) / 2.0) + environment = next + geometry.reset(model.itemProvider.itemCount, model.spacing.toDouble(), next) + } + + private fun canApplyModelInPlace(model: LazyCollectionModel): Boolean { + val next = AppKitExtentEnvironment(model.orientation, round(scrollView.crossAxisExtent(model.orientation) * 2.0) / 2.0) + if (environment != next || geometry.itemCount != model.itemProvider.itemCount) return false + if (geometry.spacing != model.spacing.toDouble()) return false + val provider = model.itemProvider + return realized.all { (index, binding) -> + index in 0 until provider.itemCount && provider.key(index) == binding.key + } + } + + private fun rebindRealized(model: LazyCollectionModel) { + val provider = model.itemProvider + realized.forEach { (index, binding) -> + val previous = binding.boundModel + val contentType = provider.contentType(index) + val measurementCompatible = + previous != null && + previous.orientation == model.orientation && + previous.crossAxisAlignment == model.crossAxisAlignment && + previous.subcompositions === model.subcompositions && + previous.itemProvider.contentType(index) == contentType && + previous.itemProvider.layoutVersion(index) == provider.layoutVersion(index) + binding.needsMeasurement = binding.needsMeasurement || !measurementCompatible + binding.bind(model, index, contentType) + } + } + + private fun resolveExtent( + model: LazyCollectionModel, + index: Int, + ): Boolean { + val provider = model.itemProvider + return geometry.resolve( + index = index, + key = provider.key(index), + layoutVersion = provider.layoutVersion(index), + contentType = provider.contentType(index), + ) != null + } + + private fun reconcileBindings( + model: LazyCollectionModel, + desired: IntRange, + ) { + pooled.resize(maxOf(MIN_RETAINED_BINDINGS, desired.count())) + realized.keys.toList().forEach { index -> + if (index !in desired) recycle(index) + } + desired.forEach { index -> + if (index in realized) return@forEach + val provider = model.itemProvider + val key = provider.key(index) + val layoutVersion = provider.layoutVersion(index) + val contentType = provider.contentType(index) + val binding = takeBinding(contentType, key) + val hasExactMeasurement = geometry.hasExactMeasurement(key, layoutVersion) + binding.needsMeasurement = binding.needsMeasurement || !hasExactMeasurement + binding.root.onExtentInvalidated = { + if (!binding.suppressExtentInvalidation) { + binding.needsMeasurement = true + if (realized[binding.index] === binding) scheduleLayout() + } + } + binding.suppressExtentInvalidation = hasExactMeasurement && binding.boundModel === model + try { + binding.bind(model, index, contentType) + } finally { + binding.suppressExtentInvalidation = false + } + realized[index] = binding + canvas.addSubview(binding.root) + } + } + + private fun takeBinding( + contentType: Any?, + key: Any, + ): AppKitAdaptiveItemBinding { + val typeKey = contentType.cacheKey() + pooled.take(typeKey, key)?.let { return it } + val root = AppKitAdaptiveItemStackView() + return AppKitAdaptiveItemBinding( + root = root, + itemHost = + coordinator.createItemHost( + InvalidatingLazyItemChildren(AppKitAdaptiveChildren(root), root::invalidateExtent), + ), + ).also(allBindings::add) + } + + private fun recycle(index: Int) { + val binding = realized.remove(index) ?: return + binding.root.removeFromSuperview() + val key = binding.key + if (key == null) { + allBindings.remove(binding) + binding.dispose() + } else { + pooled.put(binding.contentType.cacheKey(), key, binding) + } + } + + private fun recycleAll() { + realized.keys.toList().forEach(::recycle) + } + + private fun placeRealized(model: LazyCollectionModel) { + val crossExtent = scrollView.crossAxisExtent(model.orientation) + realized.forEach { (index, binding) -> + val start = geometry.itemStart(index) + val extent = geometry.itemExtent(index) + binding.root.setFrame(model.itemFrame(start, extent, crossExtent)) + } + } + + private fun measurePendingBindings(model: LazyCollectionModel): Boolean { + var changed = false + realized.values.forEach { binding -> + if (!binding.needsMeasurement) return@forEach + binding.needsMeasurement = false + val extent = binding.root.measuredExtent(model.orientation) + val provider = model.itemProvider + val index = binding.index + if (index !in 0 until provider.itemCount || provider.key(index) != binding.key) return@forEach + changed = + geometry.record( + index = index, + key = checkNotNull(binding.key), + layoutVersion = provider.layoutVersion(index), + contentType = binding.contentType, + extent = extent, + ) != null || changed + } + return changed + } + + private fun updateCanvasSize(model: LazyCollectionModel) { + val viewport = scrollView.contentView().bounds.useContents { size.width to size.height } + val frame = + when (model.orientation) { + LazyListOrientation.Vertical -> CGRectMake(0.0, 0.0, viewport.first, max(viewport.second, geometry.contentExtent)) + LazyListOrientation.Horizontal -> CGRectMake(0.0, 0.0, max(viewport.first, geometry.contentExtent), viewport.second) + } + val nextSize = frame.useContents { size.width to size.height } + val changed = + canvas.frame.useContents { + abs(size.width - nextSize.first) > CANVAS_SIZE_TOLERANCE || + abs(size.height - nextSize.second) > CANVAS_SIZE_TOLERANCE + } + if (changed) canvas.setFrame(frame) + } + + private fun restoreAnchor( + model: LazyCollectionModel, + anchor: AppKitAdaptiveAnchor, + ): AppKitAdaptiveAnchor? { + val index = + model.itemProvider.findIndexByKey( + key = anchor.key, + expectedIndex = anchor.index, + previousItemCount = anchor.itemCount, + ) + if (index !in 0 until model.itemProvider.itemCount) return null + resolveExtent(model, index) + val itemStart = geometry.itemStart(index) + val currentViewportOffset = scrollView.mainAxisOffset(model.orientation) + val target = + restoredAppKitLazyViewportOffset( + anchorTargetAtCapture = itemStart - anchor.offset, + capturedViewportOffset = anchor.viewportOffset, + currentViewportOffset = currentViewportOffset, + preserveViewportDelta = anchor.preserveViewportDelta && anchor.orientation == model.orientation, + ) + scrollToMainAxisOffset(model.orientation, target) + val restoredViewportOffset = scrollView.mainAxisOffset(model.orientation) + return AppKitAdaptiveAnchor( + key = anchor.key, + index = index, + itemCount = model.itemProvider.itemCount, + offset = itemStart - restoredViewportOffset, + viewportOffset = restoredViewportOffset, + orientation = model.orientation, + ) + } + + private fun settleScrollRequest(request: LazyListScrollRequest): Boolean { + val model = coordinator.model ?: return false + var pass = 0 + while (pass < MAX_PROGRAMMATIC_SCROLL_CORRECTIONS) { + if (!request.isActive || request.index !in 0 until model.itemProvider.itemCount) return false + layoutVisibleItems() + resolveExtent(model, request.index) + val target = geometry.itemStart(request.index) + request.scrollOffset + if (!scrollToMainAxisOffset(model.orientation, target)) break + pass += 1 + } + reportLayoutInfo(model) + return request.isActive && request.index in 0 until model.itemProvider.itemCount + } + + private fun scrollToMainAxisOffset( + orientation: LazyListOrientation, + offset: Double, + ): Boolean { + if (!needsAdaptiveLazyScrollCorrection(scrollView.mainAxisOffset(orientation), offset)) return false + scrollView.contentView().setBoundsOrigin( + when (orientation) { + LazyListOrientation.Vertical -> CGPointMake(0.0, offset) + LazyListOrientation.Horizontal -> CGPointMake(offset, 0.0) + }, + ) + scrollView.reflectScrolledClipView(scrollView.contentView()) + return true + } + + private fun cancelPendingScroll(stopAnimation: Boolean = false) { + val request = pendingScroll ?: return + pendingScroll = null + if (stopAnimation) stopAnimatedScroll() + request.cancel() + } + + private fun stopAnimatedScroll() { + val clipView = scrollView.contentView() + val currentOrigin = clipView.bounds.useContents { CGPointMake(origin.x, origin.y) } + clipView.setBoundsOrigin(currentOrigin) + scrollView.reflectScrolledClipView(clipView) + } + + private fun reportLayoutInfo(model: LazyCollectionModel) { + val viewportStart = scrollView.mainAxisOffset(model.orientation) + val viewportEnd = viewportStart + scrollView.mainAxisViewport(model.orientation) + val visible = + realized.values + .filter { binding -> + val start = geometry.itemStart(binding.index) + val end = start + geometry.itemExtent(binding.index) + end > viewportStart && start < viewportEnd + }.sortedBy(AppKitAdaptiveItemBinding::index) + .map { binding -> + val start = geometry.itemStart(binding.index) + LazyListItemInfo( + key = checkNotNull(binding.key), + index = binding.index, + offset = (start - viewportStart).toFloat(), + size = geometry.itemExtent(binding.index).toFloat(), + ) + } + coordinator.reportLayoutInfo( + LazyListLayoutInfo( + totalItemsCount = model.itemProvider.itemCount, + viewportStartOffset = 0f, + viewportEndOffset = scrollView.mainAxisViewport(model.orientation).toFloat(), + visibleItems = visible, + ), + ) + } +} + +private class AppKitAdaptiveItemBinding( + val root: AppKitAdaptiveItemStackView, + private val itemHost: LazyItemHost, +) { + var index: Int = -1 + private set + var contentType: Any? = null + private set + var needsMeasurement: Boolean = true + var suppressExtentInvalidation: Boolean = false + var boundModel: LazyCollectionModel? = null + private set + + val key: Any? + get() = itemHost.key + + fun bind( + model: LazyCollectionModel, + index: Int, + contentType: Any?, + ) { + root.configure(model) + itemHost.bind(index) + this.index = index + this.contentType = contentType + boundModel = model + } + + fun dispose() { + root.onExtentInvalidated = null + itemHost.dispose() + root.removeFromSuperview() + } +} + +internal class AppKitAdaptiveLazyScrollView : NSScrollView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + var onLayout: (() -> Unit)? = null + + override fun layout() { + super.layout() + onLayout?.invoke() + } +} + +internal class AppKitAdaptiveLazyCanvasView : NSView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + override fun isFlipped(): Boolean = true +} + +private class AppKitAdaptiveItemStackView : NSStackView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + private val stretchConstraints = mutableListOf() + private var lazyOrientation: LazyListOrientation = LazyListOrientation.Vertical + private var stretchesCrossAxis: Boolean = false + var onExtentInvalidated: (() -> Unit)? = null + + fun configure(model: LazyCollectionModel) { + val nextOrientation = + when (model.orientation) { + LazyListOrientation.Vertical -> NSUserInterfaceLayoutOrientationVertical + LazyListOrientation.Horizontal -> NSUserInterfaceLayoutOrientationHorizontal + } + val nextAlignment = + when (model.orientation) { + LazyListOrientation.Vertical -> model.crossAxisAlignment.horizontalAlignment() + LazyListOrientation.Horizontal -> model.crossAxisAlignment.verticalAlignment() + } + val nextStretchesCrossAxis = model.crossAxisAlignment == LazyCrossAxisAlignment.Stretch + if (lazyOrientation == model.orientation && + orientation == nextOrientation && + alignment == nextAlignment && + stretchesCrossAxis == nextStretchesCrossAxis + ) { + return + } + spacing = 0.0 + lazyOrientation = model.orientation + stretchesCrossAxis = nextStretchesCrossAxis + orientation = nextOrientation + alignment = nextAlignment + rebuildStretchConstraints() + } + + fun invalidateExtent() { + needsLayout = true + onExtentInvalidated?.invoke() + } + + fun rebuildStretchConstraints() { + NSLayoutConstraint.deactivateConstraints(stretchConstraints) + stretchConstraints.clear() + if (stretchesCrossAxis) { + arrangedSubviews.forEach { installStretchConstraint(it as NSView) } + } + } + + fun measuredExtent(orientation: LazyListOrientation): Double { + layoutSubtreeIfNeeded() + return fittingExtent(orientation) + } + + private fun fittingExtent(orientation: LazyListOrientation): Double = + fittingSize.useContents { + when (orientation) { + LazyListOrientation.Vertical -> height + LazyListOrientation.Horizontal -> width + } + } + + private fun installStretchConstraint(child: NSView) { + val constraint = + when (lazyOrientation) { + LazyListOrientation.Vertical -> child.widthAnchor.constraintEqualToAnchor(widthAnchor) + LazyListOrientation.Horizontal -> child.heightAnchor.constraintEqualToAnchor(heightAnchor) + }.apply { + priority = LAZY_STRETCH_PRIORITY + } + stretchConstraints += constraint + NSLayoutConstraint.activateConstraints(listOf(constraint)) + } +} + +private class AppKitAdaptiveChildren( + private val parent: AppKitAdaptiveItemStackView, +) : FlareChildren { + private val delegate = AppKitChildren(parent) + + override fun onBeginChanges() { + delegate.onBeginChanges() + } + + override fun onEndChanges() { + delegate.onEndChanges() + parent.rebuildStretchConstraints() + } + + override fun insert( + index: Int, + widget: FlareWidget, + ) { + delegate.insert(index, widget) + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + delegate.move(fromIndex, toIndex, count) + } + + override fun remove( + index: Int, + count: Int, + ) { + delegate.remove(index, count) + } +} + +private data class AppKitExtentEnvironment( + val orientation: LazyListOrientation, + val crossAxisExtent: Double, +) + +private data class AppKitAdaptiveAnchor( + val key: Any, + val index: Int, + val itemCount: Int, + val offset: Double, + val viewportOffset: Double, + val orientation: LazyListOrientation, + val preserveViewportDelta: Boolean = false, +) + +private fun LazyCollectionModel.itemFrame( + start: Double, + extent: Double, + crossExtent: Double, +) = when (orientation) { + LazyListOrientation.Vertical -> CGRectMake(0.0, start, crossExtent, extent) + LazyListOrientation.Horizontal -> CGRectMake(start, 0.0, extent, crossExtent) +} + +private fun LazyCollectionModel.mainAxisPoint(offset: Double) = + when (orientation) { + LazyListOrientation.Vertical -> CGPointMake(0.0, offset) + LazyListOrientation.Horizontal -> CGPointMake(offset, 0.0) + } + +private fun NSScrollView.mainAxisOffset(orientation: LazyListOrientation): Double = + contentView().bounds.useContents { + when (orientation) { + LazyListOrientation.Vertical -> origin.y + LazyListOrientation.Horizontal -> origin.x + } + } + +private fun NSScrollView.mainAxisViewport(orientation: LazyListOrientation): Double = + contentView().bounds.useContents { + when (orientation) { + LazyListOrientation.Vertical -> size.height + LazyListOrientation.Horizontal -> size.width + } + } + +private fun NSScrollView.crossAxisExtent(orientation: LazyListOrientation): Double = + contentView() + .bounds + .useContents { + when (orientation) { + LazyListOrientation.Vertical -> size.width + LazyListOrientation.Horizontal -> size.height + } + }.coerceAtLeast(1.0) + +private fun LazyCrossAxisAlignment.horizontalAlignment(): Long = + when (this) { + LazyCrossAxisAlignment.Start, LazyCrossAxisAlignment.Stretch -> NSLayoutAttributeLeading + LazyCrossAxisAlignment.Center -> NSLayoutAttributeCenterX + LazyCrossAxisAlignment.End -> NSLayoutAttributeTrailing + } + +private fun LazyCrossAxisAlignment.verticalAlignment(): Long = + when (this) { + LazyCrossAxisAlignment.Start, LazyCrossAxisAlignment.Stretch -> NSLayoutAttributeTop + LazyCrossAxisAlignment.Center -> NSLayoutAttributeCenterY + LazyCrossAxisAlignment.End -> platform.AppKit.NSLayoutAttributeBottom + } + +private fun Any?.cacheKey(): Any = this ?: AppKitNullContentType + +private data object AppKitNullContentType + +internal fun shouldDeferAppKitLazyOffsetCorrection(isLiveScrolling: Boolean): Boolean = isLiveScrolling + +internal fun restoredAppKitLazyViewportOffset( + anchorTargetAtCapture: Double, + capturedViewportOffset: Double, + currentViewportOffset: Double, + preserveViewportDelta: Boolean, +): Double = + if (preserveViewportDelta) { + anchorTargetAtCapture + (currentViewportOffset - capturedViewportOffset) + } else { + anchorTargetAtCapture + } + +private const val OVERSCAN_VIEWPORTS = 0.5 +private const val MAX_LAYOUT_PASSES = 2 +private const val MAX_PROGRAMMATIC_SCROLL_CORRECTIONS = 3 +private const val MIN_RETAINED_BINDINGS = 32 +private const val DEFAULT_ANIMATION_DURATION = 0.25 +private const val CANVAS_SIZE_TOLERANCE = 0.5 +private const val LAZY_STRETCH_PRIORITY: Float = 999f diff --git a/flareUI/lazy-layout/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyLayoutRendererPlugin.kt b/flareUI/lazy-layout/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyLayoutRendererPlugin.kt new file mode 100644 index 0000000000..1b68419b4a --- /dev/null +++ b/flareUI/lazy-layout/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyLayoutRendererPlugin.kt @@ -0,0 +1,16 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.lazy.LazyCollectionWidget + +/** Adaptive NSScrollView renderer for Flare lazy collections. */ +public object AppKitLazyLayoutRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(LazyCollectionWidget::class) { _ -> + AppKitAdaptiveLazyCollectionWidget() + } + } +} diff --git a/flareUI/lazy-layout/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyListTest.kt b/flareUI/lazy-layout/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyListTest.kt new file mode 100644 index 0000000000..7724e5904f --- /dev/null +++ b/flareUI/lazy-layout/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyListTest.kt @@ -0,0 +1,660 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.appkit + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.foundation.Column +import dev.dimension.flare.ui.foundation.Text +import dev.dimension.flare.ui.foundation.VerticalAlignment +import dev.dimension.flare.ui.lazy.LazyColumn +import dev.dimension.flare.ui.lazy.LazyListState +import dev.dimension.flare.ui.lazy.LazyRow +import dev.dimension.flare.ui.lazy.awaitAppleUi +import dev.dimension.flare.ui.lazy.items +import kotlinx.cinterop.useContents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import platform.AppKit.NSApplication +import platform.AppKit.NSBackingStoreBuffered +import platform.AppKit.NSScrollView +import platform.AppKit.NSScrollViewDidEndLiveScrollNotification +import platform.AppKit.NSScrollViewWillStartLiveScrollNotification +import platform.AppKit.NSStackView +import platform.AppKit.NSTextField +import platform.AppKit.NSView +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowStyleMaskBorderless +import platform.AppKit.alignmentRectForFrame +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSThread +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +public class AppKitLazyListTest { + @Test + public fun adaptiveRecyclerMeasuresMainAxisWithoutAFixedItemContract() { + val state = LazyListState() + withLazyHost { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + item(key = "dynamic") { + Text("Dynamic", modifier = FlareModifier.None.height(73f)) + } + } + } + + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit adaptive item was not measured.") { + scroll.documentView?.layoutSubtreeIfNeeded() + state.layoutInfo.visibleItems + .singleOrNull() + ?.size == 73f + } + + assertEquals( + 73f, + state.layoutInfo.visibleItems + .single() + .size, + absoluteTolerance = 0.5f, + ) + assertEquals( + 73.0, + scroll + .itemRoots() + .single() + .frame + .useContents { size.height }, + absoluteTolerance = 0.5, + ) + } + } + + @Test + public fun largeModelUpdateAndScrollingStayViewportBoundWithoutBlankItems() { + var count by mutableStateOf(0) + var keyLookups = 0 + val state = LazyListState() + val content: FlareContent = { + val itemOffset = count + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items( + count = 10_000 + itemOffset, + key = { index -> + keyLookups += 1 + index - itemOffset + }, + contentType = { index -> if ((index - itemOffset) % 5 == 0) "highlight" else "standard" }, + ) { index -> + val value = index - itemOffset + Text( + "Item $value", + modifier = FlareModifier.None.height(if (value % 5 == 0) 52f else 36f), + ) + } + } + } + + withLazyHost { host, _ -> + val render: (Int) -> Unit = { revision -> + host.setContent { + check(revision >= 0) + content() + } + } + render(0) + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit large lazy list did not realize its first viewport.") { + state.layoutInfo.totalItemsCount == 10_000 && state.layoutInfo.visibleItems.isNotEmpty() + } + + runBlocking { state.scrollToItem(538) } + awaitAppleUi("AppKit did not realize the deep anchor before the update.") { + state.layoutInfo.visibleItems.any { it.index == 538 } + } + val anchor = state.layoutInfo.visibleItems.first { it.offset + it.size > 0f } + keyLookups = 0 + count = 1 + render(1) + awaitAppleUi("AppKit did not preserve the deep stable-key anchor after the prepend.") { + state.layoutInfo.totalItemsCount == 10_001 && + state.layoutInfo.visibleItems.singleOrNull { it.key == anchor.key }?.let { + it.index == anchor.index + 1 && abs(it.offset - anchor.offset) < 1f + } == true + } + assertTrue(keyLookups < 500, "Deep prepend resolved $keyLookups keys instead of using the local anchor.") + assertVisibleContentMatchesLayout(scroll, state, itemOffset = 1) + + listOf(24, 900, 40, 538).forEach { position -> + runBlocking { state.scrollToItem(position) } + awaitAppleUi("AppKit did not realize item $position after the update.") { + state.layoutInfo.visibleItems.any { it.index == position } + } + assertVisibleContentMatchesLayout(scroll, state, itemOffset = 1) + } + } + } + + @Test + public fun firstViewportDoesNotResolveEveryItemKey() { + var keyLookups = 0 + withLazyHost { host, _ -> + host.setContent { + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + items( + count = 10_000, + key = { index -> + keyLookups += 1 + index + }, + ) { index -> Text("Item $index") } + } + } + + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit lazy viewport did not realize items.") { + scroll.documentView?.layoutSubtreeIfNeeded() + scroll.itemRoots().isNotEmpty() + } + + assertTrue(keyLookups < 500, "First viewport resolved $keyLookups of 10,000 keys.") + assertTrue(scroll.itemRoots().size < 100, "The adaptive recycler realized too much overscan.") + } + } + + @Test + public fun shrinkingTheModelCancelsAnInFlightNativeAnimation() { + var count by mutableIntStateOf(1_000) + val state = LazyListState() + var result: Result? = null + val content: FlareContent = { + val countSnapshot = count + LazyColumn(modifier = FlareModifier.None.fillMaxSize(), state = state) { + items(count = countSnapshot, key = { it }) { index -> + Text("Item $index", modifier = FlareModifier.None.height(36f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit cancellation fixture was not ready.") { + state.layoutInfo.totalItemsCount == 1_000 && state.layoutInfo.visibleItems.isNotEmpty() + } + + CoroutineScope(Dispatchers.Unconfined).launch { + result = runCatching { state.animateScrollToItem(999) } + } + count = 1 + host.setContent(content) + + awaitAppleUi("AppKit did not cancel the outdated native animation.") { + result?.isFailure == true && + state.layoutInfo.totalItemsCount == 1 && + state.layoutInfo.visibleItems.map { it.index } == listOf(0) + } + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.4, false) + assertEquals(listOf(0), state.layoutInfo.visibleItems.map { it.index }) + assertEquals( + 0.0, + scroll.contentView().bounds.useContents { origin.y }, + absoluteTolerance = 0.5, + ) + } + } + + @Test + public fun nativeScrollViewSupportsBothLazyDirections() { + assertTrue(NSThread.isMainThread) + assertDirection(vertical = true) { + LazyColumn { + items(count = 10_000, key = { it }) { index -> Text("Item $index") } + } + } + assertDirection(vertical = false) { + LazyRow { + items(count = 10_000, key = { it }) { index -> Text("Item $index") } + } + } + } + + @Test + public fun variableExtentsAndLayoutVersionUpdatesAreMeasuredIndividually() { + var expanded by mutableStateOf(false) + val state = LazyListState() + val content: FlareContent = { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + item(key = "short") { Text("Short", modifier = FlareModifier.None.height(32f)) } + item(key = "dynamic", layoutVersion = expanded) { + Text("Dynamic", modifier = FlareModifier.None.height(if (expanded) 126f else 88f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + host.awaitScrollView() + awaitAppleUi("AppKit variable lazy items were not measured.") { + state.layoutInfo.visibleItems.map { it.size } == listOf(32f, 88f) + } + + expanded = true + host.setContent(content) + awaitAppleUi("AppKit did not invalidate the changed layout version.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == "dynamic" } + ?.size == 126f + } + assertEquals( + 126f, + state.layoutInfo.visibleItems + .single { it.key == "dynamic" } + .size, + ) + } + } + + @Test + public fun visibleItemRemeasuresWhenItsIntrinsicContentChanges() { + var expanded by mutableStateOf(false) + val state = LazyListState() + val content: FlareContent = { + val expandedSnapshot = expanded + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + item(key = "timeline-post") { + Column(spacing = 4f) { + Text("Timeline title") + if (expandedSnapshot) { + Text("First dynamic body line") + Text("Second dynamic body line") + Text("Third dynamic body line") + } + } + } + } + } + withLazyHost { host, _ -> + host.setContent(content) + host.awaitScrollView() + awaitAppleUi("AppKit intrinsic timeline item was not measured.") { + state.layoutInfo.visibleItems + .singleOrNull() + ?.size + ?.let { it > 0f } == true + } + val collapsedSize = + state.layoutInfo.visibleItems + .single() + .size + + expanded = true + host.setContent(content) + awaitAppleUi("AppKit did not remeasure intrinsic content after recomposition.") { + state.layoutInfo.visibleItems + .singleOrNull() + ?.size + ?.let { it > collapsedSize + 20f } == true + } + } + } + + @Test + public fun lazyGeometryMatchesTheSharedSpacingAndAlignmentContract() { + val columnState = LazyListState() + withLazyHost(width = 200.0, height = 120.0) { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.width(200f).height(120f), + state = columnState, + spacing = 6f, + ) { + item(key = "first") { Text("First", modifier = FlareModifier.None.height(32f)) } + item(key = "second") { Text("Second", modifier = FlareModifier.None.height(48f)) } + } + } + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit column geometry did not settle.") { + val items = columnState.layoutInfo.visibleItems + items.size == 2 && items[0].size == 32f && items[1].offset == 38f && items[1].size == 48f + } + + assertTrue(scroll.hasVerticalScroller) + assertTrue(!scroll.hasHorizontalScroller) + val firstRoot = scroll.itemRoots().minBy { it.frame.useContents { origin.y } } + val firstLabel = firstRoot.arrangedSubviews.single() as NSView + val alignmentRect = firstLabel.alignmentRectForFrame(firstLabel.frame) + assertEquals(200.0, alignmentRect.useContents { size.width }, absoluteTolerance = 1.0) + } + + val rowState = LazyListState() + withLazyHost(width = 200.0, height = 80.0) { host, _ -> + host.setContent { + LazyRow( + modifier = FlareModifier.None.width(200f).height(80f), + state = rowState, + spacing = 6f, + verticalAlignment = VerticalAlignment.Center, + ) { + item(key = "first") { Text("First", modifier = FlareModifier.None.width(40f).height(24f)) } + item(key = "second") { Text("Second", modifier = FlareModifier.None.width(60f).height(24f)) } + } + } + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit row geometry did not settle.") { + val items = rowState.layoutInfo.visibleItems + items.size == 2 && items[0].size == 40f && items[1].offset == 46f && items[1].size == 60f + } + + assertTrue(!scroll.hasVerticalScroller) + assertTrue(scroll.hasHorizontalScroller) + val firstRoot = scroll.itemRoots().minBy { it.frame.useContents { origin.x } } + assertEquals(80.0, firstRoot.frame.useContents { size.height }, absoluteTolerance = 0.5) + val firstLabel = firstRoot.arrangedSubviews.single() as NSView + assertEquals(28.0, firstLabel.frame.useContents { origin.y }, absoluteTolerance = 1.0) + } + } + + @Test + public fun prependKeepsTheStableKeyAnchorWithVariableExtents() { + var items by mutableStateOf((0 until 100).toList()) + val state = LazyListState() + val content: FlareContent = { + val reverseContentTypes = items.size > 100 + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items( + items = items, + key = { it }, + contentType = { if ((it % 2 == 0) xor reverseContentTypes) "even" else "odd" }, + ) { item -> + Text("Item $item", modifier = FlareModifier.None.height(if (item % 2 == 0) 36f else 64f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + host.awaitScrollView() + awaitAppleUi("AppKit lazy list was not ready for prepend.") { + state.layoutInfo.totalItemsCount == 100 + } + runBlocking { state.scrollToItem(index = 20, scrollOffset = 17f) } + awaitAppleUi("AppKit anchor did not settle before prepend.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + + items = listOf(-2, -1) + items + host.setContent(content) + awaitAppleUi("AppKit did not restore the stable-key anchor after prepend.") { + state.layoutInfo.totalItemsCount == 102 && + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + } + } + + @Test + public fun crossAxisResizeKeepsTheDeepStableKeyAnchor() { + val state = LazyListState() + withLazyHost { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(count = 200, key = { it }) { index -> + Text("Item $index", modifier = FlareModifier.None.height(if (index % 2 == 0) 36f else 64f)) + } + } + } + + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit resize fixture was not ready.") { + state.layoutInfo.totalItemsCount == 200 && state.layoutInfo.visibleItems.isNotEmpty() + } + runBlocking { state.scrollToItem(index = 80, scrollOffset = 17f) } + awaitAppleUi("AppKit resize anchor did not settle.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == 80 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + + scroll.setFrame(CGRectMake(0.0, 0.0, 220.0, 480.0)) + scroll.needsLayout = true + awaitAppleUi("AppKit cross-axis resize changed the deep stable-key anchor.") { + scroll.layoutSubtreeIfNeeded() + state.layoutInfo.visibleItems + .singleOrNull { it.key == 80 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + } + } + + @Test + public fun modelUpdateDuringLiveScrollPreservesSubsequentPhysicalScrollDelta() { + var items by mutableStateOf((0 until 100).toList()) + val state = LazyListState() + val content: FlareContent = { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(items = items, key = { it }) { item -> + Text("Item $item", modifier = FlareModifier.None.height(if (item % 2 == 0) 36f else 64f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit live-scroll update fixture was not ready.") { + state.layoutInfo.totalItemsCount == 100 + } + runBlocking { state.scrollToItem(index = 20, scrollOffset = 17f) } + awaitAppleUi("AppKit live-scroll update anchor did not settle.") { + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.offset + ?.let { abs(it + 17f) < 1f } == true + } + + val notificationCenter = NSNotificationCenter.defaultCenter + notificationCenter.postNotificationName(NSScrollViewWillStartLiveScrollNotification, `object` = scroll) + val offsetAtUpdate = scroll.contentView().bounds.useContents { origin.y } + items = listOf(-2, -1) + items + host.setContent(content) + scroll.contentView().setBoundsOrigin(CGPointMake(0.0, offsetAtUpdate + 12.0)) + scroll.reflectScrolledClipView(scroll.contentView()) + notificationCenter.postNotificationName(NSScrollViewDidEndLiveScrollNotification, `object` = scroll) + + awaitAppleUi("AppKit model update discarded the live-scroll delta.") { + state.layoutInfo.totalItemsCount == 102 && + state.layoutInfo.visibleItems + .singleOrNull { it.key == 20 } + ?.let { it.index == 22 && abs(it.offset + 29f) < 1f } == true + } + } + } + + @Test + public fun contentModelUpdateKeepsTheRealizedNativeRoot() { + var label by mutableStateOf("Before") + val content: FlareContent = { + val labelSnapshot = label + LazyColumn(modifier = FlareModifier.None.fillMaxSize()) { + item(key = "stable", contentType = labelSnapshot, layoutVersion = Unit) { + Text(labelSnapshot, modifier = FlareModifier.None.height(40f)) + } + } + } + + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + lateinit var originalRoot: NSStackView + awaitAppleUi("AppKit content-update fixture was not ready.") { + originalRoot = scroll.itemRoots().singleOrNull() ?: return@awaitAppleUi false + (originalRoot.arrangedSubviews.singleOrNull() as? NSTextField)?.stringValue == "Before" + } + + label = "After" + host.setContent(content) + + lateinit var updatedRoot: NSStackView + awaitAppleUi("AppKit content-only update did not reach the realized item.") { + updatedRoot = scroll.itemRoots().singleOrNull() ?: return@awaitAppleUi false + (updatedRoot.arrangedSubviews.singleOrNull() as? NSTextField)?.stringValue == "After" + } + assertTrue(updatedRoot === originalRoot, "AppKit recycled the native root for an in-place model update.") + } + } + + @Test + public fun stateScrollsToAnUnmeasuredItemWithOffsetAndReportsTheViewport() { + val state = LazyListState() + withLazyHost { host, _ -> + host.setContent { + LazyColumn( + modifier = FlareModifier.None.fillMaxSize(), + state = state, + ) { + items(count = 100, key = { it }, contentType = { it % 3 }) { index -> + Text("Item $index", modifier = FlareModifier.None.height((28 + index % 3 * 17).toFloat())) + } + } + } + host.awaitScrollView() + awaitAppleUi("AppKit lazy list was not ready for programmatic scrolling.") { + state.layoutInfo.totalItemsCount == 100 + } + + runBlocking { state.scrollToItem(index = 40, scrollOffset = 13f) } + awaitAppleUi("AppKit did not settle the requested dynamic item offset.") { + state.layoutInfo.visibleItems + .singleOrNull { it.index == 40 } + ?.offset + ?.let { abs(it + 13f) < 1f } == true + } + assertEquals(100, state.layoutInfo.totalItemsCount) + } + } + + private fun assertDirection( + vertical: Boolean, + content: FlareContent, + ) { + withLazyHost { host, _ -> + host.setContent(content) + val scroll = host.awaitScrollView() + awaitAppleUi("AppKit lazy direction did not settle.") { + scroll.documentView?.layoutSubtreeIfNeeded() + scroll.itemRoots().isNotEmpty() + } + assertEquals(vertical, scroll.hasVerticalScroller) + assertEquals(!vertical, scroll.hasHorizontalScroller) + val documentSize = checkNotNull(scroll.documentView).frame.useContents { size.width to size.height } + val viewportSize = scroll.contentView().bounds.useContents { size.width to size.height } + if (vertical) { + assertTrue(documentSize.second > viewportSize.second) + } else { + assertTrue(documentSize.first > viewportSize.first) + } + assertTrue(scroll.itemRoots().size < 100) + } + } + + private fun assertVisibleContentMatchesLayout( + scroll: NSScrollView, + state: LazyListState, + itemOffset: Int, + ) { + val labels = + scroll + .itemRoots() + .mapNotNull { it.arrangedSubviews.singleOrNull() as? NSTextField } + .map { it.stringValue } + .toSet() + state.layoutInfo.visibleItems.forEach { item -> + assertTrue( + "Item ${item.index - itemOffset}" in labels, + "Visible item ${item.index} rendered a blank or stale view. labels=$labels", + ) + } + } + + private fun withLazyHost( + width: Double = 320.0, + height: Double = 480.0, + block: (FlareAppKitHost, NSWindow) -> Unit, + ) { + NSApplication.sharedApplication + val window = + NSWindow( + contentRect = CGRectMake(0.0, 0.0, width, height), + styleMask = NSWindowStyleMaskBorderless, + backing = NSBackingStoreBuffered, + defer = false, + ) + val root = NSView(frame = window.contentView?.frame ?: CGRectMake(0.0, 0.0, width, height)) + window.contentView = root + val host = FlareAppKitHost(createAppKitWidgetSystem(AppKitLazyLayoutRendererPlugin)) + try { + host.view.frame = root.bounds + root.addSubview(host.view) + block(host, window) + } finally { + host.dispose() + window.close() + } + } + + private fun FlareAppKitHost.awaitScrollView(): NSScrollView { + var scroll: NSScrollView? = null + awaitAppleUi("AppKit adaptive lazy scroll view was not created.") { + view.layoutSubtreeIfNeeded() + scroll = view.arrangedSubviews.filterIsInstance().singleOrNull() + scroll?.frame = view.bounds + scroll?.layoutSubtreeIfNeeded() + scroll != null + } + return checkNotNull(scroll) + } + + private fun NSScrollView.itemRoots(): List = documentView?.subviews?.filterIsInstance().orEmpty() +} diff --git a/flareUI/lazy-layout/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyScrollCorrectionPolicyTest.kt b/flareUI/lazy-layout/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyScrollCorrectionPolicyTest.kt new file mode 100644 index 0000000000..271454e596 --- /dev/null +++ b/flareUI/lazy-layout/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitLazyScrollCorrectionPolicyTest.kt @@ -0,0 +1,38 @@ +package dev.dimension.flare.ui.appkit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +public class AppKitLazyScrollCorrectionPolicyTest { + @Test + public fun liveScrollDefersAnchorCorrections() { + assertFalse(shouldDeferAppKitLazyOffsetCorrection(isLiveScrolling = false)) + assertTrue(shouldDeferAppKitLazyOffsetCorrection(isLiveScrolling = true)) + } + + @Test + public fun deferredCorrectionPreservesThePhysicalViewportDelta() { + assertEquals( + expected = 146.0, + actual = + restoredAppKitLazyViewportOffset( + anchorTargetAtCapture = 130.0, + capturedViewportOffset = 100.0, + currentViewportOffset = 116.0, + preserveViewportDelta = true, + ), + ) + assertEquals( + expected = 130.0, + actual = + restoredAppKitLazyViewportOffset( + anchorTargetAtCapture = 130.0, + capturedViewportOffset = 100.0, + currentViewportOffset = 116.0, + preserveViewportDelta = false, + ), + ) + } +} diff --git a/flareUI/navigation/build.gradle.kts b/flareUI/navigation/build.gradle.kts new file mode 100644 index 0000000000..e7a30753e8 --- /dev/null +++ b/flareUI/navigation/build.gradle.kts @@ -0,0 +1,45 @@ +import dev.dimension.flareui.buildlogic.FlareUiPlatform +import dev.dimension.flareui.buildlogic.flareUi + +plugins { + id("dev.dimension.flareui.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flareUi { + namespace = "dev.dimension.flare.ui.navigation" + platforms( + FlareUiPlatform.ANDROID, + FlareUiPlatform.JVM, + FlareUiPlatform.IOS, + FlareUiPlatform.MACOS, + ) + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":flare-runtime")) + api(libs.navigation3.runtime) + implementation(libs.kotlinx.coroutines.core) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + val androidMain by getting { + dependencies { + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.androidx.fragment.ktx) + implementation(libs.compose.material3) + implementation(libs.material.components) + implementation(libs.navigation3.ui) + } + } + } +} diff --git a/flareUI/navigation/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidViewNavigationRenderer.kt b/flareUI/navigation/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidViewNavigationRenderer.kt new file mode 100644 index 0000000000..e211ff1870 --- /dev/null +++ b/flareUI/navigation/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidViewNavigationRenderer.kt @@ -0,0 +1,975 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, +) + +package dev.dimension.flare.ui.android + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.transition.Transition +import com.google.android.material.transition.MaterialSharedAxis +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.navigation.NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS +import dev.dimension.flare.ui.navigation.NavigationAcknowledgementHandle +import dev.dimension.flare.ui.navigation.NavigationCommand +import dev.dimension.flare.ui.navigation.NavigationCoordinator +import dev.dimension.flare.ui.navigation.NavigationCoordinatorState +import dev.dimension.flare.ui.navigation.NavigationEntryContentHost +import dev.dimension.flare.ui.navigation.NavigationInteractionHandle +import dev.dimension.flare.ui.navigation.NavigationModel +import dev.dimension.flare.ui.navigation.NavigationModelDispatcher +import dev.dimension.flare.ui.navigation.NavigationOperation +import dev.dimension.flare.ui.navigation.NavigationOperationResult +import dev.dimension.flare.ui.navigation.NavigationPresentation +import dev.dimension.flare.ui.navigation.NavigationWidget +import dev.dimension.flare.ui.navigation.ResolvedNavigationEntry +import java.util.UUID + +/** + * Explicit native containment owner required by [AndroidViewNavigationRendererPlugin]. + * + * Pass an instance to `FlareAndroidViewHost` as its `nativeControllerOwner`; the renderer never + * guesses an activity by unwrapping a `Context`. + */ +public class AndroidViewNavigationOwner internal constructor( + public val activity: FragmentActivity, + internal val fragmentManager: FragmentManager, + internal val lifecycleOwner: LifecycleOwner, +) : FlareNativeControllerOwner { + public constructor(activity: FragmentActivity) : this( + activity = activity, + fragmentManager = activity.supportFragmentManager, + lifecycleOwner = activity, + ) + + internal constructor(fragment: Fragment) : this( + activity = fragment.requireActivity(), + fragmentManager = fragment.childFragmentManager, + lifecycleOwner = fragment, + ) +} + +/** Registers a Fragment-backed, Page-only Android View navigation renderer. */ +public object AndroidViewNavigationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(NavigationWidget::class) { backend -> AndroidViewNavigationWidget(backend) } + } +} + +private class AndroidViewNavigationWidget( + backend: AndroidViewBackend, +) : AbstractAndroidWidget( + FragmentContainerView(backend.context).apply { + id = View.generateViewId() + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + }, + ), + NavigationWidget, + DefaultLifecycleObserver { + private val adapterId = UUID.randomUUID().toString() + private val records = linkedMapOf() + private val mainHandler = Handler(Looper.getMainLooper()) + private var owner: AndroidViewNavigationOwner? = null + private var modelDispatcher: NavigationModelDispatcher? = null + private var stopObservingModels: (() -> Unit)? = null + private var pendingAcknowledgementTimeout: Runnable? = null + private var transitionCommandToken: Long? = null + private var commandTransitionKeys: Set = emptySet() + private var transitionInteraction: NavigationInteractionHandle? = null + private var interactionTransitionKeys: Set = emptySet() + private var registered = false + private var disposed = false + private val coordinator = + NavigationCoordinator( + emitCommand = ::perform, + onRetainedEntriesChanged = ::updateRetainedEntries, + ) + private val backCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (modelDispatcher?.hasUndeliveredModel == true) { + updateBackCallback() + return + } + val interaction = coordinator.beginUserBack() + if (interaction == null) { + if (coordinator.state == NavigationCoordinatorState.Idle) { + passBackToActivity() + } + } else { + updateBackCallback() + performUserBack(interaction) + } + } + } + private val attachListener = + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(view: View) { + coordinator.resumeOperations() + } + + override fun onViewDetachedFromWindow(view: View) = Unit + } + + init { + view.addOnAttachStateChangeListener(attachListener) + } + + override fun setModelDispatcher(dispatcher: NavigationModelDispatcher) { + check(!disposed) { "Android View navigation widget is already disposed." } + stopObservingModels?.invoke() + modelDispatcher = dispatcher + stopObservingModels = dispatcher.observe(::applyModel) + } + + private fun applyModel(model: NavigationModel) { + check(!disposed) { "Android View navigation widget is already disposed." } + model.entries.requirePagesOnly() + val navigationOwner = + model.nativeControllerOwner as? AndroidViewNavigationOwner + ?: error( + "Android View navigation requires AndroidViewNavigationOwner(FragmentActivity). " + + "Pass it to FlareAndroidViewHost(nativeControllerOwner = ...).", + ) + val previousOwner = owner + check(previousOwner == null || previousOwner === navigationOwner) { + "Android View navigation cannot change its FragmentActivity owner while it is mounted." + } + owner = navigationOwner + latestModel = model + if (!registered) { + registered = true + NavigationFragmentRegistry.register(adapterId, this, navigationOwner) + navigationOwner.lifecycleOwner.lifecycle.addObserver(this) + navigationOwner.activity.onBackPressedDispatcher.addCallback( + navigationOwner.lifecycleOwner, + backCallback, + ) + } + val wasPaused = coordinator.state == NavigationCoordinatorState.Paused + coordinator.setModel(model) + if (!coordinator.hasPendingAcknowledgement) { + clearAcknowledgementTimeout() + } + if (wasPaused && view.isAttachedToWindow && !navigationOwner.fragmentManager.isStateSaved) { + // A new applied model is also a fresh platform-readiness opportunity after a bounded + // recovery failure; resume once without spinning inside the failed command callback. + coordinator.resumeOperations() + } + updateBackCallback() + } + + override fun onResume(owner: LifecycleOwner) { + coordinator.resumeOperations() + updateBackCallback() + } + + override fun dispose() { + if (disposed) return + disposed = true + val navigationOwner = owner + stopObservingModels?.invoke() + stopObservingModels = null + modelDispatcher = null + backCallback.remove() + navigationOwner?.lifecycleOwner?.lifecycle?.removeObserver(this) + navigationOwner?.let { NavigationFragmentRegistry.removeWhenSafe(adapterId, it) } + NavigationFragmentRegistry.unregister(adapterId, this) + view.removeOnAttachStateChangeListener(attachListener) + clearAcknowledgementTimeout() + clearTransitionParticipants() + coordinator.dispose() + records.values.forEach(ViewEntryRecord::dispose) + records.clear() + latestModel = null + owner = null + super.dispose() + } + + /** Called by [FlareNavigationPageFragment] when its container view has been created. */ + fun attachFragment( + token: String, + fragment: Fragment, + contentContainer: FrameLayout, + ) { + if (disposed) return + val record = records.values.firstOrNull { it.token == token } ?: return + record.attach(fragment, contentContainer) + } + + /** Called when a page Fragment releases the view that owns its entry composition. */ + fun detachFragment( + token: String, + fragment: Fragment, + ) { + records.values.firstOrNull { it.token == token }?.detach(fragment) + } + + private fun updateRetainedEntries(entries: List) { + if (disposed) return + val model = latestModel ?: return + val retainedKeys = entries.mapTo(mutableSetOf(), ResolvedNavigationEntry::contentKey) + entries.forEach { entry -> + val record = records[entry.contentKey] + if (record == null) { + records[entry.contentKey] = + ViewEntryRecord( + token = "$adapterId:${UUID.randomUUID()}", + entry = entry, + model = model, + ) + } else { + record.update(entry, model) + } + } + val iterator = records.iterator() + while (iterator.hasNext()) { + val (_, record) = iterator.next() + if (record.entry.contentKey !in retainedKeys) { + record.dispose() + iterator.remove() + } + } + updateEntryRetention() + } + + private fun updateEntryRetention() { + val projectedEntries = coordinator.projectedEntries + val activeKey = projectedEntries.lastOrNull()?.contentKey + val frozenKey = projectedEntries.getOrNull(projectedEntries.lastIndex - 1)?.contentKey + val transitionKeys = commandTransitionKeys + interactionTransitionKeys + records.forEach { (contentKey, record) -> + record.updateRetention( + when (contentKey) { + in transitionKeys -> ViewEntryRetention.Active + activeKey -> ViewEntryRetention.Active + frozenKey -> ViewEntryRetention.Frozen + else -> ViewEntryRetention.Released + }, + ) + } + } + + private fun perform(command: NavigationCommand) { + val fragmentManager = + owner?.fragmentManager + ?: return complete(command, NavigationOperationResult.Failed(IllegalStateException("Missing navigation owner."))) + if (!view.isAttachedToWindow || fragmentManager.isStateSaved) { + complete(command, NavigationOperationResult.Deferred) + updateBackCallback() + return + } + beginCommandTransition(command) + try { + when (val operation = command.operation) { + is NavigationOperation.PushPage -> push(fragmentManager, operation, command) + + is NavigationOperation.PopPage -> pop(fragmentManager, operation, command) + + is NavigationOperation.Reconstruct -> reconstruct(fragmentManager, operation, command) + + is NavigationOperation.PresentOverlay, + is NavigationOperation.DismissOverlay, + -> error("Android View navigation currently supports only Page presentation.") + } + } catch (error: Throwable) { + complete(command, NavigationOperationResult.Failed(error)) + } + } + + private fun push( + fragmentManager: FragmentManager, + operation: NavigationOperation.PushPage, + command: NavigationCommand, + ) { + val destination = requireRecord(operation.entry) + val transaction = fragmentManager.beginTransaction().setReorderingAllowed(true) + operation.targetStack.dropLast(1).lastOrNull()?.let { previous -> + val previousRecord = requireRecord(previous) + previousRecord.updateRetention(ViewEntryRetention.Active) + previousRecord.fragment?.let { fragment -> + if (operation.animated) { + fragment.exitTransition = pageTransition(forward = true) + } + transaction + .hide(fragment) + .setMaxLifecycle(fragment, Lifecycle.State.CREATED) + } + } + destination.updateRetention(ViewEntryRetention.Active) + val destinationFragment = destination.createFragment() + val completionTransition = + if (operation.animated) { + pageTransition(forward = true).also { destinationFragment.enterTransition = it } + } else { + null + } + transaction + .add(view.id, destinationFragment, destination.token) + .setMaxLifecycle(destinationFragment, Lifecycle.State.RESUMED) + commitTransaction(transaction, completionTransition) { + complete(command, NavigationOperationResult.Succeeded()) + } + } + + private fun pop( + fragmentManager: FragmentManager, + operation: NavigationOperation.PopPage, + command: NavigationCommand, + ) { + val transaction = fragmentManager.beginTransaction().setReorderingAllowed(true) + val poppedRecord = requireRecord(operation.entry) + poppedRecord.updateRetention(ViewEntryRetention.Active) + val poppedFragment = + poppedRecord.fragment + ?: error("Missing visible navigation Fragment.") + val previousRecord = + operation.targetStack.lastOrNull()?.let { previous -> + requireRecord(previous) + } ?: error("Missing previous navigation entry.") + previousRecord.updateRetention(ViewEntryRetention.Active) + val previousFragment = previousRecord.fragment ?: error("Missing previous navigation Fragment.") + val completionTransition = + if (operation.animated) { + poppedFragment.exitTransition = pageTransition(forward = false) + pageTransition(forward = false).also { previousFragment.enterTransition = it } + } else { + null + } + transaction + .remove(poppedFragment) + .show(previousFragment) + .setMaxLifecycle(previousFragment, Lifecycle.State.RESUMED) + commitTransaction(transaction, completionTransition) { + complete(command, NavigationOperationResult.Succeeded()) + } + } + + private fun reconstruct( + fragmentManager: FragmentManager, + operation: NavigationOperation.Reconstruct, + command: NavigationCommand, + ) { + val transaction = fragmentManager.beginTransaction().setReorderingAllowed(true) + fragmentManager.fragments + .filter { it.navigationAdapterId() == adapterId } + .forEach(transaction::remove) + records.values.forEach(ViewEntryRecord::prepareForReconstruction) + val activeKey = operation.targetStack.lastOrNull()?.contentKey + val frozenKey = operation.targetStack.getOrNull(operation.targetStack.lastIndex - 1)?.contentKey + records.forEach { (contentKey, record) -> + record.updateRetention( + when (contentKey) { + activeKey -> ViewEntryRetention.Active + frozenKey -> ViewEntryRetention.Frozen + else -> ViewEntryRetention.Released + }, + ) + } + operation.targetStack.forEachIndexed { index, entry -> + val record = requireRecord(entry) + val fragment = record.createFragment() + transaction.add(view.id, fragment, record.token) + if (index != operation.targetStack.lastIndex) { + transaction + .hide(fragment) + .setMaxLifecycle(fragment, Lifecycle.State.CREATED) + } else { + transaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED) + } + } + commitTransaction(transaction) { + complete(command, NavigationOperationResult.Succeeded()) + } + } + + private fun performUserBack(interaction: NavigationInteractionHandle) { + val fragmentManager = owner?.fragmentManager + if (fragmentManager == null || !view.isAttachedToWindow || fragmentManager.isStateSaved) { + coordinator.cancelUserBack(interaction) + passBackToActivity() + return + } + val current = coordinator.projectedEntries + val popped = current.lastOrNull() ?: return + val previous = current.dropLast(1).lastOrNull() ?: return + beginInteractionTransition(interaction, popped, previous) + try { + val previousRecord = requireRecord(previous) + val poppedRecord = requireRecord(popped) + previousRecord.updateRetention(ViewEntryRetention.Active) + poppedRecord.updateRetention(ViewEntryRetention.Active) + val previousFragment = previousRecord.fragment ?: error("Missing previous navigation Fragment.") + val poppedFragment = poppedRecord.fragment ?: error("Missing visible navigation Fragment.") + poppedFragment.exitTransition = pageTransition(forward = false) + val completionTransition = pageTransition(forward = false) + previousFragment.enterTransition = completionTransition + val transaction = + fragmentManager + .beginTransaction() + .setReorderingAllowed(true) + .remove(poppedFragment) + .show(previousFragment) + .setMaxLifecycle( + previousFragment, + Lifecycle.State.RESUMED, + ) + commitTransaction(transaction, completionTransition) { + val acknowledgement = coordinator.commitUserBack(interaction) + finishInteractionTransition(interaction) + acknowledgement?.let(::scheduleAcknowledgementTimeout) + updateBackCallback() + } + } catch (error: Throwable) { + coordinator.cancelUserBack(interaction) + finishInteractionTransition(interaction) + updateBackCallback() + if (fragmentManager.isStateSaved || fragmentManager.isDestroyed || !view.isAttachedToWindow) { + return + } + throw error + } + } + + private fun pageTransition(forward: Boolean): Transition = MaterialSharedAxis(MaterialSharedAxis.X, forward) + + private fun commitTransaction( + transaction: FragmentTransaction, + completionTransition: Transition? = null, + onComplete: () -> Unit, + ) { + val transactionOwner = owner + var completed = false + var transitionStarted = false + var transitionListener: Transition.TransitionListener? = null + lateinit var transitionFallback: Runnable + + fun detachTransitionListener() { + transitionListener?.let { listener -> completionTransition?.removeListener(listener) } + transitionListener = null + } + + fun completeOnce() { + if (completed) return + completed = true + mainHandler.removeCallbacks(transitionFallback) + detachTransitionListener() + if (disposed) return + onComplete() + } + + fun completeAfterTransitionCallback() { + if (!view.post(::completeOnce)) completeOnce() + } + + transitionFallback = Runnable(::completeOnce) + + if (completionTransition != null) { + transitionListener = + object : Transition.TransitionListener { + override fun onTransitionStart(transition: Transition) { + transitionStarted = true + } + + override fun onTransitionEnd(transition: Transition) { + completeAfterTransitionCallback() + } + + override fun onTransitionCancel(transition: Transition) { + completeAfterTransitionCallback() + } + + override fun onTransitionPause(transition: Transition) = Unit + + override fun onTransitionResume(transition: Transition) = Unit + } + completionTransition.addListener(checkNotNull(transitionListener)) + } + transaction.runOnCommit { + if (disposed) { + completeOnce() + transactionOwner?.let { NavigationFragmentRegistry.removeWhenSafe(adapterId, it) } + return@runOnCommit + } + // Fragment skips transitions until its container has completed layout. + if (completionTransition == null || !view.isLaidOut) { + if (!view.post(::completeOnce)) completeOnce() + } else { + // Fragment transitions begin from a pre-draw callback after commit. Give that + // callback one full frame; if AndroidX skips the transition, do not leave the + // coordinator executing forever waiting for an end event that cannot arrive. + view.postOnAnimation { + view.postOnAnimation { + if (!transitionStarted) completeOnce() + } + } + } + } + mainHandler.postDelayed(transitionFallback, ANDROID_TRANSITION_COMPLETION_TIMEOUT_MILLIS) + try { + transaction.commit() + } catch (error: Throwable) { + mainHandler.removeCallbacks(transitionFallback) + detachTransitionListener() + throw error + } + } + + private fun scheduleAcknowledgementTimeout(handle: NavigationAcknowledgementHandle) { + clearAcknowledgementTimeout() + lateinit var timeout: Runnable + timeout = + Runnable { + if (pendingAcknowledgementTimeout !== timeout) return@Runnable + pendingAcknowledgementTimeout = null + if (!disposed) { + coordinator.acknowledgementDeadlineReached(handle) + updateBackCallback() + } + } + pendingAcknowledgementTimeout = timeout + mainHandler.postDelayed(timeout, NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS) + } + + private fun clearAcknowledgementTimeout() { + pendingAcknowledgementTimeout?.let(mainHandler::removeCallbacks) + pendingAcknowledgementTimeout = null + } + + private fun complete( + command: NavigationCommand, + result: NavigationOperationResult, + ) { + coordinator.completeCommand(command.token, result) + finishCommandTransition(command.token) + updateBackCallback() + } + + private fun beginCommandTransition(command: NavigationCommand) { + transitionCommandToken = command.token + commandTransitionKeys = + when (command.operation) { + is NavigationOperation.PushPage, + is NavigationOperation.PopPage, + -> { + setOfNotNull( + command.sourceStack.lastOrNull()?.contentKey, + command.operation + .targetStack + .lastOrNull() + ?.contentKey, + ) + } + + is NavigationOperation.Reconstruct -> { + setOfNotNull( + command.operation + .targetStack + .lastOrNull() + ?.contentKey, + ) + } + + is NavigationOperation.PresentOverlay, + is NavigationOperation.DismissOverlay, + -> { + emptySet() + } + } + updateEntryRetention() + } + + private fun finishCommandTransition(token: Long) { + if (transitionCommandToken != token) return + transitionCommandToken = null + commandTransitionKeys = emptySet() + updateEntryRetention() + } + + private fun beginInteractionTransition( + interaction: NavigationInteractionHandle, + source: ResolvedNavigationEntry, + target: ResolvedNavigationEntry, + ) { + transitionInteraction = interaction + interactionTransitionKeys = setOf(source.contentKey, target.contentKey) + updateEntryRetention() + } + + private fun finishInteractionTransition(interaction: NavigationInteractionHandle) { + if (transitionInteraction != interaction) return + transitionInteraction = null + interactionTransitionKeys = emptySet() + updateEntryRetention() + } + + private fun clearTransitionParticipants() { + transitionCommandToken = null + commandTransitionKeys = emptySet() + transitionInteraction = null + interactionTransitionKeys = emptySet() + } + + private fun requireRecord(entry: ResolvedNavigationEntry): ViewEntryRecord = + checkNotNull(records[entry.contentKey]) { + "No Android View host exists for navigation contentKey ${entry.contentKey}." + } + + private fun updateBackCallback() { + // Stay installed as this host's routing gate even at its root. A staged-but-undelivered + // model must be consumed here rather than accidentally escaping to an outer callback; an + // actually idle root is forwarded explicitly by passBackToActivity(). + backCallback.isEnabled = !disposed && registered + } + + private fun passBackToActivity() { + backCallback.isEnabled = false + try { + checkNotNull(owner).activity.onBackPressedDispatcher.onBackPressed() + } finally { + updateBackCallback() + } + } + + private var latestModel: NavigationModel? = null +} + +private enum class ViewEntryRetention { + Active, + Frozen, + Released, +} + +private class ViewEntryRecord( + val token: String, + var entry: ResolvedNavigationEntry, + var model: NavigationModel, +) { + var fragment: Fragment? = null + private set + private var contentHost: NavigationEntryContentHost? = null + private var contentActive = false + private var contentContainer: FrameLayout? = null + private var attachedFragment: Fragment? = null + private var attachedContainer: FrameLayout? = null + private var retention = ViewEntryRetention.Released + + fun update( + entry: ResolvedNavigationEntry, + model: NavigationModel, + ) { + val subcompositionsChanged = this.model.subcompositions !== model.subcompositions + this.entry = entry + this.model = model + if (subcompositionsChanged) { + releaseContentHost() + applyRetention() + } else { + contentHost?.update(entry) + } + } + + fun createFragment(): Fragment { + prepareForReconstruction() + return FlareNavigationPageFragment + .newInstance( + adapterId = token.substringBefore(':'), + token = token, + ).also { fragment = it } + } + + fun attach( + fragment: Fragment, + container: FrameLayout, + ) { + if (this.fragment !== fragment) { + releaseContentHost() + clearAttachment() + this.fragment = fragment + } + attachedFragment = fragment + attachedContainer = container + contentContainer?.let { attachContentContainer(it, container) } + applyRetention() + } + + fun detach(fragment: Fragment) { + if (attachedFragment !== fragment) return + contentContainer?.removeFromParent() + attachedFragment = null + attachedContainer = null + applyRetention() + } + + fun updateRetention(value: ViewEntryRetention) { + retention = value + // Re-apply even when the policy did not change: a Fragment may have become available while + // a reconstruct transaction was in flight. + applyRetention() + } + + fun prepareForReconstruction() { + releaseContentHost() + clearAttachment() + fragment = null + } + + fun dispose() { + prepareForReconstruction() + } + + private fun applyRetention() { + when (retention) { + ViewEntryRetention.Active -> { + val currentFragment = fragment ?: return + ensureContentHost(currentFragment) + activateContent() + } + + ViewEntryRetention.Frozen -> { + val currentFragment = fragment ?: return + ensureContentHost(currentFragment) + deactivateContent() + } + + ViewEntryRetention.Released -> { + releaseContentHost() + } + } + } + + private fun ensureContentContainer(fragment: Fragment): FrameLayout { + val current = contentContainer + if (current != null) return current + return FrameLayout(fragment.requireContext()).also { created -> + created.layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + contentContainer = created + attachedContainer?.let { attachContentContainer(created, it) } + } + } + + private fun attachContentContainer( + content: FrameLayout, + container: FrameLayout, + ) { + if (content.parent === container) return + content.removeFromParent() + container.addView(content) + } + + private fun ensureContentHost(fragment: Fragment) { + val current = contentHost + if (current != null) { + current.update(entry) + return + } + contentHost = + NavigationEntryContentHost( + root = AndroidViewChildren(ensureContentContainer(fragment)), + nativeControllerOwner = AndroidViewNavigationOwner(fragment), + subcompositions = model.subcompositions, + initialEntry = entry, + ) + contentActive = true + } + + private fun activateContent() { + if (contentActive) return + checkNotNull(contentHost).activate() + contentActive = true + } + + private fun deactivateContent() { + if (!contentActive) return + checkNotNull(contentHost).deactivate() + contentActive = false + } + + private fun releaseContentHost() { + contentHost?.dispose() + contentHost = null + contentActive = false + contentContainer?.removeFromParent() + contentContainer = null + } + + private fun clearAttachment() { + contentContainer?.removeFromParent() + attachedFragment = null + attachedContainer = null + } +} + +private fun View.removeFromParent() { + (parent as? ViewGroup)?.removeView(this) +} + +internal class FlareNavigationPageFragment : Fragment() { + override fun onCreateView( + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, + savedInstanceState: Bundle?, + ): View = FrameLayout(requireContext()) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + NavigationFragmentRegistry.attach( + adapterId = requireArguments().getString(ARG_ADAPTER_ID).orEmpty(), + token = requireArguments().getString(ARG_TOKEN).orEmpty(), + fragment = this, + container = view as FrameLayout, + ) + } + + override fun onDestroyView() { + NavigationFragmentRegistry.detach( + adapterId = requireArguments().getString(ARG_ADAPTER_ID).orEmpty(), + token = requireArguments().getString(ARG_TOKEN).orEmpty(), + fragment = this, + ) + super.onDestroyView() + } + + companion object { + private const val ARG_ADAPTER_ID = "flare.navigation.adapter_id" + private const val ARG_TOKEN = "flare.navigation.token" + + fun newInstance( + adapterId: String, + token: String, + ): FlareNavigationPageFragment = + FlareNavigationPageFragment().apply { + arguments = + Bundle().apply { + putString(ARG_ADAPTER_ID, adapterId) + putString(ARG_TOKEN, token) + } + } + } +} + +private object NavigationFragmentRegistry { + private val widgets = mutableMapOf() + + fun register( + adapterId: String, + widget: AndroidViewNavigationWidget, + owner: AndroidViewNavigationOwner, + ) { + widgets[adapterId] = widget + removeOrphansWhenSafe(owner) + } + + fun unregister( + adapterId: String, + widget: AndroidViewNavigationWidget, + ) { + if (widgets[adapterId] === widget) widgets.remove(adapterId) + } + + fun attach( + adapterId: String, + token: String, + fragment: Fragment, + container: FrameLayout, + ) { + widgets[adapterId]?.attachFragment(token, fragment, container) + } + + fun detach( + adapterId: String, + token: String, + fragment: Fragment, + ) { + widgets[adapterId]?.detachFragment(token, fragment) + } + + fun removeWhenSafe( + adapterId: String, + owner: AndroidViewNavigationOwner, + ) { + scheduleRemoval(owner) { fragment -> fragment.navigationAdapterId() == adapterId } + } + + private fun removeOrphansWhenSafe(owner: AndroidViewNavigationOwner) { + scheduleRemoval(owner) { fragment -> + val fragmentAdapterId = fragment.navigationAdapterId() + fragmentAdapterId != null && fragmentAdapterId !in widgets + } + } + + private fun scheduleRemoval( + navigationOwner: AndroidViewNavigationOwner, + shouldRemove: (Fragment) -> Boolean, + ) { + if (remove(navigationOwner.fragmentManager, shouldRemove)) return + val lifecycle = navigationOwner.lifecycleOwner.lifecycle + val observer = + object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + if (remove(navigationOwner.fragmentManager, shouldRemove)) { + lifecycle.removeObserver(this) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + lifecycle.removeObserver(this) + } + } + lifecycle.addObserver(observer) + } + + private fun remove( + fragmentManager: FragmentManager, + shouldRemove: (Fragment) -> Boolean, + ): Boolean { + if (fragmentManager.isDestroyed) return true + if (fragmentManager.isStateSaved) return false + val fragments = fragmentManager.fragments.filter(shouldRemove) + if (fragments.isEmpty()) return true + val transaction = fragmentManager.beginTransaction().setReorderingAllowed(true) + fragments.forEach(transaction::remove) + transaction.commit() + return true + } +} + +private fun Fragment.navigationAdapterId(): String? = arguments?.getString("flare.navigation.adapter_id") + +private const val ANDROID_TRANSITION_COMPLETION_TIMEOUT_MILLIS: Long = 5_000L + +private fun List.requirePagesOnly() { + firstOrNull { it.presentation != NavigationPresentation.Page }?.let { entry -> + error( + "Android View navigation currently supports only Page presentation; " + + "contentKey ${entry.contentKey} uses ${entry.presentation}.", + ) + } +} diff --git a/flareUI/navigation/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeNavigationRendererPlugin.kt b/flareUI/navigation/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeNavigationRendererPlugin.kt new file mode 100644 index 0000000000..0fa49c6ede --- /dev/null +++ b/flareUI/navigation/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeNavigationRendererPlugin.kt @@ -0,0 +1,412 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, +) + +package dev.dimension.flare.ui.compose + +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.UiComposable +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.NavDisplay +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.navigation.NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS +import dev.dimension.flare.ui.navigation.NavigationBackRequest +import dev.dimension.flare.ui.navigation.NavigationEntryContentHost +import dev.dimension.flare.ui.navigation.NavigationEntryIdentity +import dev.dimension.flare.ui.navigation.NavigationModel +import dev.dimension.flare.ui.navigation.NavigationModelDispatcher +import dev.dimension.flare.ui.navigation.NavigationPresentation +import dev.dimension.flare.ui.navigation.NavigationWidget +import dev.dimension.flare.ui.navigation.ResolvedNavigationEntry +import dev.dimension.flare.ui.navigation.hasSameTopologyAs +import dev.dimension.flare.ui.navigation.topology +import kotlinx.coroutines.yield + +/** Registers the Navigation3-backed Android Compose renderer for [dev.dimension.flare.ui.navigation.NavigationDisplay]. */ +public object AndroidComposeNavigationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(NavigationWidget::class) { _ -> AndroidComposeNavigationWidget() } + } +} + +private class AndroidComposeNavigationWidget : + AbstractAndroidComposeWidget(), + NavigationWidget { + private var model: NavigationModel? by mutableStateOf(null) + private var modelDispatcher: NavigationModelDispatcher? = null + private var stopObservingModels: (() -> Unit)? = null + private val hosts = linkedMapOf() + private val compositionCounts = mutableMapOf() + private val mainHandler = Handler(Looper.getMainLooper()) + private var stackRevision = 0L + private var nextBackRequestId = 0L + private var pendingBack: ComposeBackAcknowledgement? = null + private var pendingBackTimeout: Runnable? = null + + override fun setModelDispatcher(dispatcher: NavigationModelDispatcher) { + stopObservingModels?.invoke() + modelDispatcher = dispatcher + stopObservingModels = dispatcher.observe(::applyModel) + } + + private fun applyModel(model: NavigationModel) { + model.entries.requirePagesOnly("Android Compose") + val previousModel = this.model + if (previousModel == null || !previousModel.entries.hasSameTopologyAs(model.entries)) { + stackRevision += 1L + } + settlePendingBack(model.entries.topology()) + this.model = model + model.entries.forEach { entry -> + val host = hosts[entry.contentKey] + if (host == null) { + hosts[entry.contentKey] = + ComposeEntryHost( + entry = entry, + nativeControllerOwner = model.nativeControllerOwner, + subcompositions = model.subcompositions, + ) + } else { + host.update( + entry = entry, + nativeControllerOwner = model.nativeControllerOwner, + subcompositions = model.subcompositions, + ) + } + } + // Model delivery is already deferred until after the parent Flare apply transaction. Keep + // the visible page active and one frozen predecessor tree for predictive back. A frozen + // ReusableComposition retains widgets for the first transition frame while cancelling the + // hidden entry's observations and remembered effects. + val declaredKeys = model.entries.mapTo(mutableSetOf(), ResolvedNavigationEntry::contentKey) + val currentKey = model.entries.last().contentKey + val predecessorKey = model.entries.getOrNull(model.entries.lastIndex - 1)?.contentKey + checkNotNull(hosts[currentKey]).realize() + predecessorKey?.let { contentKey -> + val host = checkNotNull(hosts[contentKey]) + if (contentKey in compositionCounts) { + host.realize() + } else { + host.prepareSnapshot() + } + } + hosts.forEach { (contentKey, host) -> + if ( + contentKey != currentKey && + contentKey != predecessorKey && + contentKey !in compositionCounts && + contentKey in declaredKeys + ) { + host.releaseContent() + } + } + disposeUncomposedRemovedHosts(declaredKeys) + } + + @Composable + @UiComposable + override fun Render() { + val currentModel = model ?: return + val entries = + currentModel.entries.map { entry -> + val host = checkNotNull(hosts[entry.contentKey]) + shadowEntry(entry, host) + } + NavDisplay( + entries = entries, + modifier = composeModifier, + onBack = { + val latest = + model?.takeIf { candidate -> + candidate.entries.size > 1 && + candidate.entries.hasSameTopologyAs(currentModel.entries) && + modelDispatcher?.hasUndeliveredModel != true && + pendingBack == null + } + if (latest != null) { + requestBack(latest) + } + }, + ) + } + + override fun dispose() { + stopObservingModels?.invoke() + stopObservingModels = null + modelDispatcher = null + hosts.values.forEach(ComposeEntryHost::dispose) + hosts.clear() + compositionCounts.clear() + clearPendingBack() + model = null + } + + @Composable + private fun shadowEntry( + entry: ResolvedNavigationEntry, + host: ComposeEntryHost, + ): NavEntry = + shadowEntry(entry.entry) { + LaunchedEffect(entry.contentKey, host) { + // The retained predecessor widget tree covers the first frame. Activate its Flare + // composition only after this parent apply transaction is unlocked so hidden + // observations and effects run only while NavDisplay actually composes the scene. + yield() + if (compositionCounts.containsKey(entry.contentKey) && hosts[entry.contentKey] === host) { + host.realize() + } + } + DisposableEffect(entry.contentKey, host) { + val contentKey = entry.contentKey + compositionCounts[contentKey] = compositionCounts.getOrElse(contentKey) { 0 } + 1 + onDispose { + releaseComposition(contentKey, host) + } + } + host.children.Render() + } + + private fun releaseComposition( + contentKey: Any, + host: ComposeEntryHost, + ) { + val count = compositionCounts[contentKey] ?: return + if (count > 1) { + compositionCounts[contentKey] = count - 1 + return + } + compositionCounts.remove(contentKey) + + // NavDisplay retains outgoing entries while their transition is running. Once no scene is + // composing this entry, freeze only the immediate predictive target and release older + // widget trees completely. + if (model?.entries?.any { it.contentKey == contentKey } == true) { + if (contentKey == predictiveContentKey()) { + host.deactivateContent() + } else if (contentKey != model?.entries?.lastOrNull()?.contentKey) { + host.releaseContent() + } + } else if (hosts[contentKey] === host) { + hosts.remove(contentKey) + host.dispose() + } else { + host.dispose() + } + } + + private fun disposeUncomposedRemovedHosts(declaredKeys: Set) { + val iterator = hosts.iterator() + while (iterator.hasNext()) { + val (contentKey, host) = iterator.next() + if (contentKey !in declaredKeys && contentKey !in compositionCounts) { + host.dispose() + iterator.remove() + } + } + } + + private fun predictiveContentKey(): Any? = model?.entries?.let { it.getOrNull(it.lastIndex - 1)?.contentKey } + + private fun requestBack(model: NavigationModel) { + val base = model.entries.topology() + val acknowledgement = + ComposeBackAcknowledgement( + requestId = nextBackRequestId++, + baseRevision = stackRevision, + base = base, + target = base.dropLast(1), + ) + pendingBack = acknowledgement + scheduleBackTimeout(acknowledgement) + val request = + NavigationBackRequest( + requestId = acknowledgement.requestId, + baseRevision = acknowledgement.baseRevision, + base = acknowledgement.base, + target = acknowledgement.target, + popCount = 1, + isActiveRequest = { + pendingBack === acknowledgement && !acknowledgement.accepted + }, + acceptRequest = { + if (pendingBack !== acknowledgement || acknowledgement.accepted) { + false + } else { + acknowledgement.accepted = true + true + } + }, + rejectRequest = { + if (pendingBack !== acknowledgement || acknowledgement.accepted) { + false + } else { + clearPendingBack(acknowledgement) + } + }, + abortAcceptedRequest = { + if (pendingBack !== acknowledgement || !acknowledgement.accepted) { + false + } else { + clearPendingBack(acknowledgement) + } + }, + ) + try { + model.onBack(request) + } catch (error: Throwable) { + clearPendingBack(acknowledgement) + throw error + } + } + + private fun settlePendingBack(topology: List) { + val acknowledgement = pendingBack ?: return + when (topology) { + acknowledgement.base -> Unit + acknowledgement.target -> clearPendingBack(acknowledgement) + else -> clearPendingBack(acknowledgement) + } + } + + private fun scheduleBackTimeout(acknowledgement: ComposeBackAcknowledgement) { + pendingBackTimeout?.let(mainHandler::removeCallbacks) + lateinit var timeout: Runnable + timeout = + Runnable { + if (pendingBackTimeout !== timeout) return@Runnable + pendingBackTimeout = null + clearPendingBack(acknowledgement) + } + pendingBackTimeout = timeout + mainHandler.postDelayed(timeout, NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS) + } + + private fun clearPendingBack(expected: ComposeBackAcknowledgement? = null): Boolean { + val current = pendingBack ?: return false + if (expected != null && current !== expected) return false + pendingBack = null + pendingBackTimeout?.let(mainHandler::removeCallbacks) + pendingBackTimeout = null + return true + } +} + +private data class ComposeBackAcknowledgement( + val requestId: Long, + val baseRevision: Long, + val base: List, + val target: List, + var accepted: Boolean = false, +) + +private class ComposeEntryHost( + entry: ResolvedNavigationEntry, + nativeControllerOwner: dev.dimension.flare.ui.FlareNativeControllerOwner?, + subcompositions: dev.dimension.flare.ui.FlareSubcompositionFactory, +) { + val children = AndroidComposeChildren() + private var entry: ResolvedNavigationEntry = entry + private var contentHost: NavigationEntryContentHost? = null + private var contentActive: Boolean = false + private var disposed: Boolean = false + + private var nativeControllerOwner = nativeControllerOwner + private var subcompositions = subcompositions + + fun update( + entry: ResolvedNavigationEntry, + nativeControllerOwner: dev.dimension.flare.ui.FlareNativeControllerOwner?, + subcompositions: dev.dimension.flare.ui.FlareSubcompositionFactory, + ) { + check(!disposed) { "Compose navigation entry host is already disposed." } + require(entry.contentKey == this.entry.contentKey) { + "A Compose navigation entry host cannot change contentKey." + } + val environmentChanged = + this.nativeControllerOwner !== nativeControllerOwner || + this.subcompositions !== subcompositions + val wasActive = contentActive + this.entry = entry + this.nativeControllerOwner = nativeControllerOwner + this.subcompositions = subcompositions + if (environmentChanged) { + releaseContent() + if (wasActive) realize() + } else { + contentHost?.update(entry) + } + } + + fun realize() { + check(!disposed) { "Compose navigation entry host is already disposed." } + val current = contentHost + if (current == null) { + contentHost = + NavigationEntryContentHost( + root = children, + nativeControllerOwner = nativeControllerOwner, + subcompositions = subcompositions, + initialEntry = entry, + ) + contentActive = true + } else if (!contentActive) { + current.activate() + contentActive = true + } + } + + fun prepareSnapshot() { + if (contentHost == null) { + realize() + deactivateContent() + } else if (contentActive) { + deactivateContent() + } + } + + fun deactivateContent() { + if (!contentActive) return + checkNotNull(contentHost).deactivate() + contentActive = false + } + + fun releaseContent() { + contentHost?.dispose() + contentHost = null + contentActive = false + } + + fun dispose() { + if (disposed) return + disposed = true + releaseContent() + } +} + +@Suppress("UNCHECKED_CAST") +private fun shadowEntry( + entry: NavEntry<*>, + content: @Composable () -> Unit, +): NavEntry = + NavEntry( + navEntry = entry as NavEntry, + content = { _: Any -> content() }, + ) + +private fun List.requirePagesOnly(adapter: String) { + firstOrNull { it.presentation != NavigationPresentation.Page }?.let { entry -> + error( + "$adapter navigation currently supports only Page presentation; " + + "contentKey ${entry.contentKey} uses ${entry.presentation}.", + ) + } +} diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/ExperimentalFlareNavigation.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/ExperimentalFlareNavigation.kt new file mode 100644 index 0000000000..af15347e83 --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/ExperimentalFlareNavigation.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.ui.navigation + +/** Marks the first, evolving release of Flare's cross-platform navigation API. */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "Flare Navigation is experimental and may change without notice.", +) +@Retention(AnnotationRetention.BINARY) +public annotation class ExperimentalFlareNavigation diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationBackRequest.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationBackRequest.kt new file mode 100644 index 0000000000..e4a2ad4f26 --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationBackRequest.kt @@ -0,0 +1,113 @@ +@file:OptIn(dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class) + +package dev.dimension.flare.ui.navigation + +/** + * One committed native back request against an exact navigation stack. + * + * The request remains claimable until [accept], [reject], or [applyTo] claims it; its [target] is + * delivered to [NavigationDisplay]; a different topology supersedes it; or the platform + * acknowledgement deadline expires. An accepted request may remain pending while the coordinator + * waits for its target model, but it is no longer [isActive] and cannot be claimed again. A retained + * request may be completed asynchronously, but completion and any associated back-stack mutation + * must run on the host UI thread. + * + * Callers must apply the request only when their authoritative back stack still represents [base]. + * [applyTo] provides that compare-and-remove operation for mutable back stacks. The high-level + * [NavigationDisplay] overload exposes route values here; only the low-level decorated-entry + * overload exposes [NavigationEntryIdentity]. + */ +@ExperimentalFlareNavigation +public class NavigationBackRequest internal constructor( + public val requestId: Long, + public val baseRevision: Long, + public val base: List, + public val target: List, + public val popCount: Int, + private val isActiveRequest: () -> Boolean, + private val acceptRequest: () -> Boolean, + private val rejectRequest: () -> Boolean, + private val abortAcceptedRequest: () -> Boolean, +) { + /** Whether this request is unclaimed and may still be accepted, rejected, or applied. */ + public val isActive: Boolean + get() = isActiveRequest() + + /** + * Claims this request as accepted. + * + * Acceptance does not replace model delivery: [target] must still be delivered to + * [NavigationDisplay]. A successful call makes [isActive] false. Returns false for a stale, + * already accepted, or rejected request. + */ + public fun accept(): Boolean = acceptRequest() + + /** + * Rejects the request and restores the latest declared projection. + * + * Returns false for a stale, accepted, or already rejected request. + */ + public fun reject(): Boolean = rejectRequest() + + /** + * Applies this suffix removal only if [backStack] still exactly equals [base]. + * + * No mutation occurs when the request is stale, was already accepted, or the stack no longer + * matches. This operation also accepts the request, so callers must not call [accept] first. + */ + public fun applyTo(backStack: MutableList): Boolean = applyTo(backStack) { it } + + /** + * Applies this suffix removal after mapping [backStack] values into this request's identity. + * + * This is useful at adapter seams whose mutable stack stores controllers or wrappers. Most + * application callers should use [applyTo] directly with their route back stack. + */ + public fun applyTo( + backStack: MutableList, + identityOf: (K) -> T, + ): Boolean { + if (!isActive) return false + if (backStack.map(identityOf) != base) return false + val proposed = backStack.dropLast(popCount) + if (proposed.map(identityOf) != target) return false + // Claim the one permitted mutation before touching an observable list. The pending request + // remains tracked until model delivery, rejection, supersession, or the deadline. + if (!accept()) return false + + try { + backStack.subList(target.size, backStack.size).clear() + } catch (error: Throwable) { + // Public reject is deliberately unavailable after the request has been claimed. This + // private rollback seam is reachable only from the apply operation that performed the + // claim, so an unsuccessful mutation cannot strand the native projection. + abortAcceptedRequest() + throw error + } + return true + } + + internal fun mapValues( + base: List, + target: List, + ): NavigationBackRequest { + check(base.size - target.size == popCount) { + "A mapped NavigationBackRequest must preserve popCount $popCount." + } + return NavigationBackRequest( + requestId = requestId, + baseRevision = baseRevision, + base = base, + target = target, + popCount = popCount, + isActiveRequest = isActiveRequest, + acceptRequest = acceptRequest, + rejectRequest = rejectRequest, + abortAcceptedRequest = abortAcceptedRequest, + ) + } + + override fun toString(): String = + "NavigationBackRequest(requestId=$requestId, baseRevision=$baseRevision, " + + "base=$base, target=$target, popCount=$popCount, isActive=$isActive)" +} diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationCoordinator.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationCoordinator.kt new file mode 100644 index 0000000000..1d74c15ef7 --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationCoordinator.kt @@ -0,0 +1,556 @@ +@file:OptIn(dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class) + +package dev.dimension.flare.ui.navigation + +internal enum class NavigationCoordinatorState { + Idle, + Executing, + Interacting, + AwaitingAcknowledgement, + Paused, + Disposed, +} + +/** Time-based grace period for an asynchronous reducer to acknowledge a committed native back. */ +internal const val NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS: Long = 5_000L + +/** Platform-independent ordering and acknowledgement state for one NavigationDisplay. */ +internal class NavigationCoordinator( + private val emitCommand: (NavigationCommand) -> Unit, + private val onRetainedEntriesChanged: (List) -> Unit = {}, + private val onOperationDeferred: (NavigationCommand) -> Unit = {}, + private val onOperationFailed: (NavigationCommand, Throwable) -> Unit = { _, _ -> }, + private val onProjectionMismatch: (NavigationProjectionMismatch) -> Unit = {}, + private val onBackMismatch: (NavigationBackMismatch) -> Unit = {}, +) { + private var model: NavigationModel? = null + private var declaredStack: List = emptyList() + private var projectedStack: List = emptyList() + private var stackRevision: Long = 0L + private var nextCommandToken: Long = 0L + private var nextGestureToken: Long = 0L + private var inFlight: NavigationCommand? = null + private var interaction: NavigationInteraction? = null + private var pendingAcknowledgement: PendingNavigationAcknowledgement? = null + private var uncertainProjectionEntries: List = emptyList() + private var projectionDirty: Boolean = false + private var recoveringFailedOperation: Boolean = false + private var operationsPaused: Boolean = false + private var disposed: Boolean = false + + val state: NavigationCoordinatorState + get() = + when { + disposed -> NavigationCoordinatorState.Disposed + inFlight != null -> NavigationCoordinatorState.Executing + interaction != null -> NavigationCoordinatorState.Interacting + pendingAcknowledgement != null -> NavigationCoordinatorState.AwaitingAcknowledgement + operationsPaused -> NavigationCoordinatorState.Paused + else -> NavigationCoordinatorState.Idle + } + + internal val declaredEntries: List + get() = declaredStack + + internal val projectedEntries: List + get() = projectedStack + + /** Whether an adapter still needs to keep its acknowledgement deadline scheduled. */ + internal val hasPendingAcknowledgement: Boolean + get() = pendingAcknowledgement != null + + fun setModel(value: NavigationModel) { + check(!disposed) { "NavigationCoordinator is already disposed." } + validateStablePresentations( + previous = retainedEntriesForPresentationValidation(), + current = value.entries, + ) + + val topologyChanged = !declaredStack.hasSameTopologyAs(value.entries) + model = value + declaredStack = value.entries + if (topologyChanged) stackRevision += 1L + + projectedStack = projectedStack.rebindCommonPrefixFrom(declaredStack) + publishRetainedEntries() + + val acknowledgement = pendingAcknowledgement + if (acknowledgement != null) { + when { + declaredStack.hasSameTopologyAs(acknowledgement.target) -> { + pendingAcknowledgement = null + projectedStack = declaredStack + publishRetainedEntries() + } + + declaredStack.hasSameTopologyAs(acknowledgement.base) -> { + return + } + + else -> { + pendingAcknowledgement = null + projectionDirty = true + onBackMismatch( + acknowledgement.toMismatch( + actual = declaredStack, + reason = NavigationBackMismatchReason.ModelMismatch, + ), + ) + } + } + } + + if (interaction != null || inFlight != null || operationsPaused) return + reconcile() + } + + /** Completes the current programmatic native operation; stale delegate callbacks are ignored. */ + fun completeCommand( + token: Long, + result: NavigationOperationResult, + ): Boolean { + if (disposed) return false + val command = inFlight ?: return false + if (command.token != token) return false + inFlight = null + + when (result) { + is NavigationOperationResult.Succeeded -> { + val expectedTopology = command.operation.targetStack.topology() + val observedTopology = result.observedTopology ?: expectedTopology + if (observedTopology == expectedTopology) { + projectedStack = command.operation.targetStack.rebindCommonPrefixFrom(declaredStack) + if (command.operation is NavigationOperation.Reconstruct) { + projectionDirty = false + recoveringFailedOperation = false + uncertainProjectionEntries = emptyList() + } + } else { + val observedProjection = + resolveObservedProjection( + observedTopology = observedTopology, + command = command, + ) + if (observedProjection != null) { + projectedStack = observedProjection + if (command.operation is NavigationOperation.Reconstruct) { + uncertainProjectionEntries = emptyList() + } + } else { + retainUncertainProjection(command) + } + projectionDirty = true + // A topology mismatch means the native operation completed without proving + // its requested projection. Give it one authoritative reconstruction, but + // bound repeated synchronous mismatches just like repeated native failures. + // Without this guard, an adapter that consistently reports a different + // topology can recurse through emitCommand/completeCommand until the stack + // overflows on the UI thread. + operationsPaused = command.operation is NavigationOperation.Reconstruct && recoveringFailedOperation + recoveringFailedOperation = true + onProjectionMismatch( + NavigationProjectionMismatch( + command = command, + expected = expectedTopology, + observed = observedTopology, + ), + ) + } + publishRetainedEntries() + reconcile() + } + + NavigationOperationResult.Deferred -> { + operationsPaused = true + publishRetainedEntries() + onOperationDeferred(command) + } + + is NavigationOperationResult.Failed -> { + retainUncertainProjection(command) + projectionDirty = true + // A failed native operation leaves the physical projection uncertain. Recover + // immediately with one authoritative, non-animated reconstruction instead of + // waiting indefinitely for a platform lifecycle event that may never arrive. + // Bound the automatic recovery at one attempt. The flag tracks the recovery + // cycle rather than the operation type so an initial or model-driven + // reconstruction also receives one retry. If that recovery attempt fails, pause + // until the adapter reports that native operations are safe again. + operationsPaused = recoveringFailedOperation + recoveringFailedOperation = true + publishRetainedEntries() + try { + onOperationFailed(command, result.cause) + } finally { + if (!operationsPaused) reconcile() + } + } + } + return true + } + + /** Resumes reconciliation after the platform reports that native operations are safe again. */ + fun resumeOperations(): Boolean { + if (disposed || !operationsPaused) return false + operationsPaused = false + reconcile() + return true + } + + /** Returns whether a native back interaction can begin, without changing coordinator state. */ + fun canBeginUserBack(popCount: Int = 1): Boolean { + require(popCount > 0) { "A navigation back interaction must pop at least one entry." } + if (state != NavigationCoordinatorState.Idle) return false + if (projectionDirty) return false + if (!projectedStack.hasSameTopologyAs(declaredStack)) return false + return projectedStack.size - popCount >= 1 + } + + /** Begins a user-owned native back interaction without changing the declared model. */ + fun beginUserBack(popCount: Int = 1): NavigationInteractionHandle? { + if (!canBeginUserBack(popCount)) return null + + val handle = NavigationInteractionHandle(nextGestureToken++) + interaction = + NavigationInteraction( + handle = handle, + revision = stackRevision, + base = projectedStack, + target = projectedStack.dropLast(popCount), + popCount = popCount, + ) + return handle + } + + /** Reports that the active user interaction returned to its original projection. */ + fun cancelUserBack(handle: NavigationInteractionHandle): Boolean { + if (disposed || interaction?.handle != handle) return false + interaction = null + reconcile() + return true + } + + /** + * Reports that an adapter could not safely finish or restore an active native interaction. + * + * The physical projection is now uncertain, so the next operation is one authoritative + * reconstruction. If that recovery also fails, normal failed-operation handling pauses until a + * later platform retry opportunity. + */ + fun failUserBack(handle: NavigationInteractionHandle): Boolean { + if (disposed) return false + val failedInteraction = interaction?.takeIf { it.handle == handle } ?: return false + interaction = null + uncertainProjectionEntries = + mergeEntriesByIdentity( + uncertainProjectionEntries + failedInteraction.base + failedInteraction.target, + ) + projectionDirty = true + recoveringFailedOperation = true + publishRetainedEntries() + reconcile() + return true + } + + /** + * Reports a committed native interaction. + * + * A non-null result means the coordinator is waiting for a later exact target model or explicit + * rejection. Re-delivering the unchanged base keeps a controlled request pending. The adapter + * must eventually pass the returned handle to [acknowledgementDeadlineReached] if the request is + * unresolved. A concurrent matching model update or synchronous rejection returns null. + */ + fun commitUserBack(handle: NavigationInteractionHandle): NavigationAcknowledgementHandle? { + if (disposed) return null + val committedInteraction = interaction?.takeIf { it.handle == handle } ?: return null + interaction = null + projectedStack = committedInteraction.target.rebindCommonPrefixFrom(declaredStack) + publishRetainedEntries() + + if (stackRevision != committedInteraction.revision) { + if (declaredStack.hasSameTopologyAs(committedInteraction.target)) { + projectedStack = declaredStack + publishRetainedEntries() + } else { + projectionDirty = true + } + reconcile() + return null + } + + val acknowledgementHandle = NavigationAcknowledgementHandle(committedInteraction.handle.token) + val acknowledgement = + PendingNavigationAcknowledgement( + handle = acknowledgementHandle, + baseRevision = committedInteraction.revision, + base = committedInteraction.base, + target = committedInteraction.target, + popCount = committedInteraction.popCount, + ) + pendingAcknowledgement = acknowledgement + val request = + NavigationBackRequest( + requestId = acknowledgementHandle.token, + baseRevision = acknowledgement.baseRevision, + base = acknowledgement.base.topology(), + target = acknowledgement.target.topology(), + popCount = acknowledgement.popCount, + isActiveRequest = { isBackRequestActive(acknowledgementHandle) }, + acceptRequest = { acceptBackRequest(acknowledgementHandle) }, + rejectRequest = { rejectBackRequest(acknowledgementHandle) }, + abortAcceptedRequest = { abortAcceptedBackRequest(acknowledgementHandle) }, + ) + try { + checkNotNull(model).onBack(request) + } catch (error: Throwable) { + pendingAcknowledgement = null + projectionDirty = true + reconcile() + throw error + } + return pendingAcknowledgement?.handle + } + + /** + * Expires a pending native back and restores the latest declaration. + * + * The request carries its own identity and exact base/target topology, so expiry can close it + * permanently without quarantining an unrelated future interaction. + */ + fun acknowledgementDeadlineReached(handle: NavigationAcknowledgementHandle): Boolean { + if (disposed) return false + val acknowledgement = pendingAcknowledgement?.takeIf { it.handle == handle } ?: return false + pendingAcknowledgement = null + projectionDirty = true + onBackMismatch( + acknowledgement.toMismatch( + actual = declaredStack, + reason = NavigationBackMismatchReason.DeadlineReached, + ), + ) + reconcile() + return true + } + + private fun isBackRequestActive(handle: NavigationAcknowledgementHandle): Boolean = + !disposed && + pendingAcknowledgement?.let { acknowledgement -> + acknowledgement.handle == handle && !acknowledgement.accepted + } == true + + private fun acceptBackRequest(handle: NavigationAcknowledgementHandle): Boolean { + if (disposed) return false + val acknowledgement = pendingAcknowledgement?.takeIf { it.handle == handle } ?: return false + if (acknowledgement.accepted) return false + pendingAcknowledgement = acknowledgement.copy(accepted = true) + return true + } + + private fun rejectBackRequest(handle: NavigationAcknowledgementHandle): Boolean { + if (disposed) return false + val acknowledgement = pendingAcknowledgement?.takeIf { it.handle == handle } ?: return false + if (acknowledgement.accepted) return false + restoreAfterRejectedBack(acknowledgement) + return true + } + + private fun abortAcceptedBackRequest(handle: NavigationAcknowledgementHandle): Boolean { + if (disposed) return false + val acknowledgement = pendingAcknowledgement?.takeIf { it.handle == handle } ?: return false + if (!acknowledgement.accepted) return false + restoreAfterRejectedBack(acknowledgement) + return true + } + + private fun restoreAfterRejectedBack(acknowledgement: PendingNavigationAcknowledgement) { + pendingAcknowledgement = null + projectionDirty = true + onBackMismatch( + acknowledgement.toMismatch( + actual = declaredStack, + reason = NavigationBackMismatchReason.ExplicitlyRejected, + ), + ) + reconcile() + } + + fun dispose() { + if (disposed) return + disposed = true + model = null + declaredStack = emptyList() + projectedStack = emptyList() + inFlight = null + interaction = null + pendingAcknowledgement = null + uncertainProjectionEntries = emptyList() + projectionDirty = false + recoveringFailedOperation = false + operationsPaused = false + onRetainedEntriesChanged(emptyList()) + } + + private fun reconcile() { + if (disposed || model == null || inFlight != null || interaction != null || + pendingAcknowledgement != null || operationsPaused + ) { + return + } + if (!projectionDirty && projectedStack.hasSameTopologyAs(declaredStack)) { + projectedStack = declaredStack + publishRetainedEntries() + return + } + + val operation = + checkNotNull( + calculateNextNavigationOperation( + projectedStack = projectedStack, + declaredStack = declaredStack, + forceReconstruction = projectionDirty, + ), + ) { + "Navigation reconciliation expected an operation for different topologies." + } + val command = + NavigationCommand( + token = nextCommandToken++, + sourceStack = projectedStack, + operation = operation, + ) + inFlight = command + publishRetainedEntries() + try { + emitCommand(command) + } catch (error: Throwable) { + if (inFlight?.token == command.token) { + completeCommand(command.token, NavigationOperationResult.Failed(error)) + } + } + } + + private fun publishRetainedEntries() { + val latestByIdentity = declaredStack.associateBy(ResolvedNavigationEntry::identity) + val retainedByIdentity = linkedMapOf() + val candidates = + projectedStack + + uncertainProjectionEntries + + (inFlight?.operation?.targetStack ?: emptyList()) + candidates.forEach { entry -> + val identity = entry.identity() + retainedByIdentity[identity] = latestByIdentity[identity] ?: entry + } + onRetainedEntriesChanged(retainedByIdentity.values.toList()) + } + + private fun retainedEntriesForPresentationValidation(): List = + mergeEntriesByIdentity( + declaredStack + + projectedStack + + uncertainProjectionEntries + + (inFlight?.operation?.targetStack ?: emptyList()), + ) + + private fun retainUncertainProjection(command: NavigationCommand) { + uncertainProjectionEntries = + mergeEntriesByIdentity( + uncertainProjectionEntries + command.sourceStack + command.operation.targetStack, + ) + } + + private fun resolveObservedProjection( + observedTopology: List, + command: NavigationCommand, + ): List? { + val candidatesByIdentity = + ( + projectedStack + + uncertainProjectionEntries + + command.sourceStack + + command.operation.targetStack + + declaredStack + ).associateBy(ResolvedNavigationEntry::identity) + val observed = + observedTopology.map { identity -> + candidatesByIdentity[identity] ?: return null + } + if (observed.isEmpty()) return observed + if (observed.first().presentation != NavigationPresentation.Page) return null + val contentKeys = mutableSetOf() + var foundOverlay = false + observed.forEach { entry -> + if (!contentKeys.add(entry.contentKey)) return null + if (entry.presentation == NavigationPresentation.Page) { + if (foundOverlay) return null + } else { + foundOverlay = true + } + } + return observed + } +} + +private fun mergeEntriesByIdentity(entries: List): List { + val result = linkedMapOf() + entries.forEach { entry -> result[entry.identity()] = entry } + return result.values.toList() +} + +internal data class NavigationInteractionHandle( + val token: Long, +) + +internal data class NavigationAcknowledgementHandle( + val token: Long, +) + +internal enum class NavigationBackMismatchReason { + DeadlineReached, + ExplicitlyRejected, + ModelMismatch, +} + +internal data class NavigationBackMismatch( + val token: Long, + val base: List, + val expected: List, + val actual: List, + val popCount: Int, + val reason: NavigationBackMismatchReason, +) + +internal data class NavigationProjectionMismatch( + val command: NavigationCommand, + val expected: List, + val observed: List, +) + +private data class NavigationInteraction( + val handle: NavigationInteractionHandle, + val revision: Long, + val base: List, + val target: List, + val popCount: Int, +) + +private data class PendingNavigationAcknowledgement( + val handle: NavigationAcknowledgementHandle, + val baseRevision: Long, + val base: List, + val target: List, + val popCount: Int, + val accepted: Boolean = false, +) { + fun toMismatch( + actual: List, + reason: NavigationBackMismatchReason, + ): NavigationBackMismatch = + NavigationBackMismatch( + token = handle.token, + base = base, + expected = target, + actual = actual, + popCount = popCount, + reason = reason, + ) +} diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationDisplay.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationDisplay.kt new file mode 100644 index 0000000000..f3049a9d14 --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationDisplay.kt @@ -0,0 +1,123 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.currentFlareNativeControllerOwner +import dev.dimension.flare.ui.rememberFlareSubcompositionFactory + +/** + * Displays a Navigation3 back stack through the active Flare renderer. + * + * [backStack] remains the only business state. A committed native back interaction delivers one + * exact, revisioned [NavigationBackRequest] through [onBack]; a cancelled interaction does not call + * it. The caller should compare and remove the requested suffix with [NavigationBackRequest.applyTo] + * or otherwise update its model to the request's exact target. A request may be retained for an + * asynchronous reducer, then explicitly accepted or rejected. After rejection, a different model, + * or the bounded platform deadline, the request becomes stale and can no longer mutate the stack. + * + * The stack must be non-empty and have the shape `[Page+][Overlay*]`. Every active + * [NavEntry.contentKey] must be unique and stable, and an active key cannot change presentation in + * place. The current renderer set supports Page only and rejects the reserved overlay values. + * Stateful [entryDecorators] must be remembered or otherwise hoisted for the lifetime of + * this back stack; their order is observable. The default decorator preserves saveable entry + * state only within the current saveable host and does not provide process or native-controller + * restoration. + * + * Entry and decorator content must emit only Flare widgets. Using another Compose applier can fail + * at runtime because [NavEntry] does not retain its composable target in its public type. + * + * @throws IllegalArgumentException if the stack shape, content keys, or Flare presentation + * metadata are invalid. + */ +@ExperimentalFlareNavigation +@Composable +@FlareUiComposable +public fun NavigationDisplay( + backStack: List, + modifier: FlareModifier = FlareModifier.None.fillMaxSize(), + onBack: (NavigationBackRequest) -> Unit, + entryDecorators: List> = + listOf(rememberSaveableStateHolderNavEntryDecorator()), + entryProvider: (K) -> NavEntry, +) { + NavigationDisplay( + entries = + rememberDecoratedNavEntries( + backStack = backStack, + entryDecorators = entryDecorators, + entryProvider = entryProvider, + ), + modifier = modifier, + onBack = { request -> + check(request.base.size == backStack.size) { + "A route NavigationBackRequest must match the current back stack." + } + onBack( + request.mapValues( + base = backStack.toList(), + target = backStack.dropLast(request.popCount), + ), + ) + }, + ) +} + +/** + * Displays an already decorated Navigation3 entry list through the active Flare renderer. + * + * This overload never adds or reapplies decorators. [entries] must be non-empty and have the shape + * `[Page+][Overlay*]`. Every active entry must have a unique, stable [NavEntry.contentKey], and an + * active key cannot change presentation in place. The current renderer set supports Page only and + * rejects the reserved overlay values. Entry and decorator content must emit only Flare widgets. + * + * A committed native back interaction invokes [onBack] once with an exact, revisioned + * [NavigationBackRequest]. Re-delivering the unchanged topology keeps a deferred request pending; + * delivering its target accepts it, while a different topology safely supersedes it. Rejection or + * the bounded platform deadline restores the latest declared projection without quarantining future + * back interactions. This overload provides saveable entry state only when the supplied entries + * already contain an equivalent decorator. + * + * @throws IllegalArgumentException if the stack shape, content keys, or Flare presentation + * metadata are invalid. + */ +@ExperimentalFlareNavigation +@Composable +@FlareUiComposable +public fun NavigationDisplay( + entries: List>, + modifier: FlareModifier = FlareModifier.None.fillMaxSize(), + onBack: (NavigationBackRequest) -> Unit, +) { + val dispatcher = remember { NavigationModelDispatcher() } + val deliveryScope = rememberCoroutineScope() + val model = + NavigationModel( + entries = resolveNavigationEntries(entries), + onBack = onBack, + subcompositions = rememberFlareSubcompositionFactory(), + nativeControllerOwner = currentFlareNativeControllerOwner(), + ) + SideEffect { + // Scheduling yields before invoking the renderer, so no child composition can be created + // from the parent's applyChanges call stack. + dispatcher.stage(model, deliveryScope) + } + EmitFlareWidget( + componentType = NavigationWidget::class, + modifier = modifier, + update = { + set(dispatcher, NavigationWidget::setModelDispatcher) + }, + ) +} diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationEntryContentHost.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationEntryContentHost.kt new file mode 100644 index 0000000000..c7ef3db2ec --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationEntryContentHost.kt @@ -0,0 +1,73 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, +) + +package dev.dimension.flare.ui.navigation + +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.ProvideFlareNativeControllerOwner + +/** Owns the independently disposable Flare content rendered by one native entry controller. */ +internal class NavigationEntryContentHost( + root: FlareChildren, + private val nativeControllerOwner: FlareNativeControllerOwner?, + subcompositions: FlareSubcompositionFactory, + initialEntry: ResolvedNavigationEntry, +) { + private val composition: FlareSubcomposition = subcompositions.create(root) + private var disposed: Boolean = false + private var active: Boolean = false + private var installedEntry: NavEntry<*>? = null + + var entry: ResolvedNavigationEntry = initialEntry + private set + + init { + activate() + } + + fun update(value: ResolvedNavigationEntry) { + check(!disposed) { "Navigation entry content host is already disposed." } + require(value.identity() == entry.identity()) { + "A navigation entry content host cannot change identity." + } + entry = value + if (active) install(value) + } + + fun activate() { + check(!disposed) { "Navigation entry content host is already disposed." } + if (active) return + active = true + install(entry) + } + + fun deactivate() { + check(!disposed) { "Navigation entry content host is already disposed." } + if (!active) return + active = false + installedEntry = null + composition.deactivate() + } + + private fun install(value: ResolvedNavigationEntry) { + if (installedEntry === value.entry) return + installedEntry = value.entry + composition.setContent { + ProvideFlareNativeControllerOwner(nativeControllerOwner) { + value.entry.Content() + } + } + } + + fun dispose() { + if (disposed) return + disposed = true + composition.dispose() + } +} diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationModel.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationModel.kt new file mode 100644 index 0000000000..67985626d7 --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationModel.kt @@ -0,0 +1,199 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, +) + +package dev.dimension.flare.ui.navigation + +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield + +/** Atomic model delivered to one renderer adapter. */ +internal class NavigationModel( + val entries: List, + val onBack: (NavigationBackRequest) -> Unit, + val subcompositions: FlareSubcompositionFactory, + val nativeControllerOwner: FlareNativeControllerOwner? = null, +) + +/** Renderer seam implemented by the platform navigation adapters. */ +internal interface NavigationWidget : FlareWidget { + fun setModelDispatcher(dispatcher: NavigationModelDispatcher) +} + +/** + * Conflates staged models and delivers only the latest one after parent applyChanges has finished. + * + * Direct [dispatch] remains available for adapter tests and controlled non-Compose hosts. Production + * [NavigationDisplay] uses [stage] and one finite scheduled delivery, so recomposition cannot resume + * an older per-model effect after a newer model has already been staged. + */ +internal class NavigationModelDispatcher { + private var observer: ((NavigationModel) -> Unit)? = null + private var stagedModel: NavigationModel? = null + private var stagedRevision: Long = 0L + private var deliveredRevision: Long = 0L + private var deliveryScope: CoroutineScope? = null + private var deliveryJob: Job? = null + + /** True while a newer staged model has not reached the current renderer observer. */ + val hasUndeliveredModel: Boolean + get() = stagedModel != null && deliveredRevision != stagedRevision + + fun observe(value: (NavigationModel) -> Unit): () -> Unit { + check(observer == null) { "A NavigationModelDispatcher already has a renderer observer." } + observer = value + // A replacement renderer must receive the latest model even if its predecessor did. + deliveredRevision = -1L + scheduleDelivery() + return { + if (observer === value) observer = null + } + } + + fun stage( + model: NavigationModel, + scope: CoroutineScope, + ) { + stagedModel = model + stagedRevision += 1L + deliveryScope = scope + scheduleDelivery() + } + + private fun scheduleDelivery() { + val scope = deliveryScope ?: return + if (deliveryJob?.isActive == true || !hasUndeliveredModel) return + deliveryJob = + scope.launch { + do { + // This first suspension keeps renderer work outside applyChanges even when the + // scope uses an immediate UI dispatcher. + yield() + val revision = stagedRevision + dispatchLatestStagedModel() + } while (stagedRevision != revision) + } + } + + fun dispatch(model: NavigationModel) { + observer?.invoke(model) + } + + private fun dispatchLatestStagedModel() { + val currentObserver = observer ?: return + if (deliveredRevision == stagedRevision) return + val currentModel = stagedModel ?: return + val currentRevision = stagedRevision + currentObserver(currentModel) + deliveredRevision = currentRevision + } +} + +/** Validated entry data shared by every renderer adapter. */ +internal data class ResolvedNavigationEntry( + val contentKey: Any, + val presentation: NavigationPresentation, + val entry: NavEntry<*>, +) + +internal fun resolveNavigationEntries(entries: List>): List { + require(entries.isNotEmpty()) { "A navigation stack cannot be empty." } + + val indicesByContentKey = mutableMapOf() + var foundOverlay = false + val resolved = + entries.mapIndexed { index, entry -> + val presentation = entry.navigationPresentation() + val previousIndex = indicesByContentKey.put(entry.contentKey, index) + require(previousIndex == null) { + "Navigation contentKey ${entry.contentKey} occurs at both index " + + "$previousIndex and $index." + } + if (presentation == NavigationPresentation.Page) { + require(!foundOverlay) { + "Navigation Page at index $index appears after an overlay entry." + } + } else { + foundOverlay = true + } + ResolvedNavigationEntry( + contentKey = entry.contentKey, + presentation = presentation, + entry = entry, + ) + } + + require(resolved.first().presentation == NavigationPresentation.Page) { + "The first navigation entry must use Page presentation." + } + return resolved +} + +private fun NavEntry<*>.navigationPresentation(): NavigationPresentation { + val metadataKey = NavigationPresentationMetadata.toString() + if (!metadata.containsKey(metadataKey)) return NavigationPresentation.Page + val value = metadata[metadataKey] + require(value is NavigationPresentation) { + "Navigation metadata $metadataKey must contain a NavigationPresentation, " + + "but was ${value?.let { it::class.simpleName } ?: "null"}." + } + return value +} + +internal fun validateStablePresentations( + previous: List, + current: List, +) { + if (previous.isEmpty()) return + val previousByKey = previous.groupBy(ResolvedNavigationEntry::contentKey) + current.forEach { entry -> + val oldEntries = previousByKey[entry.contentKey] ?: return@forEach + val oldPresentation = oldEntries.first().presentation + require(oldEntries.all { it.presentation == oldPresentation }) { + "Navigation contentKey ${entry.contentKey} is still retained with multiple presentations." + } + require(oldPresentation == entry.presentation) { + "Navigation contentKey ${entry.contentKey} changed presentation from " + + "$oldPresentation to ${entry.presentation}. Wait until its previous native " + + "incarnation has been released, or use a new contentKey." + } + } +} + +internal fun List.hasSameTopologyAs(other: List): Boolean = + size == other.size && + indices.all { index -> + this[index].contentKey == other[index].contentKey && + this[index].presentation == other[index].presentation + } + +internal fun List.isTopologyPrefixOf(other: List): Boolean = + size <= other.size && + indices.all { index -> + this[index].contentKey == other[index].contentKey && + this[index].presentation == other[index].presentation + } + +internal fun List.rebindCommonPrefixFrom(current: List): List { + if (isEmpty() || current.isEmpty()) return this + var prefixSize = 0 + val limit = minOf(size, current.size) + while (prefixSize < limit && + this[prefixSize].contentKey == current[prefixSize].contentKey && + this[prefixSize].presentation == current[prefixSize].presentation + ) { + prefixSize += 1 + } + if (prefixSize == 0) return this + return buildList(size) { + addAll(current.take(prefixSize)) + addAll(this@rebindCommonPrefixFrom.drop(prefixSize)) + } +} diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationPlan.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationPlan.kt new file mode 100644 index 0000000000..629d6e3c6d --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationPlan.kt @@ -0,0 +1,167 @@ +@file:OptIn(dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class) + +package dev.dimension.flare.ui.navigation + +internal sealed interface NavigationOperation { + val targetStack: List + val animated: Boolean + + data class PushPage( + val entry: ResolvedNavigationEntry, + override val targetStack: List, + ) : NavigationOperation { + override val animated: Boolean = true + } + + data class PopPage( + val entry: ResolvedNavigationEntry, + override val targetStack: List, + ) : NavigationOperation { + override val animated: Boolean = true + } + + data class PresentOverlay( + val entry: ResolvedNavigationEntry, + override val targetStack: List, + ) : NavigationOperation { + override val animated: Boolean = true + } + + data class DismissOverlay( + val entry: ResolvedNavigationEntry, + override val targetStack: List, + ) : NavigationOperation { + override val animated: Boolean = true + } + + data class Reconstruct( + override val targetStack: List, + ) : NavigationOperation { + override val animated: Boolean = false + } +} + +internal fun calculateNavigationPlan( + projectedStack: List, + declaredStack: List, + forceReconstruction: Boolean = false, +): List { + if (forceReconstruction) { + return listOf(NavigationOperation.Reconstruct(declaredStack.toList())) + } + if (projectedStack.hasSameTopologyAs(declaredStack)) return emptyList() + if (projectedStack.isEmpty()) { + return listOf(NavigationOperation.Reconstruct(declaredStack.toList())) + } + + if (projectedStack.isTopologyPrefixOf(declaredStack)) { + if (declaredStack.size - projectedStack.size != 1) { + return listOf(NavigationOperation.Reconstruct(declaredStack.toList())) + } + val entry = declaredStack.last() + return listOf( + if (entry.presentation == NavigationPresentation.Page) { + NavigationOperation.PushPage(entry, declaredStack.toList()) + } else { + NavigationOperation.PresentOverlay(entry, declaredStack.toList()) + }, + ) + } + if (declaredStack.isTopologyPrefixOf(projectedStack)) { + if (projectedStack.size - declaredStack.size != 1) { + return listOf(NavigationOperation.Reconstruct(declaredStack.toList())) + } + val reboundProjection = projectedStack.rebindCommonPrefixFrom(declaredStack) + val entry = reboundProjection.last() + return listOf( + if (entry.presentation == NavigationPresentation.Page) { + NavigationOperation.PopPage(entry, declaredStack.toList()) + } else { + NavigationOperation.DismissOverlay(entry, declaredStack.toList()) + }, + ) + } + return listOf(NavigationOperation.Reconstruct(declaredStack.toList())) +} + +/** + * Calculates only the next serialized platform operation. + * + * The coordinator cannot execute more than one native operation at a time. Building every suffix + * operation eagerly copies progressively larger target lists that are discarded before the next + * reconciliation. Keeping this helper separate from [calculateNavigationPlan] preserves the full + * planner for focused tests while making the production path proportional to the next target. + */ +internal fun calculateNextNavigationOperation( + projectedStack: List, + declaredStack: List, + forceReconstruction: Boolean = false, +): NavigationOperation? { + if (forceReconstruction) { + return NavigationOperation.Reconstruct(declaredStack.toList()) + } + if (projectedStack.hasSameTopologyAs(declaredStack)) return null + if (projectedStack.isEmpty()) { + return NavigationOperation.Reconstruct(declaredStack.toList()) + } + + if (projectedStack.isTopologyPrefixOf(declaredStack)) { + if (declaredStack.size - projectedStack.size != 1) { + return NavigationOperation.Reconstruct(declaredStack.toList()) + } + val entry = declaredStack.last() + return if (entry.presentation == NavigationPresentation.Page) { + NavigationOperation.PushPage(entry, declaredStack.toList()) + } else { + NavigationOperation.PresentOverlay(entry, declaredStack.toList()) + } + } + if (declaredStack.isTopologyPrefixOf(projectedStack)) { + if (projectedStack.size - declaredStack.size != 1) { + return NavigationOperation.Reconstruct(declaredStack.toList()) + } + val entry = projectedStack.last() + return if (entry.presentation == NavigationPresentation.Page) { + NavigationOperation.PopPage(entry, declaredStack.toList()) + } else { + NavigationOperation.DismissOverlay(entry, declaredStack.toList()) + } + } + return NavigationOperation.Reconstruct(declaredStack.toList()) +} + +internal data class NavigationCommand( + val token: Long, + val sourceStack: List, + val operation: NavigationOperation, +) + +internal sealed interface NavigationOperationResult { + /** Null means the adapter observed exactly the operation's requested target. */ + data class Succeeded( + val observedTopology: List? = null, + ) : NavigationOperationResult + + /** The platform did not start this operation and its projection is still unchanged. */ + data object Deferred : NavigationOperationResult + + /** The operation may have partially changed the platform projection. */ + data class Failed( + val cause: Throwable, + ) : NavigationOperationResult +} + +/** Stable native projection identity for one active navigation entry. */ +@ExperimentalFlareNavigation +public data class NavigationEntryIdentity( + val contentKey: Any, + val presentation: NavigationPresentation, +) + +internal fun ResolvedNavigationEntry.identity(): NavigationEntryIdentity = + NavigationEntryIdentity( + contentKey = contentKey, + presentation = presentation, + ) + +internal fun List.topology(): List = map(ResolvedNavigationEntry::identity) diff --git a/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationPresentation.kt b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationPresentation.kt new file mode 100644 index 0000000000..7d89a78439 --- /dev/null +++ b/flareUI/navigation/src/commonMain/kotlin/dev/dimension/flare/ui/navigation/NavigationPresentation.kt @@ -0,0 +1,24 @@ +package dev.dimension.flare.ui.navigation + +import androidx.navigation3.runtime.NavMetadataKey + +/** + * Cross-platform presentation intent interpreted by the active renderer. + * + * The current renderer set supports [Page] only. The overlay values are reserved for the planned + * native presentation chain and fail fast when passed to any currently shipped adapter; callers + * must not use them as a capability signal. + */ +@ExperimentalFlareNavigation +public enum class NavigationPresentation { + Page, + Dialog, + Sheet, + Fullscreen, +} + +/** Navigation3 metadata key used to select a [NavigationPresentation]. */ +@ExperimentalFlareNavigation +public object NavigationPresentationMetadata : NavMetadataKey { + override fun toString(): String = "dev.dimension.flare.ui.navigation.presentation" +} diff --git a/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationCoordinatorTest.kt b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationCoordinatorTest.kt new file mode 100644 index 0000000000..dff30bda25 --- /dev/null +++ b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationCoordinatorTest.kt @@ -0,0 +1,1152 @@ +@file:OptIn( + ExperimentalFlareNavigation::class, + dev.dimension.flare.ui.LowLevelFlareApi::class, +) + +package dev.dimension.flare.ui.navigation + +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +public class NavigationCoordinatorTest { + @Test + public fun rebindsRetainedEntriesWithoutEmittingAPlatformCommand() { + val commands = mutableListOf() + val retainedSnapshots = mutableListOf>() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onRetainedEntriesChanged = { retainedSnapshots += it }, + ) + val initialEntry = entry("home") + coordinator.setModel(model(initialEntry)) + completeLatest(coordinator, commands) + val commandCount = commands.size + + val updatedEntry = entry("home") + coordinator.setModel(model(updatedEntry)) + + assertEquals(commandCount, commands.size) + assertSame(updatedEntry, retainedSnapshots.last().single().entry) + assertSame(updatedEntry, coordinator.projectedEntries.single().entry) + } + + @Test + public fun rebindsAnEnteringEntryWhileItsCommandIsInFlight() { + val commands = mutableListOf() + val retainedSnapshots = mutableListOf>() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onRetainedEntriesChanged = { retainedSnapshots += it }, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail")) + + val updatedHome = entry("home") + val updatedDetail = entry("detail") + coordinator.setModel(model(updatedHome, updatedDetail)) + + assertEquals(listOf("home", "detail"), retainedSnapshots.last().contentKeys()) + assertSame(updatedHome, retainedSnapshots.last()[0].entry) + assertSame(updatedDetail, retainedSnapshots.last()[1].entry) + } + + @Test + public fun disposeReleasesPreparedEntriesAndIgnoresTheirCompletion() { + val commands = mutableListOf() + val retainedSnapshots = mutableListOf>() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onRetainedEntriesChanged = { retainedSnapshots += it }, + ) + coordinator.setModel(model("home")) + val initial = commands.single() + assertEquals(listOf("home"), retainedSnapshots.last().contentKeys()) + + coordinator.dispose() + + assertEquals(emptyList(), retainedSnapshots.last()) + assertEquals(NavigationCoordinatorState.Disposed, coordinator.state) + assertFalse(coordinator.completeCommand(initial.token, NavigationOperationResult.Succeeded())) + } + + @Test + public fun committedUserBackUsesTheLatestModelCallback() { + val commands = mutableListOf() + var staleCalls = 0 + var latestCalls = 0 + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail", onBack = { staleCalls += it })) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail", onBack = { latestCalls += it })) + + val interaction = beginBack(coordinator) + assertNotNull(coordinator.commitUserBack(interaction)) + + assertEquals(0, staleCalls) + assertEquals(1, latestCalls) + } + + @Test + public fun synchronousBackAcknowledgementReturnsNoPendingHandle() { + val commands = mutableListOf() + lateinit var coordinator: NavigationCoordinator + coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel( + model( + "home", + "detail", + onBack = { coordinator.setModel(model("home")) }, + ), + ) + completeLatest(coordinator, commands) + val commandCount = commands.size + + assertNull(coordinator.commitUserBack(beginBack(coordinator))) + + assertFalse(coordinator.hasPendingAcknowledgement) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertEquals(commandCount, commands.size) + } + + @Test + public fun synchronousBackMismatchReconstructsWithoutQuarantiningFutureBack() { + val commands = mutableListOf() + lateinit var coordinator: NavigationCoordinator + coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel( + model( + "home", + "detail", + onBack = { coordinator.setModel(model("home", "replacement")) }, + ), + ) + completeLatest(coordinator, commands) + + assertNull(coordinator.commitUserBack(beginBack(coordinator))) + + assertFalse(coordinator.hasPendingAcknowledgement) + assertIs(commands.last().operation) + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertTrue(coordinator.canBeginUserBack()) + } + + @Test + public fun serializesCommandsAndReconcilesToTheLatestModel() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + + coordinator.setModel(model("home", "detail")) + assertIs(commands.last().operation) + + coordinator.setModel(model("home", "replacement")) + assertEquals(2, commands.size) + completeLatest(coordinator, commands) + + assertIs(commands.last().operation) + assertEquals( + listOf("home", "replacement"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + } + + @Test + public fun failedOperationAutomaticallyReconstructsLatestDeclaration() { + val commands = mutableListOf() + val failures = mutableListOf() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onOperationFailed = { _, error -> failures += error }, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + + coordinator.setModel(model("home", "detail")) + val failedCommand = commands.last() + coordinator.setModel(model("home", "replacement")) + coordinator.completeCommand( + failedCommand.token, + NavigationOperationResult.Failed(IllegalStateException("state saved")), + ) + + assertEquals(NavigationCoordinatorState.Executing, coordinator.state) + assertEquals(1, failures.size) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "replacement"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + } + + @Test + public fun failedOperationCallbackCannotStartBackAgainstADirtyProjection() { + val commands = mutableListOf() + var reentrantInteraction: NavigationInteractionHandle? = null + lateinit var coordinator: NavigationCoordinator + coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onOperationFailed = { _, _ -> + reentrantInteraction = coordinator.beginUserBack() + }, + ) + coordinator.setModel(model("home", "detail")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail", "editor")) + val push = commands.last() + coordinator.setModel(model("home", "detail")) + + coordinator.completeCommand( + push.token, + NavigationOperationResult.Failed(IllegalStateException("native projection is unknown")), + ) + + assertNull(reentrantInteraction) + assertIs(commands.last().operation) + } + + @Test + public fun failedRecoveryPausesUntilThePlatformCanRetryReconstruction() { + val commands = mutableListOf() + val failures = mutableListOf() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onOperationFailed = { _, error -> failures += error }, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail")) + + val push = commands.last() + assertTrue( + coordinator.completeCommand( + push.token, + NavigationOperationResult.Failed(IllegalStateException("push failed")), + ), + ) + val recovery = commands.last() + assertIs(recovery.operation) + assertTrue( + coordinator.completeCommand( + recovery.token, + NavigationOperationResult.Failed(IllegalStateException("recovery failed")), + ), + ) + + assertEquals(NavigationCoordinatorState.Paused, coordinator.state) + assertEquals(listOf("push failed", "recovery failed"), failures.map(Throwable::message)) + assertTrue(coordinator.resumeOperations()) + assertIs(commands.last().operation) + } + + @Test + public fun failedInitialReconstructionGetsOneAutomaticRetry() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home")) + + val initial = commands.single() + assertIs(initial.operation) + assertTrue( + coordinator.completeCommand( + initial.token, + NavigationOperationResult.Failed(IllegalStateException("initial install failed")), + ), + ) + + assertEquals(NavigationCoordinatorState.Executing, coordinator.state) + assertEquals(2, commands.size) + assertIs(commands.last().operation) + + assertTrue( + coordinator.completeCommand( + commands.last().token, + NavigationOperationResult.Failed(IllegalStateException("retry failed")), + ), + ) + assertEquals(NavigationCoordinatorState.Paused, coordinator.state) + assertEquals(2, commands.size) + } + + @Test + public fun cancelledUserBackDoesNotCallTheModel() { + val commands = mutableListOf() + var backCalls = 0 + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail", onBack = { backCalls += it })) + completeLatest(coordinator, commands) + + val interaction = beginBack(coordinator) + assertTrue(coordinator.cancelUserBack(interaction)) + + assertEquals(0, backCalls) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertEquals(listOf("home", "detail"), coordinator.projectedEntries.contentKeys()) + } + + @Test + public fun failedUserBackReconstructsTheAuthoritativeDeclaration() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail")) + completeLatest(coordinator, commands) + + val interaction = beginBack(coordinator) + assertTrue(coordinator.failUserBack(interaction)) + + assertEquals(NavigationCoordinatorState.Executing, coordinator.state) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "detail"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + assertFalse(coordinator.failUserBack(interaction)) + + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + } + + @Test + public fun failedUserBackRecoveryPausesAfterOneReconstructionAttempt() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail")) + completeLatest(coordinator, commands) + + val interaction = beginBack(coordinator) + assertTrue(coordinator.failUserBack(interaction)) + val recovery = commands.last() + val commandCount = commands.size + assertIs(recovery.operation) + + assertTrue( + coordinator.completeCommand( + recovery.token, + NavigationOperationResult.Failed(IllegalStateException("native recovery failed")), + ), + ) + + assertEquals(NavigationCoordinatorState.Paused, coordinator.state) + assertEquals(commandCount, commands.size) + assertFalse(coordinator.failUserBack(interaction)) + } + + @Test + public fun committedUserBackWaitsThroughUnchangedModelsAndAcceptsTheTarget() { + val commands = mutableListOf() + val popCounts = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail", onBack = popCounts::add)) + completeLatest(coordinator, commands) + val commandCount = commands.size + + val interaction = beginBack(coordinator) + val acknowledgement = assertNotNull(coordinator.commitUserBack(interaction)) + assertEquals(listOf(1), popCounts) + assertEquals(NavigationCoordinatorState.AwaitingAcknowledgement, coordinator.state) + assertTrue(coordinator.hasPendingAcknowledgement) + assertEquals(listOf("home"), coordinator.projectedEntries.contentKeys()) + + coordinator.setModel(model("home", "detail", onBack = popCounts::add)) + + assertEquals(NavigationCoordinatorState.AwaitingAcknowledgement, coordinator.state) + assertTrue(coordinator.hasPendingAcknowledgement) + assertEquals(commandCount, commands.size) + + coordinator.setModel(model("home", onBack = popCounts::add)) + + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertFalse(coordinator.hasPendingAcknowledgement) + assertEquals(commandCount, commands.size) + assertFalse(coordinator.acknowledgementDeadlineReached(acknowledgement)) + } + + @Test + public fun timedOutBackRestoresThenAllowsLateTopologyAsANewModelChange() { + val commands = mutableListOf() + val mismatches = mutableListOf() + lateinit var request: NavigationBackRequest + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onBackMismatch = mismatches::add, + ) + coordinator.setModel( + modelWithBackRequest("home", "detail", onBack = { request = it }), + ) + completeLatest(coordinator, commands) + + val interaction = beginBack(coordinator) + val acknowledgement = assertNotNull(coordinator.commitUserBack(interaction)) + assertEquals(acknowledgement.token, request.requestId) + assertEquals(listOf("home", "detail"), request.base.map { it.contentKey }) + assertEquals(listOf("home"), request.target.map { it.contentKey }) + assertTrue(request.isActive) + assertTrue(coordinator.acknowledgementDeadlineReached(acknowledgement)) + assertFalse(coordinator.hasPendingAcknowledgement) + assertFalse(request.isActive) + assertFalse(request.accept()) + assertFalse(request.reject()) + val staleStack = request.base.toMutableList() + assertFalse(request.applyTo(staleStack)) + assertEquals(request.base, staleStack) + + assertEquals(1, mismatches.size) + assertEquals(NavigationBackMismatchReason.DeadlineReached, mismatches.single().reason) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "detail"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertTrue(coordinator.canBeginUserBack()) + coordinator.setModel(model("home", "detail")) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertTrue(coordinator.canBeginUserBack()) + + // A late reducer result is ordinary programmatic navigation after the request's deadline; + // it cannot revive or resolve the stale token. + coordinator.setModel(model("home")) + assertIs(commands.last().operation) + completeUntilIdle(coordinator, commands) + assertFalse(request.isActive) + } + + @Test + public fun mismatchingModelSupersedesRequestAndRestoresBackAvailability() { + val commands = mutableListOf() + lateinit var request: NavigationBackRequest + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel( + modelWithBackRequest("home", "detail", onBack = { request = it }), + ) + completeLatest(coordinator, commands) + + val interaction = beginBack(coordinator) + assertNotNull(coordinator.commitUserBack(interaction)) + coordinator.setModel(model("home", "replacement")) + assertFalse(coordinator.hasPendingAcknowledgement) + assertIs(commands.last().operation) + completeLatest(coordinator, commands) + + assertFalse(request.isActive) + assertFalse(request.accept()) + assertFalse(request.reject()) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertTrue(coordinator.canBeginUserBack()) + + coordinator.setModel(model("home")) + assertIs(commands.last().operation) + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + } + + @Test + public fun explicitBackRejectionRestoresAndDoesNotQuarantineFutureBack() { + val commands = mutableListOf() + val mismatches = mutableListOf() + lateinit var request: NavigationBackRequest + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onBackMismatch = mismatches::add, + ) + coordinator.setModel( + modelWithBackRequest("home", "detail", onBack = { request = it }), + ) + completeLatest(coordinator, commands) + + assertNotNull(coordinator.commitUserBack(beginBack(coordinator))) + assertTrue(request.reject()) + assertFalse(request.isActive) + assertFalse(request.reject()) + assertEquals(NavigationBackMismatchReason.ExplicitlyRejected, mismatches.single().reason) + assertIs(commands.last().operation) + + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertTrue(coordinator.canBeginUserBack()) + } + + @Test + public fun acceptedBackRequestIsClaimedAndCannotBeRejected() { + val commands = mutableListOf() + lateinit var request: NavigationBackRequest + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel( + modelWithBackRequest("home", "detail", onBack = { request = it }), + ) + completeLatest(coordinator, commands) + + assertNotNull(coordinator.commitUserBack(beginBack(coordinator))) + assertTrue(request.isActive) + assertTrue(request.accept()) + assertFalse(request.isActive) + assertFalse(request.accept()) + assertFalse(request.reject()) + assertTrue(coordinator.hasPendingAcknowledgement) + + coordinator.setModel(model("home")) + assertFalse(coordinator.hasPendingAcknowledgement) + } + + @Test + public fun mappedBackRequestAppliesAtMostOnceAgainstItsExactBase() { + val commands = mutableListOf() + lateinit var topologyRequest: NavigationBackRequest + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel( + modelWithBackRequest("home", "detail", onBack = { topologyRequest = it }), + ) + completeLatest(coordinator, commands) + assertNotNull(coordinator.commitUserBack(beginBack(coordinator))) + + val request = + topologyRequest.mapValues( + base = listOf("home", "detail"), + target = listOf("home"), + ) + val routes = mutableListOf("home", "detail") + assertEquals(topologyRequest.requestId, request.requestId) + assertEquals(topologyRequest.baseRevision, request.baseRevision) + assertTrue(request.applyTo(routes)) + assertEquals(listOf("home"), routes) + assertFalse(request.applyTo(mutableListOf("home", "detail"))) + assertFalse(request.isActive) + + coordinator.setModel(model("home")) + assertFalse(request.isActive) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + } + + @Test + public fun failedBackStackMutationAbortsTheClaimAndRestoresTheNativeProjection() { + val commands = mutableListOf() + val mismatches = mutableListOf() + lateinit var topologyRequest: NavigationBackRequest + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onBackMismatch = mismatches::add, + ) + coordinator.setModel( + modelWithBackRequest("home", "detail", onBack = { topologyRequest = it }), + ) + completeLatest(coordinator, commands) + assertNotNull(coordinator.commitUserBack(beginBack(coordinator))) + val request = + topologyRequest.mapValues( + base = listOf("home", "detail"), + target = listOf("home"), + ) + val routes = RemovalFailingMutableList("home", "detail") + + val failure = + assertFailsWith { + request.applyTo(routes) + } + + assertEquals("Back-stack removal failed.", failure.message) + assertEquals(listOf("home", "detail"), routes.toList()) + assertFalse(request.isActive) + assertFalse(request.accept()) + assertFalse(request.reject()) + assertFalse(coordinator.hasPendingAcknowledgement) + assertEquals(NavigationBackMismatchReason.ExplicitlyRejected, mismatches.single().reason) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "detail"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + } + + @Test + public fun backRequestNeverMutatesAMismatchingBase() { + val commands = mutableListOf() + lateinit var request: NavigationBackRequest + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel( + modelWithBackRequest("home", "detail", onBack = { request = it }), + ) + completeLatest(coordinator, commands) + assertNotNull(coordinator.commitUserBack(beginBack(coordinator))) + + val routes = mutableListOf("home", "replacement") + assertFalse( + request.applyTo(routes) { route -> + NavigationEntryIdentity(route, NavigationPresentation.Page) + }, + ) + assertEquals(listOf("home", "replacement"), routes) + assertTrue(request.reject()) + } + + @Test + public fun concurrentModelChangeDuringGestureNeverCallsBackAgainstTheOldBase() { + val commands = mutableListOf() + var backCalls = 0 + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail", onBack = { backCalls += it })) + completeLatest(coordinator, commands) + + val interaction = beginBack(coordinator) + coordinator.setModel(model("home", "replacement", onBack = { backCalls += it })) + assertNull(coordinator.commitUserBack(interaction)) + + assertEquals(0, backCalls) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "replacement"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + } + + @Test + public fun matchingConcurrentModelChangeAcknowledgesGestureWithoutCallingBack() { + val commands = mutableListOf() + var backCalls = 0 + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail", onBack = { backCalls += it })) + completeLatest(coordinator, commands) + val commandCount = commands.size + + val interaction = beginBack(coordinator) + coordinator.setModel(model("home", onBack = { backCalls += it })) + assertNull(coordinator.commitUserBack(interaction)) + + assertEquals(0, backCalls) + assertEquals(commandCount, commands.size) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + } + + @Test + public fun staleGestureCallbacksCannotAffectTheCurrentInteraction() { + val commands = mutableListOf() + var backCalls = 0 + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail", onBack = { backCalls += it })) + completeLatest(coordinator, commands) + + val stale = beginBack(coordinator) + assertTrue(coordinator.cancelUserBack(stale)) + val current = beginBack(coordinator) + + assertNull(coordinator.commitUserBack(stale)) + assertFalse(coordinator.cancelUserBack(stale)) + assertEquals(NavigationCoordinatorState.Interacting, coordinator.state) + assertNotNull(coordinator.commitUserBack(current)) + assertEquals(1, backCalls) + } + + @Test + public fun staleAcknowledgementDeadlineCannotExpireTheCurrentBack() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail", "editor")) + completeLatest(coordinator, commands) + + val firstInteraction = beginBack(coordinator) + val staleAcknowledgement = assertNotNull(coordinator.commitUserBack(firstInteraction)) + coordinator.setModel(model("home", "detail")) + + val secondInteraction = beginBack(coordinator) + val currentAcknowledgement = assertNotNull(coordinator.commitUserBack(secondInteraction)) + + assertFalse(coordinator.acknowledgementDeadlineReached(staleAcknowledgement)) + assertEquals(NavigationCoordinatorState.AwaitingAcknowledgement, coordinator.state) + assertTrue(coordinator.acknowledgementDeadlineReached(currentAcknowledgement)) + } + + @Test + public fun canBeginBackHasNoStateSideEffects() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "detail")) + completeLatest(coordinator, commands) + + assertTrue(coordinator.canBeginUserBack()) + assertTrue(coordinator.canBeginUserBack()) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + assertFalse(coordinator.canBeginUserBack(popCount = 2)) + } + + @Test + public fun deferredOperationReplansLatestModelWithoutDirtyReconstruction() { + val commands = mutableListOf() + val deferred = mutableListOf() + val retained = mutableListOf>() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onRetainedEntriesChanged = { retained += it }, + onOperationDeferred = deferred::add, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail")) + val original = commands.last() + + assertTrue(coordinator.completeCommand(original.token, NavigationOperationResult.Deferred)) + assertEquals(NavigationCoordinatorState.Paused, coordinator.state) + assertEquals(listOf("home"), retained.last().contentKeys()) + coordinator.setModel(model("home", "replacement")) + assertTrue(coordinator.resumeOperations()) + + assertEquals(listOf(original), deferred) + assertIs(commands.last().operation) + assertEquals(listOf("home"), commands.last().sourceStack.contentKeys()) + assertEquals( + listOf("home", "replacement"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + } + + @Test + public fun observedProjectionMismatchForcesReconstruction() { + val commands = mutableListOf() + val mismatches = mutableListOf() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onProjectionMismatch = mismatches::add, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail")) + val push = commands.last() + + coordinator.completeCommand( + push.token, + NavigationOperationResult.Succeeded(observedTopology = push.sourceStack.topology()), + ) + + assertEquals(1, mismatches.size) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "detail"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + } + + @Test + public fun persistentSynchronousReconstructionMismatchPausesAfterABoundedRetry() { + val commands = mutableListOf() + val mismatches = mutableListOf() + lateinit var coordinator: NavigationCoordinator + coordinator = + NavigationCoordinator( + emitCommand = { command -> + commands += command + coordinator.completeCommand( + command.token, + NavigationOperationResult.Succeeded( + observedTopology = command.sourceStack.topology(), + ), + ) + }, + onProjectionMismatch = mismatches::add, + ) + + coordinator.setModel(model("home")) + + assertEquals(2, commands.size) + assertEquals(2, mismatches.size) + assertEquals(NavigationCoordinatorState.Paused, coordinator.state) + } + + @Test + public fun unresolvableObservedProjectionRetainsEntriesUntilReconstructionSucceeds() { + val commands = mutableListOf() + val retained = mutableListOf>() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onRetainedEntriesChanged = { retained += it }, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail")) + val stalePush = commands.last() + coordinator.setModel(model("home", "replacement")) + val snapshotCountBeforeCompletion = retained.size + + coordinator.completeCommand( + stalePush.token, + NavigationOperationResult.Succeeded( + observedTopology = + listOf( + NavigationEntryIdentity( + contentKey = "unknown-native-entry", + presentation = NavigationPresentation.Page, + ), + ), + ), + ) + + assertIs(commands.last().operation) + assertTrue( + retained + .drop(snapshotCountBeforeCompletion) + .all { "detail" in it.contentKeys() }, + ) + assertEquals( + listOf("home", "detail", "replacement"), + retained.last().contentKeys(), + ) + + completeLatest(coordinator, commands) + assertEquals(listOf("home", "replacement"), retained.last().contentKeys()) + } + + @Test + public fun resolvableReconstructionMismatchReplacesPreviousUncertainty() { + val commands = mutableListOf() + val retained = mutableListOf>() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onRetainedEntriesChanged = { retained += it }, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail")) + val failedPush = commands.last() + coordinator.completeCommand( + failedPush.token, + NavigationOperationResult.Failed(IllegalStateException("projection unknown")), + ) + coordinator.setModel(model("home", "replacement")) + val reconstruction = commands.last() + assertIs(reconstruction.operation) + val snapshotCountBeforeCompletion = retained.size + val commandCountBeforeCompletion = commands.size + + coordinator.completeCommand( + reconstruction.token, + NavigationOperationResult.Succeeded( + observedTopology = reconstruction.sourceStack.topology(), + ), + ) + + assertTrue( + retained + .drop(snapshotCountBeforeCompletion) + .all { "detail" !in it.contentKeys() }, + ) + assertEquals(listOf("home"), retained.last().contentKeys()) + assertEquals(commandCountBeforeCompletion, commands.size) + assertEquals(NavigationCoordinatorState.Paused, coordinator.state) + + assertTrue(coordinator.resumeOperations()) + assertEquals(listOf("home", "replacement"), retained.last().contentKeys()) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "replacement"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + completeLatest(coordinator, commands) + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) + } + + @Test + public fun batchesDeepPageAndOverlaySuffixChangesIntoSingleReconstruction() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + + val forwardStart = commands.size + coordinator.setModel( + model( + entry("home"), + entry("detail"), + entry("sheet", NavigationPresentation.Sheet), + entry("dialog", NavigationPresentation.Dialog), + ), + ) + completeUntilIdle(coordinator, commands) + assertEquals( + listOf("reconstruct"), + commands.drop(forwardStart).map { it.operation.kind() }, + ) + + val backwardStart = commands.size + coordinator.setModel(model("home")) + completeUntilIdle(coordinator, commands) + assertEquals( + listOf("reconstruct"), + commands.drop(backwardStart).map { it.operation.kind() }, + ) + assertEquals(listOf("home"), coordinator.projectedEntries.contentKeys()) + } + + @Test + public fun failedSecondStepRetainsUncertainEntriesUntilReconstructionSucceeds() { + val commands = mutableListOf() + val retained = mutableListOf>() + val coordinator = + NavigationCoordinator( + emitCommand = commands::add, + onRetainedEntriesChanged = { retained += it }, + ) + coordinator.setModel(model("home")) + completeLatest(coordinator, commands) + coordinator.setModel(model("home", "detail")) + completeLatest(coordinator, commands) + coordinator.setModel( + model( + entry("home"), + entry("detail"), + entry("sheet", NavigationPresentation.Sheet), + ), + ) + val present = commands.last() + assertIs(present.operation) + + coordinator.completeCommand( + present.token, + NavigationOperationResult.Failed(IllegalStateException("presentation interrupted")), + ) + assertEquals(listOf("home", "detail", "sheet"), retained.last().contentKeys()) + coordinator.setModel(model("home", "replacement")) + + assertIs(commands.last().operation) + assertEquals( + listOf("home", "detail", "sheet"), + retained.last().contentKeys(), + ) + assertEquals( + listOf("home", "detail", "sheet"), + commands + .last() + .operation.targetStack + .contentKeys(), + ) + completeLatest(coordinator, commands) + assertIs(commands.last().operation) + assertEquals( + listOf("home", "detail", "sheet", "replacement"), + retained.last().contentKeys(), + ) + completeLatest(coordinator, commands) + assertEquals(listOf("home", "replacement"), retained.last().contentKeys()) + } + + @Test + public fun presentationMutationFailsBeforeAPlatformCommandIsEmitted() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "editor")) + completeLatest(coordinator, commands) + val commandCount = commands.size + + assertFailsWith { + coordinator.setModel( + model( + entry("home"), + entry("editor", NavigationPresentation.Sheet), + ), + ) + } + assertEquals(commandCount, commands.size) + } + + @Test + public fun presentationCannotChangeUntilThePreviousIncarnationIsReleased() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home", "editor")) + completeLatest(coordinator, commands) + + coordinator.setModel(model("home")) + val pop = commands.last() + assertIs(pop.operation) + + assertFailsWith { + coordinator.setModel( + model( + entry("home"), + entry("editor", NavigationPresentation.Sheet), + ), + ) + } + + assertTrue(coordinator.completeCommand(pop.token, NavigationOperationResult.Succeeded())) + coordinator.setModel( + model( + entry("home"), + entry("editor", NavigationPresentation.Sheet), + ), + ) + assertIs(commands.last().operation) + } + + @Test + public fun duplicateOrStalePlatformCompletionsAreIgnored() { + val commands = mutableListOf() + val coordinator = NavigationCoordinator(emitCommand = commands::add) + coordinator.setModel(model("home")) + val initial = commands.single() + + assertTrue(coordinator.completeCommand(initial.token, NavigationOperationResult.Succeeded())) + assertFalse(coordinator.completeCommand(initial.token, NavigationOperationResult.Succeeded())) + assertFalse(coordinator.completeCommand(initial.token + 100, NavigationOperationResult.Succeeded())) + } +} + +private fun model( + vararg keys: String, + onBack: (Int) -> Unit = {}, +): NavigationModel = model(keys.map(::entry), onBack) + +private fun model( + vararg entries: NavEntry, + onBack: (Int) -> Unit = {}, +): NavigationModel = model(entries.toList(), onBack) + +private fun model( + entries: List>, + onBack: (Int) -> Unit, +): NavigationModel = + NavigationModel( + entries = resolveNavigationEntries(entries), + onBack = { request -> onBack(request.popCount) }, + subcompositions = UnusedSubcompositionFactory, + ) + +private fun modelWithBackRequest( + vararg keys: String, + onBack: (NavigationBackRequest) -> Unit, +): NavigationModel = + NavigationModel( + entries = resolveNavigationEntries(keys.map(::entry)), + onBack = onBack, + subcompositions = UnusedSubcompositionFactory, + ) + +private fun entry( + key: String, + presentation: NavigationPresentation = NavigationPresentation.Page, +): NavEntry = + NavEntry( + key = key, + contentKey = key, + metadata = + if (presentation == NavigationPresentation.Page) { + emptyMap() + } else { + mapOf(NavigationPresentationMetadata.toString() to presentation) + }, + ) {} + +private fun completeLatest( + coordinator: NavigationCoordinator, + commands: List, +) { + val command = commands.last() + assertTrue(coordinator.completeCommand(command.token, NavigationOperationResult.Succeeded())) +} + +private fun beginBack(coordinator: NavigationCoordinator): NavigationInteractionHandle = assertNotNull(coordinator.beginUserBack()) + +private fun completeUntilIdle( + coordinator: NavigationCoordinator, + commands: List, +) { + while (coordinator.state == NavigationCoordinatorState.Executing) { + completeLatest(coordinator, commands) + } + assertEquals(NavigationCoordinatorState.Idle, coordinator.state) +} + +private fun NavigationOperation.kind(): String = + when (this) { + is NavigationOperation.PushPage -> "push" + is NavigationOperation.PopPage -> "pop" + is NavigationOperation.PresentOverlay -> "present" + is NavigationOperation.DismissOverlay -> "dismiss" + is NavigationOperation.Reconstruct -> "reconstruct" + } + +private fun List.contentKeys(): List = map(ResolvedNavigationEntry::contentKey) + +private class RemovalFailingMutableList( + vararg values: T, +) : AbstractMutableList() { + private val delegate = values.toMutableList() + + override val size: Int + get() = delegate.size + + override fun get(index: Int): T = delegate[index] + + override fun set( + index: Int, + element: T, + ): T = delegate.set(index, element) + + override fun add( + index: Int, + element: T, + ) { + delegate.add(index, element) + } + + override fun removeAt(index: Int): T = error("Back-stack removal failed.") +} + +private object UnusedSubcompositionFactory : FlareSubcompositionFactory { + override fun create(root: FlareChildren): FlareSubcomposition = error("Coordinator tests never realize entry content.") +} diff --git a/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationEntryContentHostTest.kt b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationEntryContentHostTest.kt new file mode 100644 index 0000000000..9039c901f8 --- /dev/null +++ b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationEntryContentHostTest.kt @@ -0,0 +1,113 @@ +@file:OptIn( + ExperimentalFlareNavigation::class, + dev.dimension.flare.ui.LowLevelFlareApi::class, +) + +package dev.dimension.flare.ui.navigation + +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareWidget +import kotlin.test.Test +import kotlin.test.assertEquals + +public class NavigationEntryContentHostTest { + @Test + public fun skipsResettingContentForTheSameNavEntryInstance() { + val factory = RecordingSubcompositionFactory() + val original = resolvedEntry(NavEntry(key = "home", contentKey = "home") {}) + val host = + NavigationEntryContentHost( + root = EmptyChildren, + nativeControllerOwner = null, + subcompositions = factory, + initialEntry = original, + ) + + host.update(original) + + assertEquals(1, factory.composition.setContentCalls) + + val replacement = resolvedEntry(NavEntry(key = "home", contentKey = "home") {}) + host.update(replacement) + + assertEquals(2, factory.composition.setContentCalls) + host.dispose() + assertEquals(1, factory.composition.disposeCalls) + } + + @Test + public fun deactivatedHostDefersUpdatesUntilItIsActivatedAgain() { + val factory = RecordingSubcompositionFactory() + val original = resolvedEntry(NavEntry(key = "home", contentKey = "home") {}) + val host = + NavigationEntryContentHost( + root = EmptyChildren, + nativeControllerOwner = null, + subcompositions = factory, + initialEntry = original, + ) + + host.deactivate() + val replacement = resolvedEntry(NavEntry(key = "home", contentKey = "home") {}) + host.update(replacement) + + assertEquals(1, factory.composition.deactivateCalls) + assertEquals(1, factory.composition.setContentCalls) + + host.activate() + + assertEquals(2, factory.composition.setContentCalls) + host.dispose() + } +} + +private fun resolvedEntry(entry: NavEntry): ResolvedNavigationEntry = resolveNavigationEntries(listOf(entry)).single() + +private class RecordingSubcompositionFactory : FlareSubcompositionFactory { + val composition = RecordingSubcomposition() + + override fun create(root: FlareChildren): FlareSubcomposition = composition +} + +private class RecordingSubcomposition : FlareSubcomposition { + var setContentCalls: Int = 0 + private set + var disposeCalls: Int = 0 + private set + var deactivateCalls: Int = 0 + private set + + override fun setContent(content: FlareContent) { + setContentCalls += 1 + } + + override fun deactivate() { + deactivateCalls += 1 + } + + override fun dispose() { + disposeCalls += 1 + } +} + +private object EmptyChildren : FlareChildren { + override fun insert( + index: Int, + widget: FlareWidget, + ) = Unit + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit +} diff --git a/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationModelDispatcherTest.kt b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationModelDispatcherTest.kt new file mode 100644 index 0000000000..0967ba6035 --- /dev/null +++ b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationModelDispatcherTest.kt @@ -0,0 +1,39 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + ExperimentalFlareNavigation::class, +) + +package dev.dimension.flare.ui.navigation + +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import kotlin.test.Test +import kotlin.test.assertEquals + +public class NavigationModelDispatcherTest { + @Test + public fun stopsDeliveringModelsAfterTheObserverIsRemoved() { + val models = mutableListOf() + val dispatcher = NavigationModelDispatcher() + val stop = dispatcher.observe(models::add) + val delivered = unusedModel() + dispatcher.dispatch(delivered) + + stop() + dispatcher.dispatch(unusedModel()) + + assertEquals(listOf(delivered), models) + } +} + +private fun unusedModel(): NavigationModel = + NavigationModel( + entries = emptyList(), + onBack = {}, + subcompositions = UnusedDispatcherSubcompositionFactory, + ) + +private object UnusedDispatcherSubcompositionFactory : FlareSubcompositionFactory { + override fun create(root: FlareChildren): FlareSubcomposition = error("Dispatcher tests do not compose entries.") +} diff --git a/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationModelTest.kt b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationModelTest.kt new file mode 100644 index 0000000000..dc6555ec7d --- /dev/null +++ b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationModelTest.kt @@ -0,0 +1,160 @@ +@file:OptIn(ExperimentalFlareNavigation::class) + +package dev.dimension.flare.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.metadata +import dev.dimension.flare.ui.FlareUiComposable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +public class NavigationModelTest { + @Test + public fun acceptsEntryProviderAndTypedMetadataDsl() { + val provider: (TestRoute) -> NavEntry = + entryProvider { + entry { TestFlareScreen() } + entry( + metadata = + metadata { + put( + NavigationPresentationMetadata, + NavigationPresentation.Sheet, + ) + }, + ) { TestFlareScreen() } + } + + val resolved = + resolveNavigationEntries( + listOf(provider(TestHome), provider(TestEditor)), + ) + + assertEquals(NavigationPresentation.Page, resolved.first().presentation) + assertEquals(NavigationPresentation.Sheet, resolved.last().presentation) + } + + @Test + public fun acceptsAFlareTargetDecorator() { + val decorators: List> = + listOf( + NavEntryDecorator( + decorate = { entry -> + TestFlareScreen() + entry.Content() + }, + ), + ) + + assertEquals(1, decorators.size) + } + + @Test + public fun resolvesDefaultAndExplicitPresentations() { + val resolved = + resolveNavigationEntries( + listOf( + entry("home"), + entry("editor", NavigationPresentation.Sheet), + entry("confirm", NavigationPresentation.Dialog), + ), + ) + + assertEquals( + listOf( + NavigationPresentation.Page, + NavigationPresentation.Sheet, + NavigationPresentation.Dialog, + ), + resolved.map(ResolvedNavigationEntry::presentation), + ) + } + + @Test + public fun rejectsEveryInvalidStackShape() { + assertFailsWith { + resolveNavigationEntries(emptyList>()) + } + assertFailsWith { + resolveNavigationEntries(listOf(entry("dialog", NavigationPresentation.Dialog))) + } + assertFailsWith { + resolveNavigationEntries( + listOf( + entry("home"), + entry("dialog", NavigationPresentation.Dialog), + entry("detail"), + ), + ) + } + assertFailsWith { + resolveNavigationEntries( + listOf( + entry("home", contentKey = "shared"), + entry("detail", contentKey = "shared"), + ), + ) + } + } + + @Test + public fun rejectsWrongPresentationMetadataType() { + val invalid = + NavEntry( + key = "home", + contentKey = "home", + metadata = mapOf(NavigationPresentationMetadata.toString() to "Page"), + ) {} + + val error = + assertFailsWith { + resolveNavigationEntries(listOf(invalid)) + } + + assertTrue(error.message.orEmpty().contains("NavigationPresentation")) + } + + @Test + public fun rejectsPresentationChangesForARetainedIdentity() { + val previous = resolveNavigationEntries(listOf(entry("home"), entry("editor"))) + val current = + resolveNavigationEntries( + listOf(entry("home"), entry("editor", NavigationPresentation.Sheet)), + ) + + assertFailsWith { + validateStablePresentations(previous, current) + } + } +} + +private fun entry( + key: String, + presentation: NavigationPresentation = NavigationPresentation.Page, + contentKey: Any = key, +): NavEntry = + NavEntry( + key = key, + contentKey = contentKey, + metadata = + if (presentation == NavigationPresentation.Page) { + emptyMap() + } else { + mapOf(NavigationPresentationMetadata.toString() to presentation) + }, + ) {} + +private sealed interface TestRoute + +private data object TestHome : TestRoute + +private data object TestEditor : TestRoute + +@Composable +@FlareUiComposable +private fun TestFlareScreen() = Unit diff --git a/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationPlanTest.kt b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationPlanTest.kt new file mode 100644 index 0000000000..87c96d4548 --- /dev/null +++ b/flareUI/navigation/src/commonTest/kotlin/dev/dimension/flare/ui/navigation/NavigationPlanTest.kt @@ -0,0 +1,119 @@ +@file:OptIn(ExperimentalFlareNavigation::class) + +package dev.dimension.flare.ui.navigation + +import androidx.navigation3.runtime.NavEntry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +public class NavigationPlanTest { + @Test + public fun animatesSingleSuffixUpdatesAndBatchesBulkOrReplacementUpdates() { + val home = resolved(entry("home")) + val detail = resolved(entry("detail")) + val sheet = resolved(entry("sheet", NavigationPresentation.Sheet)) + val dialog = resolved(entry("dialog", NavigationPresentation.Dialog)) + + assertIs( + calculateNavigationPlan(listOf(home), listOf(home, detail)).single(), + ) + assertIs( + calculateNavigationPlan(listOf(home, detail), listOf(home, detail, sheet)).single(), + ) + assertIs( + calculateNavigationPlan(listOf(home, detail, sheet), listOf(home, detail)).single(), + ) + assertIs( + calculateNavigationPlan(listOf(home, detail), listOf(home)).single(), + ) + + val appendBatch = calculateNavigationPlan(listOf(home), listOf(home, detail, sheet, dialog)).single() + assertIs(appendBatch) + assertEquals(listOf("home", "detail", "sheet", "dialog"), appendBatch.targetStack.contentKeys()) + + val removeBatch = calculateNavigationPlan(listOf(home, detail, sheet, dialog), listOf(home)).single() + assertIs(removeBatch) + assertEquals(listOf("home"), removeBatch.targetStack.contentKeys()) + + val replacement = resolved(entry("replacement")) + val replacementPlan = calculateNavigationPlan(listOf(home, detail), listOf(home, replacement)) + assertEquals(1, replacementPlan.size) + assertIs(replacementPlan.single()) + } + + @Test + public fun initialProjectionAndDirtyProjectionUseImmediateReconstruction() { + val target = resolveNavigationEntries(listOf(entry("home"), entry("detail"))) + + val initial = calculateNavigationPlan(emptyList(), target).single() + val dirty = calculateNavigationPlan(target, target, forceReconstruction = true).single() + + assertIs(initial) + assertIs(dirty) + assertTrue(!initial.animated) + assertTrue(!dirty.animated) + } + + @Test + public fun nextOperationAnimatesOneEntryAndBatchesADeepSuffix() { + val home = resolved(entry("home")) + val detail = resolved(entry("detail")) + val editor = resolved(entry("editor")) + val settings = resolved(entry("settings")) + + val push = + assertIs( + calculateNextNavigationOperation( + projectedStack = listOf(home), + declaredStack = listOf(home, detail), + ), + ) + assertEquals(listOf("home", "detail"), push.targetStack.contentKeys()) + + val pop = + assertIs( + calculateNextNavigationOperation( + projectedStack = listOf(home, detail), + declaredStack = listOf(home), + ), + ) + assertEquals(listOf("home"), pop.targetStack.contentKeys()) + + val batch = + assertIs( + calculateNextNavigationOperation( + projectedStack = listOf(home), + declaredStack = listOf(home, detail, editor, settings), + ), + ) + assertEquals(listOf("home", "detail", "editor", "settings"), batch.targetStack.contentKeys()) + } +} + +private fun entry( + key: String, + presentation: NavigationPresentation = NavigationPresentation.Page, +): NavEntry = + NavEntry( + key = key, + contentKey = key, + metadata = + if (presentation == NavigationPresentation.Page) { + emptyMap() + } else { + mapOf(NavigationPresentationMetadata.toString() to presentation) + }, + ) {} + +private fun resolved(entry: NavEntry): ResolvedNavigationEntry = + resolveNavigationEntries( + if (entry.metadata.isEmpty()) { + listOf(entry) + } else { + listOf(entry("root"), entry) + }, + ).last() + +private fun List.contentKeys(): List = map(ResolvedNavigationEntry::contentKey) diff --git a/flareUI/navigation/src/iosMain/kotlin/dev/dimension/flare/ui/navigation/UIKitNavigationRendererPlugin.kt b/flareUI/navigation/src/iosMain/kotlin/dev/dimension/flare/ui/navigation/UIKitNavigationRendererPlugin.kt new file mode 100644 index 0000000000..03422b861c --- /dev/null +++ b/flareUI/navigation/src/iosMain/kotlin/dev/dimension/flare/ui/navigation/UIKitNavigationRendererPlugin.kt @@ -0,0 +1,540 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.navigation + +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.uikit.AbstractUIKitWidget +import dev.dimension.flare.ui.uikit.UIKitBackend +import dev.dimension.flare.ui.uikit.UIKitChildren +import kotlinx.cinterop.ObjCSignatureOverride +import kotlinx.cinterop.useContents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import platform.Foundation.NSProcessInfo +import platform.UIKit.NSLayoutConstraint +import platform.UIKit.UIColor +import platform.UIKit.UIGestureRecognizer +import platform.UIKit.UIGestureRecognizerDelegateProtocol +import platform.UIKit.UILayoutConstraintAxisHorizontal +import platform.UIKit.UINavigationController +import platform.UIKit.UINavigationControllerDelegateProtocol +import platform.UIKit.UIStackView +import platform.UIKit.UIStackViewAlignmentTop +import platform.UIKit.UIView +import platform.UIKit.UIViewController +import platform.UIKit.addChildViewController +import platform.UIKit.didMoveToParentViewController +import platform.UIKit.removeFromParentViewController +import platform.UIKit.systemBackgroundColor +import platform.UIKit.transitionCoordinator +import platform.UIKit.willMoveToParentViewController +import platform.darwin.NSObject + +/** + * Supplies the UIKit parent controller that owns a [NavigationDisplay]. + * + * Pass this owner to [dev.dimension.flare.ui.uikit.FlareUIKitHost]. The navigation renderer adds + * its `UINavigationController` as a child of [parent], while each Page receives its own owner. + */ +public class UIKitNavigationOwner( + public val parent: UIViewController, +) : FlareNativeControllerOwner + +/** UIKit renderer for Page-only [NavigationDisplay] stacks. */ +public object UIKitNavigationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(NavigationWidget::class) { UIKitNavigationWidget() } + } +} + +internal class UIKitNavigationWidget( + internal val navigationController: UINavigationController = UINavigationController(), +) : AbstractUIKitWidget(UIView()), + NavigationWidget { + private val controllers = linkedMapOf() + private val delegate = UIKitNavigationDelegate(this) + private val interactivePopDelegate = UIKitInteractivePopGestureDelegate(this) + private val acknowledgementScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val coordinator = + NavigationCoordinator( + emitCommand = ::execute, + onRetainedEntriesChanged = ::updateRetainedEntries, + ) + private var parent: UIViewController? = null + private var subcompositions: FlareSubcompositionFactory? = null + private var modelDispatcher: NavigationModelDispatcher? = null + private var stopObservingModels: (() -> Unit)? = null + private var pendingCommand: NavigationCommand? = null + private var interaction: NavigationInteractionHandle? = null + private var interactionTargetIdentity: NavigationEntryIdentity? = null + private var acknowledgementJob: Job? = null + private var disposed: Boolean = false + + init { + navigationController.delegate = delegate + navigationController.setNavigationBarHidden(hidden = true, animated = false) + } + + override fun setModelDispatcher(dispatcher: NavigationModelDispatcher) { + check(!disposed) { "UIKit navigation widget is already disposed." } + stopObservingModels?.invoke() + modelDispatcher = dispatcher + stopObservingModels = dispatcher.observe(::applyModel) + } + + private fun applyModel(model: NavigationModel) { + check(!disposed) { "UIKit navigation widget is already disposed." } + require(model.entries.all { it.presentation == NavigationPresentation.Page }) { + "UIKitNavigationRendererPlugin supports only Page presentation." + } + val owner = + model.nativeControllerOwner as? UIKitNavigationOwner + ?: error( + "UIKitNavigationRendererPlugin requires UIKitNavigationOwner. Pass " + + "UIKitNavigationOwner(parentViewController) to FlareUIKitHost.", + ) + validateParent(owner.parent) + subcompositions = model.subcompositions + attachTo(owner.parent) + val wasPaused = coordinator.state == NavigationCoordinatorState.Paused + coordinator.setModel(model) + if (!coordinator.hasPendingAcknowledgement) { + clearAcknowledgementTimeout() + } + // A fresh applied model is a bounded retry opportunity if the preceding native recovery + // failed. Retrying here avoids a permanently paused projection without a callback loop. + if (wasPaused) coordinator.resumeOperations() + applyContentRetentionPolicyIfStable() + updateInteractivePopAvailability() + } + + override fun dispose() { + if (disposed) return + disposed = true + stopObservingModels?.invoke() + stopObservingModels = null + modelDispatcher = null + pendingCommand = null + interaction = null + interactionTargetIdentity = null + clearAcknowledgementTimeout() + acknowledgementScope.cancel() + coordinator.dispose() + updateInteractivePopAvailability() + clearInteractivePopGestureDelegates() + navigationController.delegate = null + navigationController.willMoveToParentViewController(null) + navigationController.view.removeFromSuperview() + navigationController.removeFromParentViewController() + parent = null + } + + private fun attachTo(value: UIViewController) { + validateParent(value) + if (parent != null) return + parent = value + value.addChildViewController(navigationController) + val navigationView = navigationController.view + navigationView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationView) + NSLayoutConstraint.activateConstraints( + listOf( + navigationView.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor), + navigationView.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor), + navigationView.topAnchor.constraintEqualToAnchor(view.topAnchor), + navigationView.bottomAnchor.constraintEqualToAnchor(view.bottomAnchor), + ), + ) + installInteractivePopGestureDelegates() + navigationController.didMoveToParentViewController(value) + } + + private fun validateParent(value: UIViewController) { + val previous = parent + // Objective-C collections and properties may vend a different Kotlin wrapper for the same + // native controller. NSObject equality is the stable identity boundary here. + check(previous == null || previous == value) { + "A UIKit navigation widget cannot move between UIViewController parents." + } + } + + private fun updateRetainedEntries(entries: List) { + if (disposed) { + controllers.values.forEach(UIKitNavigationEntryController::dispose) + controllers.clear() + return + } + val subcompositions = requireSubcompositions() + val retained = entries.associateBy(ResolvedNavigationEntry::identity) + val physicalControllers = navigationController.viewControllers + controllers.entries + .filter { (identity, _) -> identity !in retained } + .forEach { (identity, controller) -> + check(physicalControllers.none { it == controller }) { + "UIKit navigation released a controller that is still displayed." + } + controllers.remove(identity) + controller.dispose() + } + retained.forEach { (identity, entry) -> + controllers[identity]?.update(entry, subcompositions) + } + } + + private fun requireSubcompositions(): FlareSubcompositionFactory = + checkNotNull(subcompositions) { + "Navigation entries must be retained only after their NavigationModel is installed." + } + + private fun execute(command: NavigationCommand) { + check(pendingCommand == null) { "UIKit navigation command overlap." } + pendingCommand = command + updateInteractivePopAvailability() + try { + when (val operation = command.operation) { + is NavigationOperation.PushPage -> { + val projection = physicalControllers() + val from = + projection.lastOrNull() + ?: error("UIKit navigation cannot push without a displayed root controller.") + val to = controllerFor(operation.entry) + check(projection.none { it == to }) { + "UIKit navigation attempted to push an existing page controller." + } + from.realizeContent() + to.realizeContent() + navigationController.pushViewController( + viewController = to, + animated = operation.animated, + ) + } + + is NavigationOperation.PopPage -> { + val projection = physicalControllers() + val from = + projection.lastOrNull() + ?: error("UIKit navigation cannot pop without a displayed page controller.") + check(from.identity == operation.entry.identity()) { + "UIKit navigation pop source does not match the displayed page controller." + } + val to = + projection.getOrNull(projection.lastIndex - 1) + ?: error("UIKit navigation cannot pop its root page controller.") + from.realizeContent() + to.realizeContent() + val popped = + checkNotNull( + navigationController.popViewControllerAnimated(operation.animated), + ) { + "UIKit navigation controller rejected a requested page pop." + } + check(popped == from) { + "UIKit navigation controller popped a different page than requested." + } + } + + is NavigationOperation.Reconstruct -> { + navigationController.setViewControllers( + viewControllers = operation.targetStack.map(::controllerFor), + animated = operation.animated, + ) + } + + is NavigationOperation.PresentOverlay, + is NavigationOperation.DismissOverlay, + -> { + error("UIKitNavigationRendererPlugin supports only Page presentation.") + } + } + if (!command.operation.animated) { + completePendingCommandIfNeeded(expectedToken = command.token) + } + } catch (error: Throwable) { + // A UIKit delegate is allowed to complete synchronously. Do not clear a newer command + // if the native call throws after invoking that delegate. + if (pendingCommand?.token == command.token) { + pendingCommand = null + coordinator.completeCommand(command.token, NavigationOperationResult.Failed(error)) + } + applyContentRetentionPolicyIfStable() + updateInteractivePopAvailability() + } + } + + private fun controllerFor(entry: ResolvedNavigationEntry): UIKitNavigationEntryController = + controllers + .getOrPut(entry.identity()) { + UIKitNavigationEntryController( + initialEntry = entry, + subcompositions = requireSubcompositions(), + ) + }.also { it.update(entry, requireSubcompositions()) } + + private fun completePendingCommandIfNeeded(expectedToken: Long? = null) { + val command = pendingCommand ?: return + if (expectedToken != null && command.token != expectedToken) return + val result = + try { + NavigationOperationResult.Succeeded(observedTopology()) + } catch (error: Throwable) { + NavigationOperationResult.Failed(error) + } + pendingCommand = null + coordinator.completeCommand( + token = command.token, + result = result, + ) + applyContentRetentionPolicyIfStable() + updateInteractivePopAvailability() + } + + private fun observedTopology(): List = physicalControllers().map(UIKitNavigationEntryController::identity) + + private fun physicalControllers(): List = + navigationController.viewControllers.map { controller -> + val entryController = + controller as? UIKitNavigationEntryController + ?: error("UIKit navigation stack contains a controller not owned by Flare navigation.") + check(controllers[entryController.identity] == entryController) { + "UIKit navigation stack contains an entry controller owned by another projection." + } + entryController + } + + /** Keeps the current page active and one frozen predecessor ready for interactive back. */ + private fun applyContentRetentionPolicyIfStable() { + if (disposed || pendingCommand != null || interaction != null) return + val projection = physicalControllers() + projection.forEachIndexed { index, controller -> + when (index) { + projection.lastIndex -> controller.realizeContent() + projection.lastIndex - 1 -> controller.deactivateContent() + else -> controller.releaseContent() + } + } + } + + internal fun beginUserPop(target: UIViewController): NavigationInteractionHandle? { + if (pendingCommand != null || modelDispatcher?.hasUndeliveredModel == true) return null + val entryController = target as? UIKitNavigationEntryController ?: return null + val targetIdentity = entryController.identity + if (controllers[targetIdentity] != entryController) return null + val targetIndex = + coordinator.projectedEntries.indexOfFirst { entry -> + entry.identity() == targetIdentity + } + if (targetIndex < 0) return null + val popCount = coordinator.projectedEntries.lastIndex - targetIndex + if (popCount <= 0) return null + return coordinator.beginUserBack(popCount)?.also { + physicalControllers().lastOrNull()?.realizeContent() + entryController.realizeContent() + interaction = it + interactionTargetIdentity = targetIdentity + } + } + + internal fun finishUserPop(cancelled: Boolean) { + val handle = interaction ?: return + interaction = null + interactionTargetIdentity = null + if (cancelled) { + coordinator.cancelUserBack(handle) + applyContentRetentionPolicyIfStable() + updateInteractivePopAvailability() + return + } + coordinator.commitUserBack(handle)?.let(::scheduleAcknowledgementTimeout) + applyContentRetentionPolicyIfStable() + updateInteractivePopAvailability() + } + + private fun scheduleAcknowledgementTimeout(handle: NavigationAcknowledgementHandle) { + clearAcknowledgementTimeout() + acknowledgementJob = + acknowledgementScope.launch { + delay(NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS) + acknowledgementJob = null + if (!disposed) { + coordinator.acknowledgementDeadlineReached(handle) + applyContentRetentionPolicyIfStable() + updateInteractivePopAvailability() + } + } + } + + private fun clearAcknowledgementTimeout() { + acknowledgementJob?.cancel() + acknowledgementJob = null + } + + internal fun finishUserPopIfNeeded(shown: UIViewController) { + val targetIdentity = interactionTargetIdentity ?: return + val shownController = shown as? UIKitNavigationEntryController + finishUserPop( + cancelled = + shownController == null || + shownController.identity != targetIdentity || + controllers[targetIdentity] != shownController, + ) + } + + private fun canBeginInteractivePop(): Boolean = + !disposed && + pendingCommand == null && + interaction == null && + modelDispatcher?.hasUndeliveredModel != true && + coordinator.canBeginUserBack() + + private fun installInteractivePopGestureDelegates() { + navigationController.interactivePopGestureRecognizer?.delegate = interactivePopDelegate + interactiveContentPopGestureRecognizer()?.delegate = interactivePopDelegate + } + + private fun updateInteractivePopAvailability() { + if (interaction != null) return + val enabled = !disposed + navigationController.interactivePopGestureRecognizer?.enabled = enabled + interactiveContentPopGestureRecognizer()?.enabled = enabled + } + + private fun clearInteractivePopGestureDelegates() { + navigationController.interactivePopGestureRecognizer?.let { gesture -> + if (gesture.delegate === interactivePopDelegate) { + gesture.delegate = null + } + } + interactiveContentPopGestureRecognizer()?.let { gesture -> + if (gesture.delegate === interactivePopDelegate) { + gesture.delegate = null + } + } + } + + private fun interactiveContentPopGestureRecognizer(): UIGestureRecognizer? { + val isIOS26OrLater = + NSProcessInfo.processInfo.operatingSystemVersion.useContents { majorVersion >= 26 } + return if (isIOS26OrLater) { + navigationController.interactiveContentPopGestureRecognizer + } else { + null + } + } + + private class UIKitNavigationDelegate( + private val widget: UIKitNavigationWidget, + ) : NSObject(), + UINavigationControllerDelegateProtocol { + @ObjCSignatureOverride + override fun navigationController( + navigationController: UINavigationController, + willShowViewController: UIViewController, + animated: Boolean, + ) { + val transition = navigationController.transitionCoordinator + widget.beginUserPop(willShowViewController) ?: return + transition?.animateAlongsideTransition( + animation = null, + completion = { context -> widget.finishUserPop(context?.isCancelled() ?: true) }, + ) + } + + @ObjCSignatureOverride + override fun navigationController( + navigationController: UINavigationController, + didShowViewController: UIViewController, + animated: Boolean, + ) { + widget.completePendingCommandIfNeeded() + widget.finishUserPopIfNeeded(didShowViewController) + } + } + + private class UIKitInteractivePopGestureDelegate( + private val widget: UIKitNavigationWidget, + ) : NSObject(), + UIGestureRecognizerDelegateProtocol { + @ObjCSignatureOverride + override fun gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer): Boolean = widget.canBeginInteractivePop() + } +} + +internal class UIKitNavigationEntryController( + initialEntry: ResolvedNavigationEntry, + private var subcompositions: FlareSubcompositionFactory, +) : UIViewController(nibName = null, bundle = null) { + private val contentView = + UIStackView().apply { + axis = UILayoutConstraintAxisHorizontal + alignment = UIStackViewAlignmentTop + backgroundColor = UIColor.systemBackgroundColor + } + private var contentHost: NavigationEntryContentHost? = null + private var entry: ResolvedNavigationEntry = initialEntry + private var disposed: Boolean = false + + internal val identity: NavigationEntryIdentity + get() = entry.identity() + + override fun loadView() { + view = contentView + } + + fun update( + entry: ResolvedNavigationEntry, + subcompositions: FlareSubcompositionFactory = this.subcompositions, + ) { + check(!disposed) { "UIKit navigation entry controller is already disposed." } + require(entry.identity() == identity) { + "A UIKit navigation entry controller cannot change identity." + } + if (this.subcompositions !== subcompositions) { + releaseContent() + this.subcompositions = subcompositions + } + this.entry = entry + contentHost?.update(entry) + } + + internal fun realizeContent() { + check(!disposed) { "UIKit navigation entry controller is already disposed." } + ensureContentHost().activate() + } + + internal fun deactivateContent() { + check(!disposed) { "UIKit navigation entry controller is already disposed." } + ensureContentHost().deactivate() + } + + private fun ensureContentHost(): NavigationEntryContentHost = + contentHost + ?: NavigationEntryContentHost( + root = UIKitChildren(contentView), + nativeControllerOwner = UIKitNavigationOwner(this), + subcompositions = subcompositions, + initialEntry = entry, + ).also { contentHost = it } + + internal fun releaseContent() { + contentHost?.dispose() + contentHost = null + } + + fun dispose() { + if (disposed) return + disposed = true + releaseContent() + } +} diff --git a/flareUI/navigation/src/iosTest/kotlin/dev/dimension/flare/ui/navigation/UIKitNavigationRendererTest.kt b/flareUI/navigation/src/iosTest/kotlin/dev/dimension/flare/ui/navigation/UIKitNavigationRendererTest.kt new file mode 100644 index 0000000000..6478b508e9 --- /dev/null +++ b/flareUI/navigation/src/iosTest/kotlin/dev/dimension/flare/ui/navigation/UIKitNavigationRendererTest.kt @@ -0,0 +1,576 @@ +@file:OptIn( + ExperimentalFlareNavigation::class, + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.uikit.AbstractUIKitWidget +import dev.dimension.flare.ui.uikit.FlareUIKitHost +import dev.dimension.flare.ui.uikit.UIKitBackend +import kotlinx.cinterop.ObjCSignatureOverride +import kotlinx.cinterop.useContents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import platform.CoreGraphics.CGRectMake +import platform.UIKit.NSLayoutConstraint +import platform.UIKit.UIColor +import platform.UIKit.UILabel +import platform.UIKit.UILayoutConstraintAxisHorizontal +import platform.UIKit.UINavigationController +import platform.UIKit.UIStackView +import platform.UIKit.UIStackViewAlignmentTop +import platform.UIKit.UIView +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.childViewControllers +import platform.UIKit.systemBackgroundColor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +public class UIKitNavigationRendererTest { + @Test + public fun deepStackKeepsOnlyCurrentAndImmediatePredecessorMaterialized() { + val fixture = UIKitNavigationFixture() + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail, fixture.editor, fixture.settings)) + awaitUIKitNavigation("UIKit navigation did not reconstruct the deep Page stack.") { + fixture.navigationController.viewControllers.size == 4 + } + + assertEquals(2, fixture.subcompositions.created) + assertEquals(2, fixture.subcompositions.installed) + assertEquals(1, fixture.subcompositions.deactivated) + assertEquals(0, fixture.subcompositions.disposed) + } finally { + fixture.dispose() + } + + assertEquals(2, fixture.subcompositions.disposed) + assertEquals(0, fixture.parent.childViewControllers.size) + } + + @Test + public fun factoryRebindRestoresActiveFrozenAndReleasedPagesWithTheNewFactory() { + val fixture = UIKitNavigationFixture() + val replacement = UIKitRecordingSubcompositionFactory() + val reboundHome = uikitEntry("home") + val reboundDetail = uikitEntry("detail") + val reboundEditor = uikitEntry("editor") + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail, fixture.editor)) + awaitUIKitNavigation("UIKit navigation did not apply the initial retention policy.") { + fixture.navigationController.viewControllers.size == 3 && + fixture.subcompositions.created == 2 + } + fixture.subcompositions.disposeOwnedCompositions() + + fixture.dispatch( + entries = listOf(reboundHome, reboundDetail, reboundEditor), + subcompositions = replacement, + ) + awaitUIKitNavigation("UIKit navigation did not restore retained content with the new factory.") { + replacement.created == 2 && replacement.deactivated == 1 + } + + assertEquals(2, fixture.subcompositions.disposed) + assertEquals(2, replacement.installed) + assertEquals(0, replacement.disposed) + + fixture.dispatch(listOf(reboundHome), replacement) + awaitUIKitNavigation("UIKit navigation did not realize the released root with the new factory.") { + fixture.navigationController.viewControllers.size == 1 && replacement.created == 3 + } + } finally { + fixture.dispose() + } + } + + @Test + public fun programmaticPushAndPopApplyContentRetentionPolicy() { + val fixture = UIKitNavigationFixture() + + try { + fixture.dispatch(listOf(fixture.home)) + awaitUIKitNavigation("UIKit navigation did not install its root Page.") { + fixture.navigationController.viewControllers.size == 1 && + fixture.subcompositions.created == 1 + } + + fixture.dispatch(listOf(fixture.home, fixture.detail)) + awaitUIKitNavigation("UIKit navigation did not finish the Page push.") { + fixture.navigationController.viewControllers.size == 2 && + fixture.navigationController.topEntryIdentity == fixture.detail.identity() && + fixture.subcompositions.deactivated == 1 + } + + assertEquals(2, fixture.subcompositions.created) + assertEquals(2, fixture.subcompositions.installed) + assertEquals(0, fixture.subcompositions.disposed) + + fixture.dispatch(listOf(fixture.home)) + awaitUIKitNavigation("UIKit navigation did not finish the Page pop.") { + fixture.navigationController.viewControllers.size == 1 && + fixture.navigationController.topEntryIdentity == fixture.home.identity() && + fixture.subcompositions.disposed == 1 + } + + assertEquals(2, fixture.subcompositions.created) + assertEquals(3, fixture.subcompositions.installed) + assertEquals(1, fixture.subcompositions.deactivated) + } finally { + fixture.dispose() + } + } + + @Test + public fun interactivePopCancelRefreezesAndCommitReleasesSource() { + val fixture = UIKitNavigationFixture() + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail)) + awaitUIKitNavigation("UIKit navigation did not reconstruct the interactive Page stack.") { + fixture.navigationController.viewControllers.size == 2 && + fixture.subcompositions.deactivated == 1 + } + val homeController = fixture.navigationController.viewControllers.first() as UIViewController + + assertNotNull(fixture.widget.beginUserPop(homeController)) + assertEquals(3, fixture.subcompositions.installed) + fixture.widget.finishUserPop(cancelled = true) + + assertEquals(0, fixture.backRequestCount) + assertEquals(2, fixture.navigationController.viewControllers.size) + assertEquals(2, fixture.subcompositions.deactivated) + assertEquals(0, fixture.subcompositions.disposed) + + assertNotNull(fixture.widget.beginUserPop(homeController)) + assertEquals(4, fixture.subcompositions.installed) + fixture.navigationController.setViewControllers(listOf(homeController), animated = false) + fixture.widget.finishUserPop(cancelled = false) + awaitUIKitNavigation("UIKit navigation did not acknowledge the committed native pop.") { + fixture.backRequestCount == 1 && + fixture.navigationController.viewControllers.size == 1 && + fixture.subcompositions.disposed == 1 + } + + assertEquals(fixture.home.identity(), fixture.navigationController.topEntryIdentity) + } finally { + fixture.dispose() + } + } + + @Test + public fun interactiveCancelCompletionAndDidShowOrdersAreIdempotent() { + listOf(true, false).forEach { completionFirst -> + val fixture = UIKitNavigationFixture() + try { + fixture.dispatch(listOf(fixture.home, fixture.detail)) + awaitUIKitNavigation("UIKit navigation did not install the interactive Page stack.") { + fixture.navigationController.viewControllers.size == 2 && + fixture.subcompositions.deactivated == 1 + } + val target = fixture.navigationController.viewControllers.first() as UIViewController + val shownAfterCancellation = fixture.navigationController.viewControllers.last() as UIViewController + assertNotNull(fixture.widget.beginUserPop(target)) + + if (completionFirst) { + fixture.widget.finishUserPop(cancelled = true) + fixture.widget.finishUserPopIfNeeded(shownAfterCancellation) + } else { + fixture.widget.finishUserPopIfNeeded(shownAfterCancellation) + fixture.widget.finishUserPop(cancelled = true) + } + + assertEquals(0, fixture.backRequestCount) + assertEquals(2, fixture.subcompositions.deactivated) + assertEquals(0, fixture.subcompositions.disposed) + assertNotNull(fixture.widget.beginUserPop(target)) + fixture.widget.finishUserPop(cancelled = true) + } finally { + fixture.dispose() + } + } + } + + @Test + public fun stagedModelBlocksInteractivePopAgainstStaleTopology() { + val fixture = UIKitNavigationFixture() + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail)) + awaitUIKitNavigation("UIKit navigation did not install the initial Page stack.") { + fixture.navigationController.viewControllers.size == 2 + } + fixture.stage(listOf(fixture.home)) + + assertNull( + fixture.widget.beginUserPop( + fixture.navigationController.viewControllers.first() as UIViewController, + ), + ) + } finally { + fixture.dispose() + } + } + + @Test + public fun rejectedNativePopRecoversAndDoesNotLeaveCoordinatorExecuting() { + val rejectingNavigationController = RejectingPopNavigationController() + val fixture = UIKitNavigationFixture(navigationController = rejectingNavigationController) + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail)) + awaitUIKitNavigation("UIKit navigation did not install the initial Page stack.") { + rejectingNavigationController.viewControllers.size == 2 + } + + fixture.dispatch(listOf(fixture.home)) + awaitUIKitNavigation("UIKit navigation did not reconstruct after a rejected pop.") { + rejectingNavigationController.popAttempts == 1 && + rejectingNavigationController.viewControllers.size == 1 && + rejectingNavigationController.topEntryIdentity == fixture.home.identity() + } + + fixture.dispatch(listOf(fixture.home, fixture.editor)) + awaitUIKitNavigation("UIKit navigation stayed stuck after a rejected pop.") { + rejectingNavigationController.viewControllers.size == 2 && + rejectingNavigationController.topEntryIdentity == fixture.editor.identity() + } + } finally { + fixture.dispose() + } + } + + @Test + public fun pageEntryRootPreservesWrapHeightOnItsCrossAxis() { + val parent = UIViewController() + val window = UIWindow(frame = CGRectMake(0.0, 0.0, 320.0, 640.0)) + val host = + FlareUIKitHost( + widgetSystem = + FlareWidgetSystem( + UIKitNavigationRendererPlugin, + wrapHeightLabelPlugin, + ), + nativeControllerOwner = UIKitNavigationOwner(parent), + ) + + try { + host.setContent { + NavigationDisplay( + entries = + listOf( + NavEntry( + key = "home", + contentKey = "home", + ) { + WrapHeightLabel() + }, + ), + onBack = {}, + ) + } + window.rootViewController = parent + host.view.translatesAutoresizingMaskIntoConstraints = false + parent.view.addSubview(host.view) + NSLayoutConstraint.activateConstraints( + listOf( + host.view.leadingAnchor.constraintEqualToAnchor(parent.view.leadingAnchor), + host.view.trailingAnchor.constraintEqualToAnchor(parent.view.trailingAnchor), + host.view.topAnchor.constraintEqualToAnchor(parent.view.topAnchor), + host.view.bottomAnchor.constraintEqualToAnchor(parent.view.bottomAnchor), + ), + ) + window.hidden = false + + awaitUIKitNavigation("UIKit navigation did not create its Page controller.") { + parent.childViewControllers + .filterIsInstance() + .singleOrNull() + ?.topViewController != null + } + + val navigationController = + parent.childViewControllers + .filterIsInstance() + .single() + val entryRoot = navigationController.topViewController?.view as UIStackView + parent.view.layoutIfNeeded() + navigationController.view.layoutIfNeeded() + entryRoot.layoutIfNeeded() + + val label = entryRoot.arrangedSubviews.single() as UILabel + assertEquals(UILayoutConstraintAxisHorizontal, entryRoot.axis) + assertEquals(UIStackViewAlignmentTop, entryRoot.alignment) + val labelHeight = label.frame.useContents { size.height } + val entryHeight = entryRoot.bounds.useContents { size.height } + assertTrue( + labelHeight < entryHeight, + "A wrap-height Page must not be stretched to the entry controller height.", + ) + } finally { + host.dispose() + window.hidden = true + } + } + + @Test + public fun pageControllerUsesTheDynamicSystemBackground() { + val parent = UIViewController() + val window = UIWindow(frame = CGRectMake(0.0, 0.0, 320.0, 640.0)) + val host = + FlareUIKitHost( + widgetSystem = FlareWidgetSystem(UIKitNavigationRendererPlugin), + nativeControllerOwner = UIKitNavigationOwner(parent), + ) + + try { + host.setContent { + NavigationDisplay( + entries = + listOf( + NavEntry( + key = "home", + contentKey = "home", + ) {}, + ), + onBack = {}, + ) + } + window.rootViewController = parent + parent.view.addSubview(host.view) + window.hidden = false + + awaitUIKitNavigation("UIKit navigation did not create its Page controller.") { + parent.childViewControllers + .filterIsInstance() + .singleOrNull() + ?.topViewController != null + } + + val navigationController = + parent.childViewControllers + .filterIsInstance() + .single() + val backgroundColor = navigationController.topViewController?.view?.backgroundColor + assertTrue( + backgroundColor?.isEqual(UIColor.systemBackgroundColor) == true, + "Every UIKit Page controller must provide the dynamic system background surface.", + ) + } finally { + host.dispose() + window.hidden = true + } + } +} + +private class UIKitNavigationFixture( + val navigationController: UINavigationController = UINavigationController(), +) { + val parent: UIViewController = UIViewController() + val widget: UIKitNavigationWidget = UIKitNavigationWidget(navigationController) + val home: ResolvedNavigationEntry = uikitEntry("home") + val detail: ResolvedNavigationEntry = uikitEntry("detail") + val editor: ResolvedNavigationEntry = uikitEntry("editor") + val settings: ResolvedNavigationEntry = uikitEntry("settings") + val subcompositions = UIKitRecordingSubcompositionFactory() + var backRequestCount: Int = 0 + private set + + private val dispatcher = NavigationModelDispatcher() + private val stagingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val window = UIWindow(frame = CGRectMake(0.0, 0.0, 320.0, 640.0)) + private var currentEntries: List = emptyList() + private var currentSubcompositions: FlareSubcompositionFactory = subcompositions + + init { + // These tests exercise command/delegate ordering and content retention, not Core Animation. + // Disabling the animation clock keeps native transitions deterministic in the CLI runner. + UIView.setAnimationsEnabled(false) + window.rootViewController = parent + widget.view.translatesAutoresizingMaskIntoConstraints = false + parent.view.addSubview(widget.view) + NSLayoutConstraint.activateConstraints( + listOf( + widget.view.leadingAnchor.constraintEqualToAnchor(parent.view.leadingAnchor), + widget.view.trailingAnchor.constraintEqualToAnchor(parent.view.trailingAnchor), + widget.view.topAnchor.constraintEqualToAnchor(parent.view.topAnchor), + widget.view.bottomAnchor.constraintEqualToAnchor(parent.view.bottomAnchor), + ), + ) + window.hidden = false + widget.setModelDispatcher(dispatcher) + } + + fun dispatch( + entries: List, + subcompositions: FlareSubcompositionFactory = currentSubcompositions, + ) { + currentEntries = entries + currentSubcompositions = subcompositions + dispatcher.dispatch(model(entries, subcompositions)) + } + + fun stage(entries: List) { + currentEntries = entries + dispatcher.stage(model(entries, currentSubcompositions), stagingScope) + } + + fun dispose() { + widget.dispose() + stagingScope.cancel() + window.hidden = true + UIView.setAnimationsEnabled(true) + } + + private fun model( + entries: List, + subcompositions: FlareSubcompositionFactory, + ): NavigationModel = + NavigationModel( + entries = entries, + onBack = { request -> + backRequestCount += 1 + request.accept() + dispatch(currentEntries.dropLast(request.popCount), currentSubcompositions) + }, + subcompositions = subcompositions, + nativeControllerOwner = UIKitNavigationOwner(parent), + ) +} + +private val UINavigationController.topEntryIdentity: NavigationEntryIdentity? + get() = (topViewController as? UIKitNavigationEntryController)?.identity + +private class RejectingPopNavigationController : UINavigationController(nibName = null, bundle = null) { + var popAttempts: Int = 0 + private set + + @ObjCSignatureOverride + override fun popViewControllerAnimated(animated: Boolean): UIViewController? { + popAttempts += 1 + return null + } +} + +private fun uikitEntry(contentKey: String): ResolvedNavigationEntry = + ResolvedNavigationEntry( + contentKey = contentKey, + presentation = NavigationPresentation.Page, + entry = + NavEntry( + key = contentKey, + contentKey = contentKey, + ) {}, + ) + +private class UIKitRecordingSubcompositionFactory : FlareSubcompositionFactory { + private val compositions = mutableListOf() + private var disposedFactory: Boolean = false + var created: Int = 0 + private set + var disposed: Int = 0 + private set + var deactivated: Int = 0 + private set + var installed: Int = 0 + private set + + override fun create(root: FlareChildren): FlareSubcomposition { + check(!disposedFactory) { "UIKit test subcomposition factory is already disposed." } + created += 1 + return UIKitRecordingSubcomposition( + onInstalled = { installed += 1 }, + onDeactivated = { deactivated += 1 }, + onDisposed = { disposed += 1 }, + ).also(compositions::add) + } + + fun disposeOwnedCompositions() { + disposedFactory = true + compositions.forEach(UIKitRecordingSubcomposition::dispose) + } +} + +private class UIKitRecordingSubcomposition( + private val onInstalled: () -> Unit, + private val onDeactivated: () -> Unit, + private val onDisposed: () -> Unit, +) : FlareSubcomposition { + private var disposed: Boolean = false + + override fun setContent(content: FlareContent) { + check(!disposed) { "UIKit test subcomposition is already disposed." } + onInstalled() + } + + override fun deactivate() { + check(!disposed) { "UIKit test subcomposition is already disposed." } + onDeactivated() + } + + override fun dispose() { + if (disposed) return + disposed = true + onDisposed() + } +} + +private interface WrapHeightLabelWidget : FlareWidget + +@Composable +@FlareUiComposable +private fun WrapHeightLabel() { + EmitFlareWidget(componentType = WrapHeightLabelWidget::class) +} + +private val wrapHeightLabelPlugin = + object : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(WrapHeightLabelWidget::class) { + object : + AbstractUIKitWidget( + UILabel().apply { + text = "Wrap height" + }, + ), + WrapHeightLabelWidget {} + } + } + } + +private fun awaitUIKitNavigation( + message: String, + condition: () -> Boolean, +) { + val startedAt = TimeSource.Monotonic.markNow() + while (!condition() && startedAt.elapsedNow() < 5.seconds) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, true) + } + check(condition()) { message } +} diff --git a/flareUI/navigation/src/jvmTest/kotlin/dev/dimension/flare/ui/navigation/NavigationDisplayTest.kt b/flareUI/navigation/src/jvmTest/kotlin/dev/dimension/flare/ui/navigation/NavigationDisplayTest.kt new file mode 100644 index 0000000000..fdb44be84d --- /dev/null +++ b/flareUI/navigation/src/jvmTest/kotlin/dev/dimension/flare/ui/navigation/NavigationDisplayTest.kt @@ -0,0 +1,441 @@ +@file:OptIn( + ExperimentalFlareNavigation::class, + dev.dimension.flare.ui.LowLevelFlareApi::class, +) + +package dev.dimension.flare.ui.navigation + +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Recomposer +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import dev.dimension.flare.ui.AbstractFlareWidget +import dev.dimension.flare.ui.FlareBackend +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareComposition +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareWidget +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.ProvideFlareNativeControllerOwner +import dev.dimension.flare.ui.currentFlareNativeControllerOwner +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +public class NavigationDisplayTest { + @Test + public fun conflatesStagedModelsBeforePostApplyDelivery(): Unit = + runBlocking { + val dispatcher = NavigationModelDispatcher() + val delivered = mutableListOf() + dispatcher.observe(delivered::add) + val stale = unusedDisplayModel() + val latest = unusedDisplayModel() + + dispatcher.stage(stale, this) + dispatcher.stage(latest, this) + assertTrue(dispatcher.hasUndeliveredModel) + withTimeout(1_000L) { + while (delivered.isEmpty()) yield() + } + + assertEquals(listOf(latest), delivered) + assertFalse(dispatcher.hasUndeliveredModel) + } + + @Test + public fun deliversTheModelOnlyAfterTheRendererIsInstalled(): Unit = + runNavigationDisplayTest { recomposer -> + val widget = RecordingNavigationWidget() + val composition = + FlareComposition( + root = RecordingChildren(), + widgetSystem = testWidgetSystem(widget), + backend = TestBackend, + parent = recomposer, + ) + + composition.setContent { + NavigationDisplay( + entries = + listOf( + NavEntry( + key = "home", + contentKey = "home", + ) {}, + ), + onBack = {}, + ) + } + + widget.awaitModel() + assertFalse(widget.modelWasDeliveredDuringDispatcherInstall) + composition.dispose() + } + + @Test + public fun decoratesABackStackWithoutEagerlyComposingEntries(): Unit = + runNavigationDisplayTest { recomposer -> + val root = RecordingChildren() + val widget = RecordingNavigationWidget() + val events = mutableListOf() + val provider: (DisplayTestRoute) -> NavEntry = + entryProvider { + entry { + events += "entry" + } + } + val decorators = + listOf( + recordingDecorator("first", events), + recordingDecorator("second", events), + ) + val composition = + FlareComposition( + root = root, + widgetSystem = testWidgetSystem(widget), + backend = TestBackend, + parent = recomposer, + ) + + composition.setContent { + NavigationDisplay( + backStack = listOf(DisplayTestHome), + onBack = {}, + entryDecorators = decorators, + entryProvider = provider, + ) + } + + val model = widget.awaitModel() + assertEquals(1, model.entries.size) + assertEquals(emptyList(), events) + + val entryComposition = model.subcompositions.create(RecordingChildren()) + entryComposition.setContent { + model.entries + .single() + .entry + .Content() + } + assertEquals( + listOf( + "first:before", + "second:before", + "entry", + "second:after", + "first:after", + ), + events, + ) + entryComposition.dispose() + composition.dispose() + } + + @Test + public fun highLevelBackRequestUsesRouteValuesAndAppliesDirectly(): Unit = + runNavigationDisplayTest { recomposer -> + val widget = RecordingNavigationWidget() + val backStack = listOf(DisplayTestHome, DisplayTestDetail) + var received: NavigationBackRequest? = null + val composition = + FlareComposition( + root = RecordingChildren(), + widgetSystem = testWidgetSystem(widget), + backend = TestBackend, + parent = recomposer, + ) + + try { + composition.setContent { + NavigationDisplay( + backStack = backStack, + onBack = { received = it }, + entryDecorators = emptyList(), + entryProvider = { route -> + NavEntry( + key = route, + contentKey = route, + ) {} + }, + ) + } + + val model = widget.awaitModel() + model.onBack( + NavigationBackRequest( + requestId = 42L, + baseRevision = 7L, + base = model.entries.topology(), + target = model.entries.dropLast(1).topology(), + popCount = 1, + isActiveRequest = { true }, + acceptRequest = { true }, + rejectRequest = { true }, + abortAcceptedRequest = { true }, + ), + ) + + val request = assertNotNull(received) + assertEquals(42L, request.requestId) + assertEquals(7L, request.baseRevision) + assertEquals(backStack, request.base) + assertEquals(listOf(DisplayTestHome), request.target) + val mutableBackStack = backStack.toMutableList() + assertTrue(request.applyTo(mutableBackStack)) + assertEquals(listOf(DisplayTestHome), mutableBackStack) + } finally { + composition.dispose() + } + } + + @Test + public fun deliversOneAtomicModelWithoutEagerlyComposingEntries(): Unit = + runNavigationDisplayTest { recomposer -> + val root = RecordingChildren() + val widget = RecordingNavigationWidget() + var entryCompositions = 0 + var requestedPopCount = 0 + val composition = + FlareComposition( + root = root, + widgetSystem = testWidgetSystem(widget), + backend = TestBackend, + parent = recomposer, + ) + + composition.setContent { + NavigationDisplay( + entries = + listOf( + NavEntry( + key = "home", + contentKey = "home", + ) { + entryCompositions += 1 + }, + ), + onBack = { requestedPopCount = it.popCount }, + ) + } + + val model = widget.awaitModel() + assertEquals(listOf("home"), model.entries.map(ResolvedNavigationEntry::contentKey)) + assertEquals(0, entryCompositions) + val entryComposition = model.subcompositions.create(RecordingChildren()) + entryComposition.setContent { + model.entries + .single() + .entry + .Content() + } + assertEquals(1, entryCompositions) + model.onBack(testBackRequest(popCount = 2)) + assertEquals(2, requestedPopCount) + + entryComposition.dispose() + composition.dispose() + assertEquals(emptyList(), root.widgets) + } + + @Test + public fun propagatesTheHostOwnerAndOverridesItForEntryContent(): Unit = + runNavigationDisplayTest { recomposer -> + val root = RecordingChildren() + val widget = RecordingNavigationWidget() + val hostOwner = TestNativeControllerOwner("host") + val entryOwner = TestNativeControllerOwner("entry") + var ownerSeenByEntry: FlareNativeControllerOwner? = null + val composition = + FlareComposition( + root = root, + widgetSystem = testWidgetSystem(widget), + backend = TestBackend, + parent = recomposer, + ) + + composition.setContent { + ProvideFlareNativeControllerOwner(hostOwner) { + NavigationDisplay( + entries = + listOf( + NavEntry( + key = "home", + contentKey = "home", + ) { + ownerSeenByEntry = currentFlareNativeControllerOwner() + }, + ), + onBack = {}, + ) + } + } + + val model = widget.awaitModel() + assertSame(hostOwner, model.nativeControllerOwner) + val entryHost = + NavigationEntryContentHost( + root = RecordingChildren(), + nativeControllerOwner = entryOwner, + subcompositions = model.subcompositions, + initialEntry = model.entries.single(), + ) + assertSame(entryOwner, ownerSeenByEntry) + + entryHost.dispose() + composition.dispose() + } +} + +private fun testBackRequest(popCount: Int): NavigationBackRequest { + val base = + List(popCount + 1) { index -> + NavigationEntryIdentity( + contentKey = "route-$index", + presentation = NavigationPresentation.Page, + ) + } + return NavigationBackRequest( + requestId = 1L, + baseRevision = 1L, + base = base, + target = base.dropLast(popCount), + popCount = popCount, + isActiveRequest = { true }, + acceptRequest = { true }, + rejectRequest = { true }, + abortAcceptedRequest = { true }, + ) +} + +private fun unusedDisplayModel(): NavigationModel = + NavigationModel( + entries = emptyList(), + onBack = {}, + subcompositions = UnusedDisplaySubcompositionFactory, + ) + +private fun runNavigationDisplayTest(block: suspend (Recomposer) -> Unit) { + runBlocking(BroadcastFrameClock()) { + val recomposer = Recomposer(coroutineContext) + val runner = launch { recomposer.runRecomposeAndApplyChanges() } + try { + block(recomposer) + } finally { + recomposer.close() + runner.join() + } + } +} + +private data object TestBackend : FlareBackend + +private data class TestNativeControllerOwner( + val label: String, +) : FlareNativeControllerOwner + +private sealed interface DisplayTestRoute + +private data object DisplayTestHome : DisplayTestRoute + +private data object DisplayTestDetail : DisplayTestRoute + +private object UnusedDisplaySubcompositionFactory : FlareSubcompositionFactory { + override fun create(root: FlareChildren): FlareSubcomposition = error("Dispatcher test does not compose entries.") +} + +private fun recordingDecorator( + label: String, + events: MutableList, +): NavEntryDecorator = + NavEntryDecorator( + decorate = { entry -> + events += "$label:before" + entry.Content() + events += "$label:after" + }, + ) + +private class RecordingNavigationWidget : + AbstractFlareWidget(), + NavigationWidget { + private var stopObservingModels: (() -> Unit)? = null + + var model: NavigationModel? = null + private set + + var modelWasDeliveredDuringDispatcherInstall: Boolean = false + private set + + override fun setModelDispatcher(dispatcher: NavigationModelDispatcher) { + stopObservingModels?.invoke() + var installing = true + stopObservingModels = + dispatcher.observe { model -> + modelWasDeliveredDuringDispatcherInstall = installing + this.model = model + } + installing = false + } + + override fun dispose() { + stopObservingModels?.invoke() + stopObservingModels = null + } + + suspend fun awaitModel(): NavigationModel = + withTimeout(1_000L) { + while (model == null) yield() + checkNotNull(model) + } +} + +private fun testWidgetSystem(widget: RecordingNavigationWidget): FlareWidgetSystem = + FlareWidgetSystem( + object : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(NavigationWidget::class) { widget } + } + }, + ) + +private class RecordingChildren : FlareChildren { + val widgets: MutableList = mutableListOf() + + override fun insert( + index: Int, + widget: FlareWidget, + ) { + widgets.add(index, widget) + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + val moved = widgets.subList(fromIndex, fromIndex + count).toList() + widgets.subList(fromIndex, fromIndex + count).clear() + val destination = if (fromIndex > toIndex) toIndex else toIndex - count + widgets.addAll(destination, moved) + } + + override fun remove( + index: Int, + count: Int, + ) { + widgets.subList(index, index + count).clear() + } +} diff --git a/flareUI/navigation/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationRenderer.kt b/flareUI/navigation/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationRenderer.kt new file mode 100644 index 0000000000..a7197da896 --- /dev/null +++ b/flareUI/navigation/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationRenderer.kt @@ -0,0 +1,1203 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.navigation.NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS +import dev.dimension.flare.ui.navigation.NavigationAcknowledgementHandle +import dev.dimension.flare.ui.navigation.NavigationCommand +import dev.dimension.flare.ui.navigation.NavigationCoordinator +import dev.dimension.flare.ui.navigation.NavigationCoordinatorState +import dev.dimension.flare.ui.navigation.NavigationEntryContentHost +import dev.dimension.flare.ui.navigation.NavigationEntryIdentity +import dev.dimension.flare.ui.navigation.NavigationInteractionHandle +import dev.dimension.flare.ui.navigation.NavigationModel +import dev.dimension.flare.ui.navigation.NavigationModelDispatcher +import dev.dimension.flare.ui.navigation.NavigationOperation +import dev.dimension.flare.ui.navigation.NavigationOperationResult +import dev.dimension.flare.ui.navigation.NavigationPresentation +import dev.dimension.flare.ui.navigation.NavigationWidget +import dev.dimension.flare.ui.navigation.ResolvedNavigationEntry +import dev.dimension.flare.ui.navigation.identity +import kotlinx.cinterop.CValue +import kotlinx.cinterop.pointed +import kotlinx.cinterop.useContents +import kotlinx.cinterop.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import platform.AppKit.NSAnimationContext +import platform.AppKit.NSColor +import platform.AppKit.NSEvent +import platform.AppKit.NSEventGestureAxisHorizontal +import platform.AppKit.NSEventPhaseBegan +import platform.AppKit.NSEventPhaseCancelled +import platform.AppKit.NSEventPhaseChanged +import platform.AppKit.NSEventPhaseEnded +import platform.AppKit.NSEventPhaseMayBegin +import platform.AppKit.NSEventPhaseNone +import platform.AppKit.NSEventSwipeTrackingClampGestureAmount +import platform.AppKit.NSEventSwipeTrackingLockDirection +import platform.AppKit.NSLayoutConstraint +import platform.AppKit.NSStackView +import platform.AppKit.NSUserInterfaceLayoutOrientationVertical +import platform.AppKit.NSView +import platform.AppKit.NSViewController +import platform.AppKit.NSWindowAbove +import platform.AppKit.NSWindowBelow +import platform.AppKit.NSWorkspace +import platform.AppKit.accessibilityDisplayShouldReduceMotion +import platform.AppKit.addChildViewController +import platform.AppKit.bottomAnchor +import platform.AppKit.childViewControllers +import platform.AppKit.leadingAnchor +import platform.AppKit.removeFromParentViewController +import platform.AppKit.topAnchor +import platform.AppKit.trailingAnchor +import platform.AppKit.translatesAutoresizingMaskIntoConstraints +import platform.AppKit.widthAnchor +import platform.CoreGraphics.CGPoint +import platform.CoreGraphics.CGRectMake +import kotlin.math.abs +import platform.AppKit.NSUserInterfaceLayoutDirectionRightToLeft as AppKitRightToLeft + +internal data class AppKitSwipeBackTrackingDirection( + val gestureAmountSign: Double, +) { + init { + require(abs(gestureAmountSign) == 1.0) + } + + val minimumDampenThreshold: Double + get() = if (gestureAmountSign < 0.0) -1.0 else 0.0 + + val maximumDampenThreshold: Double + get() = if (gestureAmountSign > 0.0) 1.0 else 0.0 + + fun progress(gestureAmount: Double): Double = (gestureAmount * gestureAmountSign).coerceIn(0.0, 1.0) +} + +internal fun appKitSwipeBackTrackingDirection( + scrollingDeltaX: Double, + scrollingDeltaY: Double, + directionInvertedFromDevice: Boolean, + rightToLeft: Boolean, +): AppKitSwipeBackTrackingDirection? { + if (scrollingDeltaX == 0.0 || abs(scrollingDeltaX) <= abs(scrollingDeltaY)) return null + + // NSEvent applies the user's natural-scrolling preference to scrollingDeltaX. Navigation is + // tied to the physical gesture direction, so undo that preference before checking whether the + // fingers are moving toward the trailing edge (right in LTR, left in RTL). + val trailingTravel = + (if (directionInvertedFromDevice) scrollingDeltaX else -scrollingDeltaX) * + (if (rightToLeft) -1.0 else 1.0) + if (trailingTravel <= 0.0) return null + + return AppKitSwipeBackTrackingDirection( + gestureAmountSign = if (scrollingDeltaX > 0.0) 1.0 else -1.0, + ) +} + +internal fun isAppKitDiscreteSwipeBack( + deltaX: Double, + deltaY: Double, + rightToLeft: Boolean, +): Boolean = + abs(deltaX) > abs(deltaY) && + if (rightToLeft) { + deltaX > 0.0 + } else { + deltaX < 0.0 + } + +internal data class AppKitSwipeBackOffsets( + val outgoing: Double, + val incoming: Double, +) + +internal enum class AppKitPageTransitionKind { + Push, + Pop, +} + +internal data class AppKitPageTransitionOffsets( + val source: Double, + val destination: Double, +) + +internal interface AppKitPageAnimationScheduler { + fun animate( + view: NSView, + durationSeconds: Double, + updateProgress: (Double) -> Unit, + completion: () -> Unit, + ) +} + +private object DefaultAppKitPageAnimationScheduler : AppKitPageAnimationScheduler { + override fun animate( + view: NSView, + durationSeconds: Double, + updateProgress: (Double) -> Unit, + completion: () -> Unit, + ) { + if (NSWorkspace.sharedWorkspace.accessibilityDisplayShouldReduceMotion) { + updateProgress(1.0) + view.layoutSubtreeIfNeeded() + completion() + return + } + NSAnimationContext.runAnimationGroup( + changes = { context -> + context?.duration = durationSeconds + context?.allowsImplicitAnimation = true + updateProgress(1.0) + view.layoutSubtreeIfNeeded() + }, + completionHandler = completion, + ) + } +} + +internal fun appKitSwipeBackOffsets( + progress: Double, + width: Double, +): AppKitSwipeBackOffsets { + val offsets = + appKitPageTransitionOffsets( + kind = AppKitPageTransitionKind.Pop, + progress = progress, + width = width, + ) + return AppKitSwipeBackOffsets( + outgoing = offsets.source, + incoming = offsets.destination, + ) +} + +internal fun appKitPageTransitionOffsets( + kind: AppKitPageTransitionKind, + progress: Double, + width: Double, +): AppKitPageTransitionOffsets { + val boundedProgress = progress.coerceIn(0.0, 1.0) + val boundedWidth = width.coerceAtLeast(0.0) + return when (kind) { + AppKitPageTransitionKind.Push -> { + AppKitPageTransitionOffsets( + source = + if (boundedProgress == 0.0) { + 0.0 + } else { + -boundedWidth / 3.0 * boundedProgress + }, + destination = + if (boundedProgress == 1.0) { + 0.0 + } else { + boundedWidth * (1.0 - boundedProgress) + }, + ) + } + + AppKitPageTransitionKind.Pop -> { + AppKitPageTransitionOffsets( + source = + if (boundedProgress == 0.0) { + 0.0 + } else { + boundedWidth * boundedProgress + }, + destination = + if (boundedProgress == 1.0) { + 0.0 + } else { + -boundedWidth / 3.0 * (1.0 - boundedProgress) + }, + ) + } + } +} + +internal class AppKitSwipeBackTrackingSession( + private val direction: AppKitSwipeBackTrackingDirection, + onProgress: (Double) -> Unit, + onComplete: (committed: Boolean) -> Unit, +) { + private var active: Boolean = true + private var progressCallback: ((Double) -> Unit)? = onProgress + private var completionCallback: ((Boolean) -> Unit)? = onComplete + + fun update( + gestureAmount: Double, + isComplete: Boolean, + ) { + if (!active) return + val progress = direction.progress(gestureAmount) + progressCallback?.invoke(progress) + if (isComplete) { + val completion = completionCallback + active = false + progressCallback = null + completionCallback = null + completion?.invoke(progress >= 0.5) + } + } + + fun invalidate() { + active = false + progressCallback = null + completionCallback = null + } +} + +internal interface AppKitSwipeBackDelegate { + fun canBeginSwipeBack(): Boolean + + fun beginSwipeBack(): Boolean + + fun updateSwipeBack(progress: Double) + + fun finishSwipeBack(committed: Boolean) + + fun performDiscreteSwipeBack(): Boolean +} + +internal class AppKitNavigationContainerView : NSView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + var swipeBackDelegate: AppKitSwipeBackDelegate? = null + internal var blocksPageTransitionInteraction: Boolean = false + private var trackingSession: AppKitSwipeBackTrackingSession? = null + private var scrollGestureRejected: Boolean = false + + init { + clipsToBounds = true + wantsLayer = true + } + + override fun hitTest(point: CValue): NSView? { + val hitView = super.hitTest(point) + return if (blocksPageTransitionInteraction && hitView != null) this else hitView + } + + override fun wantsScrollEventsForSwipeTrackingOnAxis(axis: Long): Boolean = + axis == NSEventGestureAxisHorizontal && + NSEvent.swipeTrackingFromScrollEventsEnabled && + swipeBackDelegate?.canBeginSwipeBack() == true + + override fun scrollWheel(event: NSEvent) { + if (trackingSession != null) return + when (event.phase) { + NSEventPhaseMayBegin -> { + scrollGestureRejected = false + super.scrollWheel(event) + return + } + + NSEventPhaseEnded, + NSEventPhaseCancelled, + NSEventPhaseNone, + -> { + scrollGestureRejected = false + super.scrollWheel(event) + return + } + + NSEventPhaseBegan -> { + scrollGestureRejected = false + } + } + if (event.phase != NSEventPhaseBegan && event.phase != NSEventPhaseChanged) { + super.scrollWheel(event) + return + } + if (scrollGestureRejected) { + super.scrollWheel(event) + return + } + + val delegate = swipeBackDelegate + val direction = + appKitSwipeBackTrackingDirection( + scrollingDeltaX = event.scrollingDeltaX, + scrollingDeltaY = event.scrollingDeltaY, + directionInvertedFromDevice = event.directionInvertedFromDevice, + rightToLeft = userInterfaceLayoutDirection == AppKitRightToLeft, + ) + if (direction == null) { + if (event.scrollingDeltaX != 0.0 || event.scrollingDeltaY != 0.0) { + scrollGestureRejected = true + } + super.scrollWheel(event) + return + } + if (delegate == null || + !NSEvent.swipeTrackingFromScrollEventsEnabled || + !delegate.beginSwipeBack() + ) { + scrollGestureRejected = true + super.scrollWheel(event) + return + } + + val session = + AppKitSwipeBackTrackingSession( + direction = direction, + onProgress = delegate::updateSwipeBack, + onComplete = delegate::finishSwipeBack, + ) + trackingSession = session + event.trackSwipeEventWithOptions( + options = NSEventSwipeTrackingLockDirection or NSEventSwipeTrackingClampGestureAmount, + dampenAmountThresholdMin = direction.minimumDampenThreshold, + max = direction.maximumDampenThreshold, + usingHandler = handler@{ gestureAmount, _, isComplete, stop -> + if (trackingSession !== session) { + stop?.pointed?.value = true + return@handler + } + if (isComplete && trackingSession === session) trackingSession = null + session.update(gestureAmount, isComplete) + }, + ) + } + + override fun swipeWithEvent(event: NSEvent) { + val isBack = + isAppKitDiscreteSwipeBack( + deltaX = event.deltaX, + deltaY = event.deltaY, + rightToLeft = userInterfaceLayoutDirection == AppKitRightToLeft, + ) + if (isBack && swipeBackDelegate?.performDiscreteSwipeBack() == true) return + super.swipeWithEvent(event) + } + + fun invalidateSwipeTracking() { + trackingSession?.invalidate() + trackingSession = null + scrollGestureRejected = false + swipeBackDelegate = null + } +} + +/** + * Supplies an AppKit controller to [NavigationDisplay][dev.dimension.flare.ui.navigation.NavigationDisplay]. + * + * The renderer installs one child container controller below [parent]. Each page is then hosted in + * its own child controller, making this owner safe to use for nested NavigationDisplay instances. + */ +public class AppKitNavigationOwner( + public val parent: NSViewController, +) : FlareNativeControllerOwner + +/** AppKit renderer plugin for Page-only Flare Navigation. */ +public object AppKitNavigationRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(NavigationWidget::class) { AppKitNavigationWidget() } + } +} + +/** + * This is deliberately a controller-backed widget. [view] is only the container controller's + * carrier view; it is not a view-managed navigation stack. + */ +internal class AppKitNavigationWidget( + private val navigationView: AppKitNavigationContainerView = AppKitNavigationContainerView(), + private val pageAnimationScheduler: AppKitPageAnimationScheduler = DefaultAppKitPageAnimationScheduler, +) : AbstractAppKitWidget(navigationView), + AppKitSwipeBackDelegate, + NavigationWidget { + private val container = NSViewController().apply { view = navigationView } + private val entries = linkedMapOf() + private val viewConstraints = mutableMapOf>() + private val acknowledgementScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val coordinator = + NavigationCoordinator( + emitCommand = ::execute, + onRetainedEntriesChanged = ::updateRetainedEntries, + ) + private var owner: AppKitNavigationOwner? = null + private var subcompositions: FlareSubcompositionFactory? = null + private var modelDispatcher: NavigationModelDispatcher? = null + private var stopObservingModels: (() -> Unit)? = null + private var transitionGeneration: Long = 0L + private var activePageTransition: AppKitPageSlideTransition? = null + private var interactionBlockingTransition: AppKitPageSlideTransition? = null + private var swipeInteraction: AppKitSwipeBackInteraction? = null + private var acknowledgementJob: Job? = null + private var disposed: Boolean = false + + init { + navigationView.swipeBackDelegate = this + } + + override fun setModelDispatcher(dispatcher: NavigationModelDispatcher) { + check(!disposed) { "AppKit navigation widget is already disposed." } + stopObservingModels?.invoke() + modelDispatcher = dispatcher + stopObservingModels = dispatcher.observe(::applyModel) + } + + private fun applyModel(model: NavigationModel) { + check(!disposed) { "AppKit navigation widget is already disposed." } + require(model.entries.all { it.presentation == NavigationPresentation.Page }) { + "AppKit navigation supports only Page presentation; overlays are not available." + } + val newOwner = + model.nativeControllerOwner as? AppKitNavigationOwner + ?: error( + "AppKit NavigationDisplay requires AppKitNavigationOwner from a controller-aware host.", + ) + bindOwner(newOwner) + subcompositions = model.subcompositions + val wasPaused = coordinator.state == NavigationCoordinatorState.Paused + coordinator.setModel(model) + if (!coordinator.hasPendingAcknowledgement) { + clearAcknowledgementTimeout() + } + // A fresh applied model is a bounded retry opportunity if the preceding native recovery + // failed. Retrying here avoids a permanently paused projection without a callback loop. + if (wasPaused) coordinator.resumeOperations() + applyContentRetentionPolicyIfStable() + } + + override fun dispose() { + if (disposed) return + disposed = true + transitionGeneration += 1L + stopObservingModels?.invoke() + stopObservingModels = null + modelDispatcher = null + navigationView.invalidateSwipeTracking() + activePageTransition?.constraints?.let(NSLayoutConstraint::deactivateConstraints) + activePageTransition = null + interactionBlockingTransition = null + navigationView.blocksPageTransitionInteraction = false + swipeInteraction = null + clearAcknowledgementTimeout() + acknowledgementScope.cancel() + coordinator.dispose() + clearPhysicalProjection() + entries.values.forEach(AppKitNavigationEntryController::dispose) + entries.clear() + NSLayoutConstraint.deactivateConstraints(viewConstraints.values.flatten()) + viewConstraints.clear() + subcompositions = null + container.removeFromParentViewController() + owner = null + } + + private fun bindOwner(value: AppKitNavigationOwner) { + val current = owner + when { + current == null -> { + owner = value + value.parent.addChildViewController(container) + } + + current.parent != value.parent -> { + error("An AppKit NavigationDisplay cannot move to a different parent controller.") + } + } + } + + private fun updateRetainedEntries(value: List) { + if (disposed) return + val subcompositions = requireSubcompositions() + val retained = value.associateBy(ResolvedNavigationEntry::identity) + val physicalControllers = container.childViewControllers + value.forEach { entry -> + entries[entry.identity()]?.update(entry, subcompositions) + ?: AppKitNavigationEntryController(entry, subcompositions).also { + entries[entry.identity()] = it + } + } + val obsolete = entries.keys.filter { it !in retained } + obsolete.forEach { identity -> + val controller = checkNotNull(entries[identity]) + check(physicalControllers.none { it == controller }) { + "AppKit navigation released a controller that is still displayed." + } + entries.remove(identity) + removeController(controller) + controller.dispose() + } + } + + private fun requireSubcompositions(): FlareSubcompositionFactory = + checkNotNull(subcompositions) { + "Navigation entries must be retained only after their NavigationModel is installed." + } + + private fun execute(command: NavigationCommand) { + check(activePageTransition == null) { + "AppKit navigation command overlapped an active page transition." + } + try { + when (val operation = command.operation) { + is NavigationOperation.Reconstruct -> { + reconstruct(command, operation.targetStack) + } + + is NavigationOperation.PushPage -> { + push(command, operation) + } + + is NavigationOperation.PopPage -> { + pop(command, operation) + } + + is NavigationOperation.PresentOverlay, + is NavigationOperation.DismissOverlay, + -> { + error("AppKit navigation supports only Page presentation; overlay operation received.") + } + } + } catch (error: Throwable) { + coordinator.completeCommand(command.token, NavigationOperationResult.Failed(error)) + } + } + + private fun reconstruct( + command: NavigationCommand, + target: List, + ) { + finishCommand(command) { + clearPhysicalProjection() + val controllers = target.map(::controllerFor) + controllers.forEach(container::addChildViewController) + controllers.lastOrNull()?.let(::installInitialView) + applyContentRetentionPolicy() + } + } + + private fun push( + command: NavigationCommand, + operation: NavigationOperation.PushPage, + ) { + val projection = physicalControllers() + val from = + projection.lastOrNull() + ?: error("AppKit navigation cannot push without a displayed root controller.") + val to = controllerFor(operation.entry) + check(to !in projection) { "AppKit navigation attempted to push an existing page controller." } + val generation = ++transitionGeneration + var transition: AppKitPageSlideTransition? = null + var addedController = false + try { + container.addChildViewController(to) + addedController = true + val prepared = preparePagePushTransition(from = from, to = to) + transition = prepared + activePageTransition = prepared + animatePageTransitionToEnd( + transition = prepared, + kind = AppKitPageTransitionKind.Push, + ) completion@{ + if ( + disposed || + generation != transitionGeneration || + activePageTransition !== prepared + ) { + return@completion + } + activePageTransition = null + finishCommand(command) { + finalizePagePushTransition(prepared, committed = true) + } + } + } catch (error: Throwable) { + if (transition != null && activePageTransition === transition) { + activePageTransition = null + runCatching { + finalizePagePushTransition(transition, committed = false) + }.exceptionOrNull()?.let(error::addSuppressed) + } else if (transition == null && addedController) { + runCatching { + removeController(to) + if (from.view.superview == null) navigationView.addSubview(from.view) + pin(from) + applyContentRetentionPolicy() + navigationView.layoutSubtreeIfNeeded() + }.exceptionOrNull()?.let(error::addSuppressed) + } + throw error + } + } + + private fun pop( + command: NavigationCommand, + operation: NavigationOperation.PopPage, + ) { + val projection = physicalControllers() + val from = + projection.lastOrNull() + ?: error("AppKit navigation cannot pop without a displayed page controller.") + check(from.identity == operation.entry.identity()) { + "AppKit navigation pop source does not match the displayed page controller." + } + val to = + projection.getOrNull(projection.lastIndex - 1) + ?: error("AppKit navigation cannot pop its root page controller.") + val generation = ++transitionGeneration + val transition = preparePagePopTransition(from = from, to = to) + activePageTransition = transition + try { + animatePageTransitionToEnd( + transition = transition, + kind = AppKitPageTransitionKind.Pop, + ) completion@{ + if ( + disposed || + generation != transitionGeneration || + activePageTransition !== transition + ) { + return@completion + } + activePageTransition = null + finishCommand(command) { + finalizePagePopTransition(transition, committed = true) + } + } + } catch (error: Throwable) { + if (activePageTransition === transition) { + activePageTransition = null + runCatching { + finalizePagePopTransition(transition, committed = false) + }.exceptionOrNull()?.let(error::addSuppressed) + } + throw error + } + } + + override fun canBeginSwipeBack(): Boolean = + !disposed && + activePageTransition == null && + swipeInteraction == null && + modelDispatcher?.hasUndeliveredModel != true && + coordinator.canBeginUserBack() + + override fun beginSwipeBack(): Boolean { + if (!canBeginSwipeBack()) return false + val handle = coordinator.beginUserBack() ?: return false + var preparedTransition: AppKitPageSlideTransition? = null + + return try { + val projection = physicalControllers() + val source = + projection.lastOrNull() + ?: error("AppKit navigation cannot begin swipe-back without a displayed page.") + val destination = + projection.getOrNull(projection.lastIndex - 1) + ?: error("AppKit navigation cannot swipe back from its root page.") + val transition = preparePagePopTransition(from = source, to = destination) + preparedTransition = transition + val interaction = + AppKitSwipeBackInteraction( + handle = handle, + transition = transition, + ) + activePageTransition = transition + swipeInteraction = interaction + true + } catch (_: Throwable) { + swipeInteraction = null + preparedTransition?.let { transition -> + if (activePageTransition === transition) activePageTransition = null + runCatching { + finalizePagePopTransition(transition, committed = false) + } + } + coordinator.failUserBack(handle) + false + } + } + + override fun updateSwipeBack(progress: Double) { + val interaction = swipeInteraction ?: return + val transition = interaction.transition + if (activePageTransition !== transition) return + setPageTransitionProgress(transition, AppKitPageTransitionKind.Pop, progress) + navigationView.layoutSubtreeIfNeeded() + } + + override fun finishSwipeBack(committed: Boolean) { + val interaction = swipeInteraction ?: return + val transition = interaction.transition + swipeInteraction = null + stopBlockingPageTransitionInteraction(transition) + if (activePageTransition === transition) activePageTransition = null + val finalized = + try { + finalizePagePopTransition(transition, committed) + true + } catch (_: Throwable) { + false + } + if (!finalized) { + coordinator.failUserBack(interaction.handle) + return + } + + if (committed) { + coordinator.commitUserBack(interaction.handle)?.let(::scheduleAcknowledgementTimeout) + } else { + coordinator.cancelUserBack(interaction.handle) + } + } + + override fun performDiscreteSwipeBack(): Boolean { + if (!beginSwipeBack()) return false + val interaction = swipeInteraction ?: return false + val transition = interaction.transition + return try { + animatePageTransitionToEnd( + transition = transition, + kind = AppKitPageTransitionKind.Pop, + ) { + if ( + !disposed && + activePageTransition === transition && + swipeInteraction === interaction + ) { + finishSwipeBack(committed = true) + } + } + true + } catch (_: Throwable) { + if (swipeInteraction === interaction) { + swipeInteraction = null + if (activePageTransition === transition) activePageTransition = null + val restored = + runCatching { + finalizePagePopTransition(transition, committed = false) + }.isSuccess + if (restored) { + coordinator.cancelUserBack(interaction.handle) + } else { + coordinator.failUserBack(interaction.handle) + } + } + false + } + } + + private fun preparePagePushTransition( + from: AppKitNavigationEntryController, + to: AppKitNavigationEntryController, + ): AppKitPageSlideTransition { + check(activePageTransition == null) { + "AppKit navigation cannot prepare two page transitions at once." + } + check(from != to) { "AppKit navigation page-push source and destination must differ." } + var transition: AppKitPageSlideTransition? = null + try { + from.realizeContent() + to.realizeContent() + val fromView = from.view + val toView = to.view + unpin(from) + unpin(to) + fromView.translatesAutoresizingMaskIntoConstraints = false + toView.translatesAutoresizingMaskIntoConstraints = false + navigationView.addSubview(toView, positioned = NSWindowAbove, relativeTo = fromView) + + val prepared = createPageSlideTransition(from = from, to = to) + transition = prepared + setPageTransitionProgress(prepared, AppKitPageTransitionKind.Push, progress = 0.0) + NSLayoutConstraint.activateConstraints(prepared.constraints) + navigationView.layoutSubtreeIfNeeded() + return prepared + } catch (error: Throwable) { + transition?.constraints?.let(NSLayoutConstraint::deactivateConstraints) + runCatching { + to.view.removeFromSuperview() + if (from.view.superview == null) navigationView.addSubview(from.view) + pin(from) + navigationView.layoutSubtreeIfNeeded() + }.exceptionOrNull()?.let(error::addSuppressed) + throw error + } + } + + private fun preparePagePopTransition( + from: AppKitNavigationEntryController, + to: AppKitNavigationEntryController, + ): AppKitPageSlideTransition { + check(activePageTransition == null) { + "AppKit navigation cannot prepare two page transitions at once." + } + check(from != to) { "AppKit navigation page-pop source and destination must differ." } + var transition: AppKitPageSlideTransition? = null + try { + from.realizeContent() + to.realizeContent() + val fromView = from.view + val toView = to.view + unpin(from) + unpin(to) + fromView.translatesAutoresizingMaskIntoConstraints = false + toView.translatesAutoresizingMaskIntoConstraints = false + navigationView.addSubview(toView, positioned = NSWindowBelow, relativeTo = fromView) + + val prepared = createPageSlideTransition(from = from, to = to) + transition = prepared + setPageTransitionProgress(prepared, AppKitPageTransitionKind.Pop, progress = 0.0) + NSLayoutConstraint.activateConstraints(prepared.constraints) + navigationView.layoutSubtreeIfNeeded() + return prepared + } catch (error: Throwable) { + transition?.constraints?.let(NSLayoutConstraint::deactivateConstraints) + runCatching { + to.view.removeFromSuperview() + pin(from) + applyContentRetentionPolicy() + navigationView.layoutSubtreeIfNeeded() + }.exceptionOrNull()?.let(error::addSuppressed) + throw error + } + } + + private fun createPageSlideTransition( + from: AppKitNavigationEntryController, + to: AppKitNavigationEntryController, + ): AppKitPageSlideTransition { + val fromView = from.view + val toView = to.view + val fromLeading = fromView.leadingAnchor.constraintEqualToAnchor(navigationView.leadingAnchor) + val toLeading = toView.leadingAnchor.constraintEqualToAnchor(navigationView.leadingAnchor) + return AppKitPageSlideTransition( + from = from, + to = to, + fromLeading = fromLeading, + toLeading = toLeading, + constraints = + listOf( + fromLeading, + fromView.widthAnchor.constraintEqualToAnchor(navigationView.widthAnchor), + fromView.topAnchor.constraintEqualToAnchor(navigationView.topAnchor), + fromView.bottomAnchor.constraintEqualToAnchor(navigationView.bottomAnchor), + toLeading, + toView.widthAnchor.constraintEqualToAnchor(navigationView.widthAnchor), + toView.topAnchor.constraintEqualToAnchor(navigationView.topAnchor), + toView.bottomAnchor.constraintEqualToAnchor(navigationView.bottomAnchor), + ), + ) + } + + private fun setPageTransitionProgress( + transition: AppKitPageSlideTransition, + kind: AppKitPageTransitionKind, + progress: Double, + ) { + val width = navigationView.bounds.useContents { size.width } + val offsets = + appKitPageTransitionOffsets( + kind = kind, + progress = progress, + width = width, + ) + transition.fromLeading.constant = offsets.source + transition.toLeading.constant = offsets.destination + } + + private fun animatePageTransitionToEnd( + transition: AppKitPageSlideTransition, + kind: AppKitPageTransitionKind, + completion: () -> Unit, + ) { + check(interactionBlockingTransition == null) { + "AppKit navigation cannot block interaction for two page transitions at once." + } + interactionBlockingTransition = transition + navigationView.blocksPageTransitionInteraction = true + try { + pageAnimationScheduler.animate( + view = navigationView, + durationSeconds = APPKIT_PAGE_TRANSITION_DURATION_SECONDS, + updateProgress = { progress -> + if (activePageTransition === transition) { + setPageTransitionProgress(transition, kind, progress) + } + }, + completion = { + stopBlockingPageTransitionInteraction(transition) + completion() + }, + ) + } catch (error: Throwable) { + stopBlockingPageTransitionInteraction(transition) + throw error + } + } + + private fun stopBlockingPageTransitionInteraction(transition: AppKitPageSlideTransition) { + if (interactionBlockingTransition !== transition) return + interactionBlockingTransition = null + navigationView.blocksPageTransitionInteraction = false + } + + private fun finalizePagePushTransition( + transition: AppKitPageSlideTransition, + committed: Boolean, + ) { + NSLayoutConstraint.deactivateConstraints(transition.constraints) + if (committed) { + val controllers = physicalControllers() + check( + controllers.lastOrNull() == transition.to && + controllers.getOrNull(controllers.lastIndex - 1) == transition.from, + ) { + "AppKit page-push controllers are no longer at the top of the physical stack." + } + transition.from.view.removeFromSuperview() + pin(transition.to) + } else { + removeController(transition.to) + if (transition.from.view.superview == null) navigationView.addSubview(transition.from.view) + pin(transition.from) + } + applyContentRetentionPolicy() + navigationView.layoutSubtreeIfNeeded() + } + + private fun finalizePagePopTransition( + transition: AppKitPageSlideTransition, + committed: Boolean, + ) { + NSLayoutConstraint.deactivateConstraints(transition.constraints) + if (committed) { + check(physicalControllers().lastOrNull() == transition.from) { + "AppKit page-pop source is no longer the displayed page." + } + removeController(transition.from) + pin(transition.to) + } else { + transition.to.view.removeFromSuperview() + pin(transition.from) + } + applyContentRetentionPolicy() + navigationView.layoutSubtreeIfNeeded() + } + + private fun scheduleAcknowledgementTimeout(handle: NavigationAcknowledgementHandle) { + clearAcknowledgementTimeout() + acknowledgementJob = + acknowledgementScope.launch { + delay(NAVIGATION_ACKNOWLEDGEMENT_TIMEOUT_MILLIS) + acknowledgementJob = null + if (!disposed) coordinator.acknowledgementDeadlineReached(handle) + } + } + + private fun clearAcknowledgementTimeout() { + acknowledgementJob?.cancel() + acknowledgementJob = null + } + + private inline fun finishCommand( + command: NavigationCommand, + mutation: () -> Unit, + ) { + val result = + try { + mutation() + NavigationOperationResult.Succeeded(observedTopology()) + } catch (error: Throwable) { + NavigationOperationResult.Failed(error) + } + coordinator.completeCommand( + token = command.token, + result = result, + ) + } + + private fun observedTopology(): List { + val controllers = physicalControllers() + val visibleView = navigationView.subviews.singleOrNull() + val expectedView = controllers.lastOrNull()?.view + // Objective-C collections may vend a new Kotlin wrapper for the same native object, so + // NSObject equality is the correct identity check here rather than Kotlin referential + // equality. + check(visibleView == expectedView) { + "AppKit navigation visible view does not match its physical controller stack." + } + return controllers.map(AppKitNavigationEntryController::identity) + } + + private fun physicalControllers(): List = + container.childViewControllers.map { controller -> + val entryController = + controller as? AppKitNavigationEntryController + ?: error("AppKit navigation container contains a controller not owned by Flare navigation.") + check(entries[entryController.identity] == entryController) { + "AppKit navigation container contains an entry controller owned by another projection." + } + entryController + } + + private fun clearPhysicalProjection() { + NSLayoutConstraint.deactivateConstraints(viewConstraints.values.flatten()) + viewConstraints.clear() + navigationView.subviews.map { it as NSView }.forEach { it.removeFromSuperview() } + container.childViewControllers.map { it as NSViewController }.asReversed().forEach { controller -> + if (controller is AppKitNavigationEntryController) { + removeController(controller) + if (entries[controller.identity] != controller) controller.dispose() + } else { + controller.view.removeFromSuperview() + controller.removeFromParentViewController() + } + } + } + + /** Keeps the current page active and one frozen predecessor ready for interactive back. */ + private fun applyContentRetentionPolicyIfStable() { + if (disposed || activePageTransition != null || swipeInteraction != null) return + applyContentRetentionPolicy() + } + + private fun applyContentRetentionPolicy() { + val controllers = physicalControllers() + controllers.forEachIndexed { index, controller -> + when (index) { + controllers.lastIndex -> controller.realizeContent() + controllers.lastIndex - 1 -> controller.deactivateContent() + else -> controller.releaseContent() + } + } + } + + private fun controllerFor(entry: ResolvedNavigationEntry): AppKitNavigationEntryController = + checkNotNull(entries[entry.identity()]) { + "AppKit navigation has no retained controller for ${entry.identity()}." + } + + private fun installInitialView(controller: AppKitNavigationEntryController) { + controller.realizeContent() + val entryView = controller.view + entryView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(entryView) + pin(controller) + } + + private fun pin(controller: AppKitNavigationEntryController) { + if (viewConstraints.containsKey(controller)) return + val entryView = controller.view + entryView.translatesAutoresizingMaskIntoConstraints = false + val constraints = + listOf( + entryView.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor), + entryView.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor), + entryView.topAnchor.constraintEqualToAnchor(view.topAnchor), + entryView.bottomAnchor.constraintEqualToAnchor(view.bottomAnchor), + ) + viewConstraints[controller] = constraints + NSLayoutConstraint.activateConstraints(constraints) + } + + private fun unpin(controller: AppKitNavigationEntryController) { + NSLayoutConstraint.deactivateConstraints(viewConstraints.remove(controller).orEmpty()) + } + + private fun removeController(controller: AppKitNavigationEntryController) { + unpin(controller) + controller.view.removeFromSuperview() + controller.removeFromParentViewController() + controller.releaseContent() + } +} + +private data class AppKitPageSlideTransition( + val from: AppKitNavigationEntryController, + val to: AppKitNavigationEntryController, + val fromLeading: NSLayoutConstraint, + val toLeading: NSLayoutConstraint, + val constraints: List, +) + +private data class AppKitSwipeBackInteraction( + val handle: NavigationInteractionHandle, + val transition: AppKitPageSlideTransition, +) + +private const val APPKIT_PAGE_TRANSITION_DURATION_SECONDS: Double = 0.2 + +internal class AppKitNavigationEntryController( + initialEntry: ResolvedNavigationEntry, + private var subcompositions: FlareSubcompositionFactory, +) : NSViewController(nibName = null, bundle = null) { + private val contentRoot = + NSStackView().apply { + orientation = NSUserInterfaceLayoutOrientationVertical + wantsLayer = true + layer?.backgroundColor = NSColor.windowBackgroundColor.CGColor + } + private var entry: ResolvedNavigationEntry = initialEntry + private var contentHost: NavigationEntryContentHost? = null + private var disposed: Boolean = false + + val identity: NavigationEntryIdentity + get() = entry.identity() + + init { + view = contentRoot + } + + fun update( + entry: ResolvedNavigationEntry, + subcompositions: FlareSubcompositionFactory = this.subcompositions, + ) { + check(!disposed) { "AppKit navigation entry controller is already disposed." } + require(entry.identity() == identity) { + "An AppKit navigation entry controller cannot change identity." + } + if (this.subcompositions !== subcompositions) { + releaseContent() + this.subcompositions = subcompositions + } + this.entry = entry + contentHost?.update(entry) + } + + internal fun realizeContent() { + check(!disposed) { "AppKit navigation entry controller is already disposed." } + ensureContentHost().activate() + } + + internal fun deactivateContent() { + check(!disposed) { "AppKit navigation entry controller is already disposed." } + ensureContentHost().deactivate() + } + + private fun ensureContentHost(): NavigationEntryContentHost = + contentHost + ?: NavigationEntryContentHost( + root = AppKitChildren(contentRoot), + nativeControllerOwner = AppKitNavigationOwner(this), + subcompositions = subcompositions, + initialEntry = entry, + ).also { contentHost = it } + + internal fun releaseContent() { + contentHost?.dispose() + contentHost = null + } + + fun dispose() { + if (disposed) return + disposed = true + releaseContent() + } +} diff --git a/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationEntryControllerTest.kt b/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationEntryControllerTest.kt new file mode 100644 index 0000000000..9a274efead --- /dev/null +++ b/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationEntryControllerTest.kt @@ -0,0 +1,129 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.appkit + +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.navigation.NavigationPresentation +import dev.dimension.flare.ui.navigation.ResolvedNavigationEntry +import kotlin.test.Test +import kotlin.test.assertEquals + +public class AppKitNavigationEntryControllerTest { + @Test + public fun retainsControllerIdentityWithoutRetainingHiddenContent() { + val factory = RecordingSubcompositionFactory() + val controller = + AppKitNavigationEntryController( + initialEntry = entry("home"), + subcompositions = factory, + ) + + controller.view + assertEquals(0, factory.created) + + controller.realizeContent() + controller.realizeContent() + assertEquals(1, factory.created) + assertEquals(0, factory.disposed) + + controller.releaseContent() + controller.releaseContent() + assertEquals(1, factory.disposed) + + controller.realizeContent() + assertEquals(2, factory.created) + + controller.dispose() + controller.dispose() + assertEquals(2, factory.disposed) + } + + @Test + public fun deactivatedContentKeepsItsHostAndReactivatesWithoutRecreation() { + val factory = RecordingSubcompositionFactory() + val controller = + AppKitNavigationEntryController( + initialEntry = entry("home"), + subcompositions = factory, + ) + + controller.realizeContent() + controller.deactivateContent() + controller.deactivateContent() + + assertEquals(1, factory.created) + assertEquals(1, factory.deactivated) + assertEquals(1, factory.installed) + assertEquals(0, factory.disposed) + + controller.realizeContent() + + assertEquals(1, factory.created) + assertEquals(2, factory.installed) + assertEquals(0, factory.disposed) + + controller.dispose() + assertEquals(1, factory.disposed) + } +} + +private fun entry(contentKey: String): ResolvedNavigationEntry = + ResolvedNavigationEntry( + contentKey = contentKey, + presentation = NavigationPresentation.Page, + entry = + NavEntry( + key = contentKey, + contentKey = contentKey, + ) {}, + ) + +private class RecordingSubcompositionFactory : FlareSubcompositionFactory { + var created: Int = 0 + private set + var disposed: Int = 0 + private set + var deactivated: Int = 0 + private set + var installed: Int = 0 + private set + + override fun create(root: FlareChildren): FlareSubcomposition { + created += 1 + return RecordingSubcomposition( + onInstalled = { installed += 1 }, + onDeactivated = { deactivated += 1 }, + onDisposed = { disposed += 1 }, + ) + } +} + +private class RecordingSubcomposition( + private val onInstalled: () -> Unit, + private val onDeactivated: () -> Unit, + private val onDisposed: () -> Unit, +) : FlareSubcomposition { + private var disposed: Boolean = false + + override fun setContent(content: FlareContent) { + onInstalled() + } + + override fun deactivate() { + onDeactivated() + } + + override fun dispose() { + if (disposed) return + disposed = true + onDisposed() + } +} diff --git a/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationSwipeBackTest.kt b/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationSwipeBackTest.kt new file mode 100644 index 0000000000..25709e0aee --- /dev/null +++ b/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationSwipeBackTest.kt @@ -0,0 +1,353 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.appkit + +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.navigation.NavigationBackRequest +import dev.dimension.flare.ui.navigation.NavigationEntryIdentity +import dev.dimension.flare.ui.navigation.NavigationModel +import dev.dimension.flare.ui.navigation.NavigationModelDispatcher +import dev.dimension.flare.ui.navigation.NavigationPresentation +import dev.dimension.flare.ui.navigation.ResolvedNavigationEntry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import platform.AppKit.NSView +import platform.AppKit.NSViewController +import platform.AppKit.childViewControllers +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +public class AppKitNavigationSwipeBackTest { + @Test + public fun pageTransitionInteractionBlockerKeepsHitsOffDescendantPages() { + val container = AppKitNavigationContainerView().apply { frame = CGRectMake(0.0, 0.0, 300.0, 200.0) } + val page = NSView(frame = container.bounds) + container.addSubview(page) + val point = CGPointMake(100.0, 100.0) + + assertEquals(page, container.hitTest(point)) + container.blocksPageTransitionInteraction = true + assertEquals(container, container.hitTest(point)) + } + + @Test + public fun recognizesPhysicalTrailingSwipeAcrossScrollPreferences() { + val natural = + appKitSwipeBackTrackingDirection( + scrollingDeltaX = 8.0, + scrollingDeltaY = 1.0, + directionInvertedFromDevice = true, + rightToLeft = false, + ) + val traditional = + appKitSwipeBackTrackingDirection( + scrollingDeltaX = -8.0, + scrollingDeltaY = 1.0, + directionInvertedFromDevice = false, + rightToLeft = false, + ) + + assertEquals(1.0, natural?.gestureAmountSign) + assertEquals(-1.0, traditional?.gestureAmountSign) + } + + @Test + public fun rejectsVerticalAndForwardGestures() { + assertNull( + appKitSwipeBackTrackingDirection( + scrollingDeltaX = 2.0, + scrollingDeltaY = 3.0, + directionInvertedFromDevice = true, + rightToLeft = false, + ), + ) + assertNull( + appKitSwipeBackTrackingDirection( + scrollingDeltaX = -3.0, + scrollingDeltaY = 0.0, + directionInvertedFromDevice = true, + rightToLeft = false, + ), + ) + } + + @Test + public fun recognizesOppositePhysicalBackDirectionForRightToLeftLayout() { + val direction = + appKitSwipeBackTrackingDirection( + scrollingDeltaX = -6.0, + scrollingDeltaY = 0.0, + directionInvertedFromDevice = true, + rightToLeft = true, + ) + + assertEquals(-1.0, direction?.gestureAmountSign) + } + + @Test + public fun recognizesDiscreteBackSwipeInBothLayoutDirections() { + assertTrue(isAppKitDiscreteSwipeBack(deltaX = -1.0, deltaY = 0.0, rightToLeft = false)) + assertTrue(isAppKitDiscreteSwipeBack(deltaX = 1.0, deltaY = 0.0, rightToLeft = true)) + assertEquals(false, isAppKitDiscreteSwipeBack(deltaX = 1.0, deltaY = 0.0, rightToLeft = false)) + assertEquals(false, isAppKitDiscreteSwipeBack(deltaX = -1.0, deltaY = 0.0, rightToLeft = true)) + assertEquals(false, isAppKitDiscreteSwipeBack(deltaX = -1.0, deltaY = 2.0, rightToLeft = false)) + } + + @Test + public fun usesTelegramStyleOneThirdIncomingParallaxInLeftToRightLayout() { + assertEquals( + AppKitSwipeBackOffsets(outgoing = 0.0, incoming = -100.0), + appKitSwipeBackOffsets(progress = 0.0, width = 300.0), + ) + assertEquals( + AppKitSwipeBackOffsets(outgoing = 150.0, incoming = -50.0), + appKitSwipeBackOffsets(progress = 0.5, width = 300.0), + ) + assertEquals( + AppKitSwipeBackOffsets(outgoing = 300.0, incoming = 0.0), + appKitSwipeBackOffsets(progress = 1.0, width = 300.0), + ) + } + + @Test + public fun pushAndPopPageOffsetsAreTimeReverses() { + listOf(0.0, 0.25, 0.5, 0.75, 1.0).forEach { progress -> + val push = + appKitPageTransitionOffsets( + kind = AppKitPageTransitionKind.Push, + progress = progress, + width = 300.0, + ) + val reversedPop = + appKitPageTransitionOffsets( + kind = AppKitPageTransitionKind.Pop, + progress = 1.0 - progress, + width = 300.0, + ) + + assertEquals(push.source, reversedPop.destination) + assertEquals(push.destination, reversedPop.source) + } + } + + @Test + public fun waitsForFluidTrackingCompletionBeforeCommitting() { + val progress = mutableListOf() + val completions = mutableListOf() + val session = + AppKitSwipeBackTrackingSession( + direction = AppKitSwipeBackTrackingDirection(1.0), + onProgress = progress::add, + onComplete = completions::add, + ) + + session.update(gestureAmount = 0.7, isComplete = false) + assertEquals(listOf(0.7), progress) + assertEquals(emptyList(), completions) + + session.update(gestureAmount = 1.0, isComplete = true) + session.update(gestureAmount = 0.0, isComplete = true) + assertEquals(listOf(0.7, 1.0), progress) + assertEquals(listOf(true), completions) + } + + @Test + public fun cancelsWhenFluidTrackingReturnsToOrigin() { + val completions = mutableListOf() + val session = + AppKitSwipeBackTrackingSession( + direction = AppKitSwipeBackTrackingDirection(-1.0), + onProgress = {}, + onComplete = completions::add, + ) + + session.update(gestureAmount = -0.4, isComplete = false) + session.update(gestureAmount = 0.0, isComplete = true) + + assertEquals(listOf(false), completions) + } + + @Test + public fun invalidatedFluidTrackingDropsCallbacks() { + val progress = mutableListOf() + val completions = mutableListOf() + val session = + AppKitSwipeBackTrackingSession( + direction = AppKitSwipeBackTrackingDirection(1.0), + onProgress = progress::add, + onComplete = completions::add, + ) + + session.invalidate() + session.update(gestureAmount = 1.0, isComplete = true) + + assertEquals(emptyList(), progress) + assertEquals(emptyList(), completions) + } + + @Test + public fun commitsPhysicalControllerPopBeforeSynchronousModelAcknowledgement() { + val parent = testParentController() + val widget = AppKitNavigationWidget() + val dispatcher = NavigationModelDispatcher() + val factory = SwipeRecordingSubcompositionFactory() + val home = swipeEntry("home") + val detail = swipeEntry("detail") + var backCalls = 0 + widget.setModelDispatcher(dispatcher) + parent.view.addSubview(widget.view) + dispatcher.dispatch( + swipeModel( + entries = listOf(home, detail), + parent = parent, + factory = factory, + onBack = { request -> + backCalls += 1 + request.accept() + dispatcher.dispatch( + swipeModel( + entries = listOf(home), + parent = parent, + factory = factory, + ), + ) + }, + ), + ) + + val nativeContainer = parent.childViewControllers.single() as NSViewController + assertEquals(2, nativeContainer.childViewControllers.size) + assertTrue(widget.beginSwipeBack()) + widget.updateSwipeBack(0.6) + widget.finishSwipeBack(committed = true) + + assertEquals(1, backCalls) + assertEquals(1, nativeContainer.childViewControllers.size) + assertEquals(1, widget.view.subviews.size) + + widget.dispose() + assertEquals(0, parent.childViewControllers.size) + } + + @Test + public fun cancelledSwipeRestoresTopControllerWithoutRequestingBack() { + val parent = testParentController() + val widget = AppKitNavigationWidget() + val dispatcher = NavigationModelDispatcher() + val factory = SwipeRecordingSubcompositionFactory() + var backCalls = 0 + widget.setModelDispatcher(dispatcher) + parent.view.addSubview(widget.view) + dispatcher.dispatch( + swipeModel( + entries = listOf(swipeEntry("home"), swipeEntry("detail")), + parent = parent, + factory = factory, + onBack = { backCalls += 1 }, + ), + ) + + val nativeContainer = parent.childViewControllers.single() as NSViewController + val detailController = nativeContainer.childViewControllers.last() as NSViewController + assertTrue(widget.beginSwipeBack()) + widget.updateSwipeBack(0.3) + widget.finishSwipeBack(committed = false) + + assertEquals(0, backCalls) + assertEquals(2, nativeContainer.childViewControllers.size) + assertEquals(1, widget.view.subviews.size) + assertEquals(detailController.view, widget.view.subviews.single()) + assertTrue(widget.beginSwipeBack()) + + widget.dispose() + assertEquals(0, parent.childViewControllers.size) + } + + @Test + public fun stagedModelBlocksSwipeBackAgainstStaleTopology() { + val parent = testParentController() + val widget = AppKitNavigationWidget() + val dispatcher = NavigationModelDispatcher() + val stagingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + val factory = SwipeRecordingSubcompositionFactory() + val home = swipeEntry("home") + widget.setModelDispatcher(dispatcher) + parent.view.addSubview(widget.view) + dispatcher.dispatch( + swipeModel( + entries = listOf(home, swipeEntry("detail")), + parent = parent, + factory = factory, + ), + ) + + dispatcher.stage( + swipeModel( + entries = listOf(home), + parent = parent, + factory = factory, + ), + stagingScope, + ) + + assertFalse(widget.canBeginSwipeBack()) + widget.dispose() + stagingScope.cancel() + } +} + +private fun testParentController(): NSViewController = + NSViewController().apply { + view = NSView(frame = CGRectMake(0.0, 0.0, 600.0, 400.0)) + } + +private fun swipeModel( + entries: List, + parent: NSViewController, + factory: FlareSubcompositionFactory, + onBack: (NavigationBackRequest) -> Unit = {}, +): NavigationModel = + NavigationModel( + entries = entries, + onBack = onBack, + subcompositions = factory, + nativeControllerOwner = AppKitNavigationOwner(parent), + ) + +private fun swipeEntry(contentKey: String): ResolvedNavigationEntry = + ResolvedNavigationEntry( + contentKey = contentKey, + presentation = NavigationPresentation.Page, + entry = + NavEntry( + key = contentKey, + contentKey = contentKey, + ) {}, + ) + +private class SwipeRecordingSubcompositionFactory : FlareSubcompositionFactory { + override fun create(root: FlareChildren): FlareSubcomposition = SwipeRecordingSubcomposition() +} + +private class SwipeRecordingSubcomposition : FlareSubcomposition { + override fun setContent(content: FlareContent) = Unit + + override fun deactivate() = Unit + + override fun dispose() = Unit +} diff --git a/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationTransitionTest.kt b/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationTransitionTest.kt new file mode 100644 index 0000000000..8520e7ff04 --- /dev/null +++ b/flareUI/navigation/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitNavigationTransitionTest.kt @@ -0,0 +1,868 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + dev.dimension.flare.ui.navigation.ExperimentalFlareNavigation::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.appkit + +import androidx.navigation3.runtime.NavEntry +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareSubcomposition +import dev.dimension.flare.ui.FlareSubcompositionFactory +import dev.dimension.flare.ui.navigation.NavigationModel +import dev.dimension.flare.ui.navigation.NavigationModelDispatcher +import dev.dimension.flare.ui.navigation.NavigationPresentation +import dev.dimension.flare.ui.navigation.ResolvedNavigationEntry +import kotlinx.cinterop.useContents +import platform.AppKit.NSApplication +import platform.AppKit.NSBackingStoreBuffered +import platform.AppKit.NSUserInterfaceLayoutDirectionRightToLeft +import platform.AppKit.NSView +import platform.AppKit.NSViewController +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowStyleMaskBorderless +import platform.AppKit.NSWorkspace +import platform.AppKit.accessibilityDisplayShouldReduceMotion +import platform.AppKit.addChildViewController +import platform.AppKit.childViewControllers +import platform.AppKit.parentViewController +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import platform.CoreGraphics.CGRectMake +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +public class AppKitNavigationTransitionTest { + @Test + public fun defaultProgrammaticPushProducesParallaxPresentationFrames() { + val fixture = AppKitTransitionFixture() + + try { + if (NSWorkspace.sharedWorkspace.accessibilityDisplayShouldReduceMotion) return + fixture.dispatch(listOf(fixture.home)) + fixture.showWindow() + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.02, false) + val container = fixture.parent.childViewControllers.single() as NSViewController + val outgoing = container.childViewControllers.single() as NSViewController + val width = container.view.bounds.useContents { size.width } + + fixture.dispatch(listOf(fixture.home, fixture.detail)) + assertEquals(2, container.childViewControllers.size) + val incoming = container.childViewControllers.last() as NSViewController + val observedPositions = mutableListOf>() + val startedAt = TimeSource.Monotonic.markNow() + var intermediatePosition: Pair? = null + while ( + intermediatePosition == null && + outgoing.view.superview != null && + startedAt.elapsedNow() < 2.seconds + ) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0 / 240.0, false) + val outgoingX = outgoing.view.presentationX() + val incomingX = incoming.view.presentationX() + observedPositions += outgoingX to incomingX + if ( + outgoingX != null && + incomingX != null && + outgoingX < -0.5 && + outgoingX > -width / 3.0 + 0.5 && + incomingX > 0.5 && + incomingX < width - 0.5 && + abs(-outgoingX / (width / 3.0) - (1.0 - incomingX / width)) < 0.05 && + container.childViewControllers.size == 2 + ) { + intermediatePosition = outgoingX to incomingX + } + } + + assertTrue( + intermediatePosition != null, + "Programmatic push completed without a shared parallax presentation frame; " + + "last observed (outgoing, incoming)=${observedPositions.takeLast(20)}.", + ) + awaitAppKitNavigationTransition("Programmatic push did not finish after rendering an intermediate frame.") { + container.view.subviews.singleOrNull() == incoming.view + } + } finally { + fixture.dispose() + } + } + + @Test + public fun defaultProgrammaticPopProducesIntermediatePresentationFrames() { + val fixture = AppKitTransitionFixture() + + try { + if (NSWorkspace.sharedWorkspace.accessibilityDisplayShouldReduceMotion) return + fixture.dispatch(listOf(fixture.home, fixture.detail)) + fixture.showWindow() + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.02, false) + val container = fixture.parent.childViewControllers.single() as NSViewController + val incoming = container.childViewControllers.first() as NSViewController + val outgoing = container.childViewControllers.last() as NSViewController + val width = container.view.bounds.useContents { size.width } + + fixture.dispatch(listOf(fixture.home)) + assertEquals(2, container.childViewControllers.size) + val observedPositions = mutableListOf>() + val startedAt = TimeSource.Monotonic.markNow() + var intermediatePosition: Pair? = null + while ( + intermediatePosition == null && + outgoing in container.childViewControllers && + startedAt.elapsedNow() < 2.seconds + ) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0 / 240.0, false) + val outgoingX = outgoing.view.presentationX() + val incomingX = incoming.view.presentationX() + observedPositions += outgoingX to incomingX + if ( + outgoingX != null && + incomingX != null && + outgoingX > 0.5 && + outgoingX < width - 0.5 && + incomingX > -width / 3.0 + 0.5 && + incomingX < -0.5 && + container.childViewControllers.size == 2 + ) { + intermediatePosition = outgoingX to incomingX + } + } + + assertTrue( + intermediatePosition != null, + "Programmatic pop completed without an intermediate presentation frame; " + + "last observed (outgoing, incoming)=${observedPositions.takeLast(20)}.", + ) + awaitAppKitNavigationTransition("Programmatic pop did not finish after rendering an intermediate frame.") { + container.childViewControllers.singleOrNull() == incoming + } + } finally { + fixture.dispose() + } + } + + @Test + public fun navigationContainerIsLayerBackedBeforeProgrammaticTransitions() { + val fixture = AppKitTransitionFixture() + + try { + fixture.dispatch(listOf(fixture.home)) + + val container = fixture.parent.childViewControllers.single() as NSViewController + val root = container.childViewControllers.single() as NSViewController + assertEquals(fixture.widget.view, container.view) + assertEquals(container.view, root.view.superview) + assertTrue( + container.view.wantsLayer, + "The transition container must be layer-backed before AppKit performs a slide transition.", + ) + } finally { + fixture.dispose() + } + } + + @Test + public fun initialDeepStackMaterializesOnlyCurrentAndImmediatePredecessor() { + val fixture = AppKitTransitionFixture() + + try { + fixture.dispatch( + listOf( + fixture.home, + fixture.detail, + fixture.editor, + fixture.settings, + ), + ) + val container = fixture.parent.childViewControllers.single() as NSViewController + + assertEquals(4, container.childViewControllers.size) + assertEquals(2, fixture.subcompositions.created) + assertEquals(2, fixture.subcompositions.installed) + assertEquals(1, fixture.subcompositions.deactivated) + assertEquals(0, fixture.subcompositions.disposed) + } finally { + fixture.dispose() + } + } + + @Test + public fun factoryRebindRestoresActiveFrozenAndReleasedPagesWithTheNewFactory() { + val fixture = AppKitTransitionFixture() + val replacement = TransitionSubcompositionFactory() + val reboundHome = transitionEntry("home") + val reboundDetail = transitionEntry("detail") + val reboundEditor = transitionEntry("editor") + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail, fixture.editor)) + val container = fixture.parent.childViewControllers.single() as NSViewController + assertEquals(3, container.childViewControllers.size) + assertEquals(2, fixture.subcompositions.created) + fixture.subcompositions.disposeOwnedCompositions() + + fixture.dispatch( + entries = listOf(reboundHome, reboundDetail, reboundEditor), + subcompositions = replacement, + ) + + assertEquals(2, fixture.subcompositions.disposed) + assertEquals(2, replacement.created) + assertEquals(2, replacement.installed) + assertEquals(1, replacement.deactivated) + assertEquals(0, replacement.disposed) + + fixture.dispatch(listOf(reboundHome), replacement) + awaitAppKitNavigationTransition("AppKit did not realize the released root with the new factory.") { + container.childViewControllers.size == 1 && replacement.created == 3 + } + } finally { + fixture.dispose() + } + } + + @Test + public fun programmaticPushAndPopCompleteWithStableControllerHierarchy() { + val fixture = AppKitTransitionFixture() + + try { + fixture.dispatch(listOf(fixture.home)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val root = container.childViewControllers.single() as NSViewController + assertEquals(1, fixture.subcompositions.created) + assertEquals(1, fixture.subcompositions.installed) + + fixture.dispatch(listOf(fixture.home, fixture.detail)) + awaitAppKitNavigationTransition("AppKit did not complete the programmatic push.") { + container.childViewControllers.size == 2 && + container.view.subviews.singleOrNull() == + (container.childViewControllers.last() as NSViewController).view + } + + val detail = container.childViewControllers.last() as NSViewController + assertEquals(root, container.childViewControllers.first()) + assertNull(root.view.superview) + assertEquals(container.view, detail.view.superview) + assertEquals(2, fixture.subcompositions.created) + assertEquals(2, fixture.subcompositions.installed) + assertEquals(1, fixture.subcompositions.deactivated) + assertEquals(0, fixture.subcompositions.disposed) + + fixture.dispatch(listOf(fixture.home)) + awaitAppKitNavigationTransition("AppKit did not complete the programmatic pop.") { + container.childViewControllers.singleOrNull() == root && + container.view.subviews.singleOrNull() == root.view + } + + assertEquals(1, fixture.parent.childViewControllers.size) + assertEquals(container.view, root.view.superview) + assertTrue(detail !in container.childViewControllers) + assertNull(detail.view.superview) + assertEquals(2, fixture.subcompositions.created) + assertEquals(3, fixture.subcompositions.installed) + assertEquals(1, fixture.subcompositions.disposed) + } finally { + fixture.dispose() + } + } + + @Test + public fun reconstructionRemovesForeignControllersAndOrphanViews() { + val fixture = AppKitTransitionFixture() + + try { + fixture.dispatch(listOf(fixture.home)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val orphanView = NSView(frame = container.view.bounds) + val foreignController = + NSViewController().apply { + view = NSView(frame = container.view.bounds) + } + container.view.addSubview(orphanView) + container.addChildViewController(foreignController) + + fixture.dispatch(listOf(fixture.home, fixture.detail)) + + assertEquals(2, container.childViewControllers.size) + assertTrue(foreignController !in container.childViewControllers) + assertNull(foreignController.parentViewController) + assertNull(orphanView.superview) + assertEquals(1, container.view.subviews.size) + assertTrue(fixture.widget.beginSwipeBack()) + fixture.widget.finishSwipeBack(committed = false) + } finally { + fixture.dispose() + } + } + + @Test + public fun foreignControllerAtSwipeBeginTriggersAuthoritativeRecovery() { + val fixture = AppKitTransitionFixture() + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val foreignController = + NSViewController().apply { + view = NSView(frame = container.view.bounds) + } + container.addChildViewController(foreignController) + + assertFalse(fixture.widget.beginSwipeBack()) + + assertEquals(2, container.childViewControllers.size) + assertTrue(foreignController !in container.childViewControllers) + assertNull(foreignController.parentViewController) + assertEquals( + (container.childViewControllers.last() as NSViewController).view, + container.view.subviews.single(), + ) + assertTrue(fixture.widget.beginSwipeBack()) + fixture.widget.finishSwipeBack(committed = false) + } finally { + fixture.dispose() + } + } + + @Test + public fun programmaticAndInteractiveBackShareHalfwayGeometry() { + val scheduler = ManualAppKitPageAnimationScheduler() + val programmatic = + AppKitTransitionFixture( + widget = AppKitNavigationWidget(pageAnimationScheduler = scheduler), + ) + val interactive = AppKitTransitionFixture() + + try { + programmatic.dispatch(listOf(programmatic.home, programmatic.detail)) + interactive.dispatch(listOf(interactive.home, interactive.detail)) + val programmaticContainer = programmatic.parent.childViewControllers.single() as NSViewController + val programmaticIncoming = programmaticContainer.childViewControllers.first() as NSViewController + val programmaticOutgoing = programmaticContainer.childViewControllers.last() as NSViewController + val interactiveContainer = interactive.parent.childViewControllers.single() as NSViewController + val interactiveIncoming = interactiveContainer.childViewControllers.first() as NSViewController + val interactiveOutgoing = interactiveContainer.childViewControllers.last() as NSViewController + + programmatic.dispatch(listOf(programmatic.home)) + assertTrue(scheduler.hasPendingAnimation, "Programmatic pop did not use the shared back animator.") + scheduler.seek(0.5) + val programmaticGeometry = captureBackGeometry(programmaticOutgoing.view, programmaticIncoming.view) + assertBackTransitionHierarchy( + container = programmaticContainer, + incoming = programmaticIncoming, + outgoing = programmaticOutgoing, + ) + + assertTrue(interactive.widget.beginSwipeBack()) + interactive.widget.updateSwipeBack(0.5) + val interactiveGeometry = captureBackGeometry(interactiveOutgoing.view, interactiveIncoming.view) + assertBackTransitionHierarchy( + container = interactiveContainer, + incoming = interactiveIncoming, + outgoing = interactiveOutgoing, + ) + + assertBackGeometryMatchesHalfwaySpec(programmaticGeometry) + assertBackGeometryMatchesHalfwaySpec(interactiveGeometry) + + scheduler.complete() + interactive.widget.finishSwipeBack(committed = false) + assertPageGeometryEquals( + expected = PageGeometry(x = 0.0, y = 0.0, width = 600.0, height = 400.0), + actual = interactiveOutgoing.view.pageGeometry(), + ) + assertNull(interactiveIncoming.view.superview) + assertEquals( + listOf(interactiveIncoming, interactiveOutgoing), + interactiveContainer.childViewControllers.map { it as NSViewController }, + ) + assertEquals( + listOf(interactiveOutgoing.view), + interactiveContainer.view.subviews.map { it as NSView }, + ) + } finally { + interactive.dispose() + programmatic.dispose() + } + } + + @Test + public fun programmaticPushUsesTelegramStyleHalfwayGeometry() { + val scheduler = ManualAppKitPageAnimationScheduler() + val navigationView = AppKitNavigationContainerView() + val fixture = + AppKitTransitionFixture( + widget = + AppKitNavigationWidget( + navigationView = navigationView, + pageAnimationScheduler = scheduler, + ), + ) + + try { + fixture.dispatch(listOf(fixture.home)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val outgoing = container.childViewControllers.single() as NSViewController + + fixture.dispatch(listOf(fixture.home, fixture.detail)) + assertTrue(scheduler.hasPendingAnimation, "Programmatic push did not use the shared page animator.") + val incoming = container.childViewControllers.last() as NSViewController + assertTrue(navigationView.blocksPageTransitionInteraction) + assertPageGeometryEquals( + expected = PageGeometry(x = 0.0, y = 0.0, width = 600.0, height = 400.0), + actual = outgoing.view.pageGeometry(), + ) + assertPageGeometryEquals( + expected = PageGeometry(x = 600.0, y = 0.0, width = 600.0, height = 400.0), + actual = incoming.view.pageGeometry(), + ) + scheduler.seek(0.5) + + assertPushGeometryMatchesHalfwaySpec(captureBackGeometry(outgoing.view, incoming.view)) + assertBackTransitionHierarchy(container, outgoing, incoming) + scheduler.complete() + assertFalse(navigationView.blocksPageTransitionInteraction) + assertNull(outgoing.view.superview) + assertEquals(listOf(incoming.view), container.view.subviews.map { it as NSView }) + } finally { + fixture.dispose() + } + } + + @Test + public fun schedulerFailureAfterSynchronousCompletionDoesNotRollbackCommittedPush() { + val fixture = + AppKitTransitionFixture( + widget = + AppKitNavigationWidget( + pageAnimationScheduler = CompletingThenThrowingAppKitPageAnimationScheduler, + ), + ) + + try { + fixture.dispatch(listOf(fixture.home)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val outgoing = container.childViewControllers.single() as NSViewController + + fixture.dispatch(listOf(fixture.home, fixture.detail)) + val incoming = container.childViewControllers.last() as NSViewController + + assertEquals( + listOf(outgoing, incoming), + container.childViewControllers.map { it as NSViewController }, + ) + assertNull(outgoing.view.superview) + assertEquals(listOf(incoming.view), container.view.subviews.map { it as NSView }) + } finally { + fixture.dispose() + } + } + + @Test + public fun rightToLeftProgrammaticPushMirrorsHalfwayGeometry() { + val scheduler = ManualAppKitPageAnimationScheduler() + val navigationView = + AppKitNavigationContainerView().apply { + userInterfaceLayoutDirection = NSUserInterfaceLayoutDirectionRightToLeft + } + val fixture = + AppKitTransitionFixture( + widget = + AppKitNavigationWidget( + navigationView = navigationView, + pageAnimationScheduler = scheduler, + ), + ) + + try { + fixture.dispatch(listOf(fixture.home)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val outgoing = container.childViewControllers.single() as NSViewController + + fixture.dispatch(listOf(fixture.home, fixture.detail)) + val incoming = container.childViewControllers.last() as NSViewController + scheduler.seek(0.5) + + assertPushGeometryMatchesRightToLeftHalfwaySpec( + captureBackGeometry(outgoing.view, incoming.view), + ) + assertBackTransitionHierarchy(container, outgoing, incoming) + scheduler.complete() + } finally { + fixture.dispose() + } + } + + @Test + public fun rightToLeftBackGeometryUsesSemanticLeadingDirection() { + val scheduler = ManualAppKitPageAnimationScheduler() + val navigationView = + AppKitNavigationContainerView().apply { + userInterfaceLayoutDirection = NSUserInterfaceLayoutDirectionRightToLeft + } + val fixture = + AppKitTransitionFixture( + widget = + AppKitNavigationWidget( + navigationView = navigationView, + pageAnimationScheduler = scheduler, + ), + ) + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val incoming = container.childViewControllers.first() as NSViewController + val outgoing = container.childViewControllers.last() as NSViewController + + fixture.dispatch(listOf(fixture.home)) + scheduler.seek(0.5) + + assertBackGeometryMatchesRightToLeftHalfwaySpec( + captureBackGeometry(outgoing.view, incoming.view), + ) + assertBackTransitionHierarchy(container, incoming, outgoing) + scheduler.complete() + } finally { + fixture.dispose() + } + } + + @Test + public fun discreteSwipeUsesSharedBackAnimatorAndCommitsOnce() { + val scheduler = ManualAppKitPageAnimationScheduler() + val fixture = + AppKitTransitionFixture( + widget = AppKitNavigationWidget(pageAnimationScheduler = scheduler), + ) + + try { + fixture.dispatch(listOf(fixture.home, fixture.detail)) + val container = fixture.parent.childViewControllers.single() as NSViewController + val incoming = container.childViewControllers.first() as NSViewController + val outgoing = container.childViewControllers.last() as NSViewController + + assertTrue(fixture.widget.performDiscreteSwipeBack()) + assertTrue(scheduler.hasPendingAnimation) + scheduler.seek(0.5) + assertBackGeometryMatchesHalfwaySpec(captureBackGeometry(outgoing.view, incoming.view)) + assertBackTransitionHierarchy(container, incoming, outgoing) + + scheduler.complete() + + assertEquals(1, fixture.backRequestCount) + assertEquals(listOf(incoming), container.childViewControllers.map { it as NSViewController }) + assertEquals(listOf(incoming.view), container.view.subviews.map { it as NSView }) + assertNull(outgoing.view.superview) + } finally { + fixture.dispose() + } + } +} + +private class AppKitTransitionFixture( + val widget: AppKitNavigationWidget = AppKitNavigationWidget(), +) { + val parent: NSViewController = + NSViewController().apply { + view = NSView(frame = CGRectMake(0.0, 0.0, 600.0, 400.0)) + } + val home: ResolvedNavigationEntry = transitionEntry("home") + val detail: ResolvedNavigationEntry = transitionEntry("detail") + val editor: ResolvedNavigationEntry = transitionEntry("editor") + val settings: ResolvedNavigationEntry = transitionEntry("settings") + + private val dispatcher = NavigationModelDispatcher() + private var currentEntries: List = emptyList() + val subcompositions = TransitionSubcompositionFactory() + private var currentSubcompositions: FlareSubcompositionFactory = subcompositions + var backRequestCount: Int = 0 + private set + private val window: NSWindow + + init { + NSApplication.sharedApplication + window = + NSWindow( + contentRect = CGRectMake(0.0, 0.0, 600.0, 400.0), + styleMask = NSWindowStyleMaskBorderless, + backing = NSBackingStoreBuffered, + defer = false, + ) + window.contentView = parent.view + widget.view.frame = parent.view.bounds + parent.view.addSubview(widget.view) + widget.setModelDispatcher(dispatcher) + } + + fun dispatch( + entries: List, + subcompositions: FlareSubcompositionFactory = currentSubcompositions, + ) { + currentEntries = entries + currentSubcompositions = subcompositions + dispatcher.dispatch( + NavigationModel( + entries = entries, + onBack = { request -> + backRequestCount += 1 + request.accept() + dispatch(currentEntries.dropLast(request.popCount), currentSubcompositions) + }, + subcompositions = subcompositions, + nativeControllerOwner = AppKitNavigationOwner(parent), + ), + ) + } + + fun showWindow() { + window.alphaValue = 0.001 + window.orderFrontRegardless() + check(window.visible) { "AppKit animation tests require a visible window." } + } + + fun dispose() { + widget.dispose() + window.close() + } +} + +private class ManualAppKitPageAnimationScheduler : AppKitPageAnimationScheduler { + private var view: NSView? = null + private var updateProgress: ((Double) -> Unit)? = null + private var completion: (() -> Unit)? = null + + val hasPendingAnimation: Boolean + get() = updateProgress != null + + override fun animate( + view: NSView, + durationSeconds: Double, + updateProgress: (Double) -> Unit, + completion: () -> Unit, + ) { + check(!hasPendingAnimation) { "Only one AppKit back animation can be pending." } + this.view = view + this.updateProgress = updateProgress + this.completion = completion + } + + fun seek(progress: Double) { + checkNotNull(updateProgress).invoke(progress) + checkNotNull(view).layoutSubtreeIfNeeded() + } + + fun complete() { + seek(1.0) + val completion = checkNotNull(completion) + view = null + updateProgress = null + this.completion = null + completion() + } +} + +private object CompletingThenThrowingAppKitPageAnimationScheduler : AppKitPageAnimationScheduler { + override fun animate( + view: NSView, + durationSeconds: Double, + updateProgress: (Double) -> Unit, + completion: () -> Unit, + ) { + updateProgress(1.0) + view.layoutSubtreeIfNeeded() + completion() + error("Animation scheduler failed after invoking its synchronous completion.") + } +} + +private data class BackGeometry( + val outgoing: PageGeometry, + val incoming: PageGeometry, +) + +private data class PageGeometry( + val x: Double, + val y: Double, + val width: Double, + val height: Double, +) + +private fun captureBackGeometry( + outgoing: NSView, + incoming: NSView, +): BackGeometry = + BackGeometry( + outgoing = outgoing.pageGeometry(), + incoming = incoming.pageGeometry(), + ) + +private fun NSView.pageGeometry(): PageGeometry = + frame.useContents { + PageGeometry( + x = origin.x, + y = origin.y, + width = size.width, + height = size.height, + ) + } + +private fun NSView.presentationX(): Double? = + layer + ?.presentationLayer() + ?.frame + ?.useContents { origin.x } + +private fun assertBackGeometryMatchesHalfwaySpec(actual: BackGeometry) { + assertPageGeometryEquals( + expected = PageGeometry(x = 300.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.outgoing, + ) + assertPageGeometryEquals( + expected = PageGeometry(x = -100.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.incoming, + ) +} + +private fun assertPushGeometryMatchesHalfwaySpec(actual: BackGeometry) { + assertPageGeometryEquals( + expected = PageGeometry(x = -100.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.outgoing, + ) + assertPageGeometryEquals( + expected = PageGeometry(x = 300.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.incoming, + ) +} + +private fun assertPushGeometryMatchesRightToLeftHalfwaySpec(actual: BackGeometry) { + assertPageGeometryEquals( + expected = PageGeometry(x = 100.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.outgoing, + ) + assertPageGeometryEquals( + expected = PageGeometry(x = -300.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.incoming, + ) +} + +private fun assertBackGeometryMatchesRightToLeftHalfwaySpec(actual: BackGeometry) { + assertPageGeometryEquals( + expected = PageGeometry(x = -300.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.outgoing, + ) + assertPageGeometryEquals( + expected = PageGeometry(x = 100.0, y = 0.0, width = 600.0, height = 400.0), + actual = actual.incoming, + ) +} + +private fun assertBackTransitionHierarchy( + container: NSViewController, + incoming: NSViewController, + outgoing: NSViewController, +) { + assertEquals( + listOf(incoming, outgoing), + container.childViewControllers.map { it as NSViewController }, + ) + assertEquals( + listOf(incoming.view, outgoing.view), + container.view.subviews.map { it as NSView }, + ) + assertEquals(container.view, incoming.view.superview) + assertEquals(container.view, outgoing.view.superview) +} + +private fun assertPageGeometryEquals( + expected: PageGeometry, + actual: PageGeometry, +) { + assertEquals(expected.x, actual.x, absoluteTolerance = 0.5) + assertEquals(expected.y, actual.y, absoluteTolerance = 0.5) + assertEquals(expected.width, actual.width, absoluteTolerance = 0.5) + assertEquals(expected.height, actual.height, absoluteTolerance = 0.5) +} + +private fun transitionEntry(contentKey: String): ResolvedNavigationEntry = + ResolvedNavigationEntry( + contentKey = contentKey, + presentation = NavigationPresentation.Page, + entry = + NavEntry( + key = contentKey, + contentKey = contentKey, + ) {}, + ) + +private class TransitionSubcompositionFactory : FlareSubcompositionFactory { + private val compositions = mutableListOf() + private var disposedFactory: Boolean = false + var created: Int = 0 + private set + var installed: Int = 0 + private set + var deactivated: Int = 0 + private set + var disposed: Int = 0 + private set + + override fun create(root: FlareChildren): FlareSubcomposition { + check(!disposedFactory) { "AppKit test subcomposition factory is already disposed." } + created += 1 + return TransitionSubcomposition( + onInstalled = { installed += 1 }, + onDeactivated = { deactivated += 1 }, + onDisposed = { disposed += 1 }, + ).also(compositions::add) + } + + fun disposeOwnedCompositions() { + disposedFactory = true + compositions.forEach(TransitionSubcomposition::dispose) + } +} + +private class TransitionSubcomposition( + private val onInstalled: () -> Unit, + private val onDeactivated: () -> Unit, + private val onDisposed: () -> Unit, +) : FlareSubcomposition { + private var disposed: Boolean = false + + override fun setContent(content: FlareContent) { + check(!disposed) { "AppKit test subcomposition is already disposed." } + onInstalled() + } + + override fun deactivate() { + check(!disposed) { "AppKit test subcomposition is already disposed." } + onDeactivated() + } + + override fun dispose() { + if (disposed) return + disposed = true + onDisposed() + } +} + +private fun awaitAppKitNavigationTransition( + message: String, + condition: () -> Boolean, +) { + val startedAt = TimeSource.Monotonic.markNow() + while (!condition() && startedAt.elapsedNow() < 5.seconds) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, true) + } + check(condition()) { message } +} diff --git a/flareUI/resources-moko/build.gradle.kts b/flareUI/resources-moko/build.gradle.kts new file mode 100644 index 0000000000..7baf8b45f7 --- /dev/null +++ b/flareUI/resources-moko/build.gradle.kts @@ -0,0 +1,40 @@ +import dev.dimension.flareui.buildlogic.FlareUiPlatform +import dev.dimension.flareui.buildlogic.flareUi + +plugins { + id("dev.dimension.flareui.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flareUi { + namespace = "dev.dimension.flare.ui.resources.moko" + platforms( + FlareUiPlatform.ANDROID, + FlareUiPlatform.IOS, + FlareUiPlatform.MACOS, + ) + } + sourceSets { + val commonMain by getting { + dependencies { + api(project(":flare-runtime")) + api(libs.moko.resources) + } + } + val androidMain by getting { + dependencies { + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.compose.foundation) + implementation(libs.material.components) + } + } + val nativeTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/flareUI/resources-moko/src/androidMain/kotlin/dev/dimension/flare/ui/resources/moko/AndroidResourceImageRenderer.kt b/flareUI/resources-moko/src/androidMain/kotlin/dev/dimension/flare/ui/resources/moko/AndroidResourceImageRenderer.kt new file mode 100644 index 0000000000..f910dd33e3 --- /dev/null +++ b/flareUI/resources-moko/src/androidMain/kotlin/dev/dimension/flare/ui/resources/moko/AndroidResourceImageRenderer.kt @@ -0,0 +1,80 @@ +package dev.dimension.flare.ui.resources.moko + +import android.widget.ImageView +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.UiComposable +import androidx.compose.ui.res.painterResource +import com.google.android.material.imageview.ShapeableImageView +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.android.AbstractAndroidWidget +import dev.dimension.flare.ui.android.AndroidViewBackend +import dev.dimension.flare.ui.compose.AbstractAndroidComposeWidget +import dev.dimension.flare.ui.compose.AndroidComposeBackend + +/** Installs [ResourceImage] for the Android View backend. */ +public object AndroidViewMokoResourcesRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ResourceImageWidget::class) { backend -> + AndroidViewResourceImageWidget(backend) + } + } +} + +/** Installs [ResourceImage] for the Android Compose backend. */ +public object AndroidComposeMokoResourcesRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ResourceImageWidget::class) { _ -> + AndroidComposeResourceImageWidget() + } + } +} + +private class AndroidViewResourceImageWidget( + backend: AndroidViewBackend, +) : AbstractAndroidWidget( + ShapeableImageView(backend.context).apply { + adjustViewBounds = true + scaleType = ImageView.ScaleType.CENTER_INSIDE + }, + ), + ResourceImageWidget { + override fun setImage(value: FlareImage) { + view.setImageResource(value.drawableResId) + } + + override fun setContentDescription(value: String?) { + view.contentDescription = value + } +} + +private class AndroidComposeResourceImageWidget : + AbstractAndroidComposeWidget(), + ResourceImageWidget { + private var currentImage: FlareImage? by mutableStateOf(null) + private var currentContentDescription: String? by mutableStateOf(null) + + override fun setImage(value: FlareImage) { + currentImage = value + } + + override fun setContentDescription(value: String?) { + currentContentDescription = value + } + + @Composable + @UiComposable + override fun Render() { + currentImage?.let { image -> + Image( + painter = painterResource(image.drawableResId), + contentDescription = currentContentDescription, + modifier = composeModifier, + ) + } + } +} diff --git a/flareUI/resources-moko/src/androidMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.android.kt b/flareUI/resources-moko/src/androidMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.android.kt new file mode 100644 index 0000000000..810c35b3c4 --- /dev/null +++ b/flareUI/resources-moko/src/androidMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.android.kt @@ -0,0 +1,18 @@ +package dev.dimension.flare.ui.resources.moko + +import android.content.Context +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.desc.StringDesc + +/** Android resolver used by both View and Compose hosts. */ +public class AndroidMokoResourceResolver( + private val context: Context, +) : MokoResourceResolver { + override fun resolve(value: StringDesc): String = value.toString(context) + + override fun resolve(value: ImageResource): FlareImage = FlareImage(value.drawableResId) +} + +public actual data class FlareImage internal constructor( + public val drawableResId: Int, +) diff --git a/flareUI/resources-moko/src/commonMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.kt b/flareUI/resources-moko/src/commonMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.kt new file mode 100644 index 0000000000..e0d29fe712 --- /dev/null +++ b/flareUI/resources-moko/src/commonMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.kt @@ -0,0 +1,89 @@ +package dev.dimension.flare.ui.resources.moko + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareUiComposable +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.PluralsResource +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.desc.StringDesc +import dev.icerock.moko.resources.desc.desc +import dev.icerock.moko.resources.format + +/** Resolves Moko resources for the platform hosting the current Flare composition. */ +public interface MokoResourceResolver { + public fun resolve(value: StringDesc): String + + public fun resolve(value: ImageResource): FlareImage +} + +/** Backend-neutral image value returned by [imageResource]. */ +public expect class FlareImage + +private val LocalMokoResourceResolver = + staticCompositionLocalOf { + error( + "No MokoResourceResolver was provided. " + + "Wrap Flare content in ProvideMokoResources.", + ) + } + +/** Supplies resource lookup once around a platform host's Flare content. */ +@Composable +@FlareUiComposable +public fun ProvideMokoResources( + resolver: MokoResourceResolver, + content: FlareContent, +) { + CompositionLocalProvider(LocalMokoResourceResolver provides resolver) { + content() + } +} + +/** Resolves an already-built [StringDesc], including raw and composed descriptions. */ +@Composable +@FlareUiComposable +public fun stringResource(value: StringDesc): String = LocalMokoResourceResolver.current.resolve(value) + +/** Resolves a generated Moko string resource. */ +@Composable +@FlareUiComposable +public fun stringResource(resource: StringResource): String = stringResource(resource.desc()) + +/** Resolves and formats a generated Moko string resource. */ +@Composable +@FlareUiComposable +public fun stringResource( + resource: StringResource, + vararg formatArgs: Any, +): String = stringResource(resource.format(*formatArgs)) + +/** Resolves a generated Moko plural resource for [quantity]. */ +@Composable +@FlareUiComposable +public fun pluralStringResource( + resource: PluralsResource, + quantity: Int, +): String = stringResource(resource.desc(quantity)) + +/** Resolves and formats a generated Moko plural resource for [quantity]. */ +@Composable +@FlareUiComposable +public fun pluralStringResource( + resource: PluralsResource, + quantity: Int, + vararg formatArgs: Any, +): String = stringResource(resource.format(quantity, *formatArgs)) + +/** Resolves a generated Moko image into a value consumable by Flare components. */ +@Composable +@FlareUiComposable +public fun imageResource(resource: ImageResource): FlareImage { + val resolver = LocalMokoResourceResolver.current + return remember(resolver, resource) { + resolver.resolve(resource) + } +} diff --git a/flareUI/resources-moko/src/commonMain/kotlin/dev/dimension/flare/ui/resources/moko/ResourceImage.kt b/flareUI/resources-moko/src/commonMain/kotlin/dev/dimension/flare/ui/resources/moko/ResourceImage.kt new file mode 100644 index 0000000000..c649316987 --- /dev/null +++ b/flareUI/resources-moko/src/commonMain/kotlin/dev/dimension/flare/ui/resources/moko/ResourceImage.kt @@ -0,0 +1,34 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.resources.moko + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget + +/** Renderer contract for the optional resource image primitive. */ +public interface ResourceImageWidget : FlareWidget { + public fun setImage(value: FlareImage) + + public fun setContentDescription(value: String?) +} + +/** Displays an image returned by [imageResource] without changing Foundation's API. */ +@Composable +@FlareUiComposable +public fun ResourceImage( + image: FlareImage, + contentDescription: String?, + modifier: FlareModifier = FlareModifier.None, +) { + EmitFlareWidget( + componentType = ResourceImageWidget::class, + modifier = modifier, + update = { + set(image, ResourceImageWidget::setImage) + set(contentDescription, ResourceImageWidget::setContentDescription) + }, + ) +} diff --git a/flareUI/resources-moko/src/iosMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.ios.kt b/flareUI/resources-moko/src/iosMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.ios.kt new file mode 100644 index 0000000000..19bee11eea --- /dev/null +++ b/flareUI/resources-moko/src/iosMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.ios.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.ui.resources.moko + +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.desc.StringDesc +import platform.UIKit.UIImage + +/** Resolver for UIKit hosts. */ +public data object AppleMokoResourceResolver : MokoResourceResolver { + override fun resolve(value: StringDesc): String = value.localized() + + override fun resolve(value: ImageResource): FlareImage = + FlareImage( + uiImage = + requireNotNull(value.toUIImage()) { + "Unable to load Moko image resource $value." + }, + ) +} + +public actual data class FlareImage internal constructor( + public val uiImage: UIImage, +) diff --git a/flareUI/resources-moko/src/iosMain/kotlin/dev/dimension/flare/ui/resources/moko/UIKitMokoResourcesRendererPlugin.kt b/flareUI/resources-moko/src/iosMain/kotlin/dev/dimension/flare/ui/resources/moko/UIKitMokoResourcesRendererPlugin.kt new file mode 100644 index 0000000000..68e3e92bbb --- /dev/null +++ b/flareUI/resources-moko/src/iosMain/kotlin/dev/dimension/flare/ui/resources/moko/UIKitMokoResourcesRendererPlugin.kt @@ -0,0 +1,37 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.resources.moko + +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.uikit.AbstractUIKitWidget +import dev.dimension.flare.ui.uikit.UIKitBackend +import platform.Foundation.setValue +import platform.UIKit.UIImageView +import platform.UIKit.UIViewContentMode + +/** Installs [ResourceImage] for the UIKit backend. */ +public object UIKitMokoResourcesRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ResourceImageWidget::class) { _ -> + UIKitResourceImageWidget() + } + } +} + +private class UIKitResourceImageWidget : + AbstractUIKitWidget( + UIImageView().apply { + contentMode = UIViewContentMode.UIViewContentModeScaleAspectFit + }, + ), + ResourceImageWidget { + override fun setImage(value: FlareImage) { + view.image = value.uiImage + } + + override fun setContentDescription(value: String?) { + view.setValue(value, forKey = "accessibilityLabel") + view.setValue(value != null, forKey = "isAccessibilityElement") + } +} diff --git a/flareUI/resources-moko/src/iosTest/kotlin/dev/dimension/flare/ui/resources/moko/UIKitMokoResourcesRendererTest.kt b/flareUI/resources-moko/src/iosTest/kotlin/dev/dimension/flare/ui/resources/moko/UIKitMokoResourcesRendererTest.kt new file mode 100644 index 0000000000..8f4e4a4ddf --- /dev/null +++ b/flareUI/resources-moko/src/iosTest/kotlin/dev/dimension/flare/ui/resources/moko/UIKitMokoResourcesRendererTest.kt @@ -0,0 +1,29 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.resources.moko + +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.uikit.UIKitBackend +import dev.dimension.flare.ui.uikit.UIKitNativeWidget +import platform.UIKit.UIImageView +import platform.UIKit.UIViewContentMode +import kotlin.test.Test +import kotlin.test.assertEquals + +public class UIKitMokoResourcesRendererTest { + @Test + public fun resourceImagesPreserveAspectRatioWhenTheirLayoutBoundsGrow() { + val widget = + FlareWidgetSystem(UIKitMokoResourcesRendererPlugin) + .create( + backend = UIKitBackend, + componentType = ResourceImageWidget::class, + ) + val imageView = (widget as UIKitNativeWidget).view as UIImageView + + assertEquals( + UIViewContentMode.UIViewContentModeScaleAspectFit, + imageView.contentMode, + ) + } +} diff --git a/flareUI/resources-moko/src/macosMain/kotlin/dev/dimension/flare/ui/resources/moko/AppKitMokoResourcesRendererPlugin.kt b/flareUI/resources-moko/src/macosMain/kotlin/dev/dimension/flare/ui/resources/moko/AppKitMokoResourcesRendererPlugin.kt new file mode 100644 index 0000000000..35b8b457b5 --- /dev/null +++ b/flareUI/resources-moko/src/macosMain/kotlin/dev/dimension/flare/ui/resources/moko/AppKitMokoResourcesRendererPlugin.kt @@ -0,0 +1,35 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.resources.moko + +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareWidgetRegistrar +import dev.dimension.flare.ui.appkit.AbstractAppKitWidget +import dev.dimension.flare.ui.appkit.AppKitBackend +import platform.AppKit.NSImageScaleProportionallyUpOrDown +import platform.AppKit.NSImageView + +/** Installs [ResourceImage] for the AppKit backend. */ +public object AppKitMokoResourcesRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ResourceImageWidget::class) { _ -> + AppKitResourceImageWidget() + } + } +} + +private class AppKitResourceImageWidget : + AbstractAppKitWidget( + NSImageView().apply { + imageScaling = NSImageScaleProportionallyUpOrDown + }, + ), + ResourceImageWidget { + override fun setImage(value: FlareImage) { + view.image = value.nsImage + } + + override fun setContentDescription(value: String?) { + view.toolTip = value + } +} diff --git a/flareUI/resources-moko/src/macosMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.macos.kt b/flareUI/resources-moko/src/macosMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.macos.kt new file mode 100644 index 0000000000..a149ab679f --- /dev/null +++ b/flareUI/resources-moko/src/macosMain/kotlin/dev/dimension/flare/ui/resources/moko/MokoResources.macos.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.ui.resources.moko + +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.desc.StringDesc +import platform.AppKit.NSImage + +/** Resolver for AppKit hosts. */ +public data object AppleMokoResourceResolver : MokoResourceResolver { + override fun resolve(value: StringDesc): String = value.localized() + + override fun resolve(value: ImageResource): FlareImage = + FlareImage( + nsImage = + requireNotNull(value.toNSImage()) { + "Unable to load Moko image resource $value." + }, + ) +} + +public actual data class FlareImage internal constructor( + public val nsImage: NSImage, +) diff --git a/flareUI/resources-moko/src/nativeTest/kotlin/dev/dimension/flare/ui/resources/moko/MokoResourcesTest.kt b/flareUI/resources-moko/src/nativeTest/kotlin/dev/dimension/flare/ui/resources/moko/MokoResourcesTest.kt new file mode 100644 index 0000000000..58e265ca27 --- /dev/null +++ b/flareUI/resources-moko/src/nativeTest/kotlin/dev/dimension/flare/ui/resources/moko/MokoResourcesTest.kt @@ -0,0 +1,78 @@ +package dev.dimension.flare.ui.resources.moko + +import androidx.compose.runtime.Recomposer +import dev.dimension.flare.ui.FlareBackend +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareComposition +import dev.dimension.flare.ui.FlareWidget +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.desc.StringDesc +import dev.icerock.moko.resources.desc.desc +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals + +public class MokoResourcesTest { + @Test + public fun stringDescIsResolvedThroughProvidedResolver() { + var rendered = "" + val resolver = RecordingResolver("localized value") + val composition = testComposition() + + try { + composition.setContent { + ProvideMokoResources(resolver) { + rendered = stringResource("raw value".desc()) + } + } + + assertEquals("localized value", rendered) + assertEquals(1, resolver.stringResolveCount) + } finally { + composition.dispose() + } + } + + private fun testComposition(): FlareComposition = + FlareComposition( + root = EmptyChildren, + widgetSystem = FlareWidgetSystem(), + backend = TestBackend, + parent = Recomposer(EmptyCoroutineContext), + ) + + private class RecordingResolver( + private val result: String, + ) : MokoResourceResolver { + var stringResolveCount: Int = 0 + private set + + override fun resolve(value: StringDesc): String { + stringResolveCount += 1 + return result + } + + override fun resolve(value: ImageResource): FlareImage = error("This test resolves only strings.") + } + + private data object TestBackend : FlareBackend + + private data object EmptyChildren : FlareChildren { + override fun insert( + index: Int, + widget: FlareWidget, + ): Unit = error("The test content must not emit widgets.") + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ): Unit = error("The test content must not emit widgets.") + + override fun remove( + index: Int, + count: Int, + ): Unit = error("The test content must not emit widgets.") + } +} diff --git a/flareUI/runtime/build.gradle.kts b/flareUI/runtime/build.gradle.kts new file mode 100644 index 0000000000..594b519b35 --- /dev/null +++ b/flareUI/runtime/build.gradle.kts @@ -0,0 +1,52 @@ +import dev.dimension.flareui.buildlogic.FlareUiPlatform +import dev.dimension.flareui.buildlogic.flareUi + +plugins { + id("dev.dimension.flareui.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flareUi { + namespace = "dev.dimension.flare.ui.runtime" + platforms( + FlareUiPlatform.ANDROID, + FlareUiPlatform.JVM, + FlareUiPlatform.IOS, + FlareUiPlatform.MACOS, + ) + } + sourceSets { + val commonMain by getting { + dependencies { + api(dependencies.platform(libs.compose.bom)) + api(libs.compose.runtime) + } + } + val androidMain by getting { + dependencies { + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.kotlinx.coroutines.core) + implementation( + "org.jetbrains.kotlinx:kotlinx-coroutines-android:" + + libs.versions.kotlinx.coroutines.get(), + ) + } + } + val appleMain by getting { + dependencies { + implementation(libs.kotlinx.coroutines.core) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + } + } + } +} diff --git a/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidFlareSnapshotManager.kt b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidFlareSnapshotManager.kt new file mode 100644 index 0000000000..cf9a4f9532 --- /dev/null +++ b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidFlareSnapshotManager.kt @@ -0,0 +1,52 @@ +package dev.dimension.flare.ui.android + +import androidx.compose.runtime.snapshots.ObserverHandle +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.platform.AndroidUiDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +/** Delivers Compose snapshot notifications to the Android View host. */ +internal object AndroidFlareSnapshotManager { + private var users: Int = 0 + private var scope: CoroutineScope? = null + private var notifications: Channel? = null + private var observerHandle: ObserverHandle? = null + + fun acquire() { + users += 1 + if (users != 1) return + + val newNotifications = Channel(capacity = Channel.CONFLATED) + // Dispatch asynchronously. Sending notifications inline from the global write observer can + // re-enter snapshot application before the write has finished. + val newScope = CoroutineScope(SupervisorJob() + AndroidUiDispatcher.Main) + notifications = newNotifications + scope = newScope + newScope.launch { + for (notification in newNotifications) { + Snapshot.sendApplyNotifications() + } + } + observerHandle = + Snapshot.registerGlobalWriteObserver { + newNotifications.trySend(Unit) + } + } + + fun release() { + check(users > 0) { "AndroidFlareSnapshotManager was released without an active user." } + users -= 1 + if (users != 0) return + + observerHandle?.dispose() + observerHandle = null + notifications?.close() + notifications = null + scope?.cancel() + scope = null + } +} diff --git a/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidWidget.kt b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidWidget.kt new file mode 100644 index 0000000000..a679ba5ead --- /dev/null +++ b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/AndroidWidget.kt @@ -0,0 +1,113 @@ +package dev.dimension.flare.ui.android + +import android.content.Context +import android.os.Build +import android.view.View +import android.view.ViewGroup +import dev.dimension.flare.ui.AbstractFlareWidget +import dev.dimension.flare.ui.FlareBackend +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareSize +import dev.dimension.flare.ui.FlareWidget +import kotlin.math.roundToInt + +/** Strong type token for Android View renderer plugins. */ +public class AndroidViewBackend( + public val context: Context, +) : FlareBackend { + override fun toString(): String = "AndroidViewBackend" +} + +/** Renderer contract implemented by Android View-backed primitive plugins. */ +public interface AndroidNativeWidget : FlareWidget { + public val view: View +} + +public abstract class AbstractAndroidWidget( + final override val view: V, +) : AbstractFlareWidget(), + AndroidNativeWidget { + override fun onModifierChanged( + previous: FlareModifier, + current: FlareModifier, + ) { + view.tag = current.testTag + val currentParams = view.layoutParams + val width = current.width.toLayoutSize(view) + val height = current.height.toLayoutSize(view) + if (currentParams == null) { + view.layoutParams = ViewGroup.LayoutParams(width, height) + } else { + currentParams.width = width + currentParams.height = height + view.layoutParams = currentParams + } + } +} + +private fun FlareSize.toLayoutSize(view: View): Int = + when (this) { + FlareSize.Wrap -> ViewGroup.LayoutParams.WRAP_CONTENT + FlareSize.Fill -> ViewGroup.LayoutParams.MATCH_PARENT + is FlareSize.Fixed -> (value * view.resources.displayMetrics.density).roundToInt() + } + +public class AndroidViewChildren( + private val parent: ViewGroup, +) : FlareChildren { + override fun onBeginChanges() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + parent.suppressLayout(true) + } + } + + override fun onEndChanges() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + parent.suppressLayout(false) + } + } + + override fun insert( + index: Int, + widget: FlareWidget, + ) { + val child = widget.requireAndroidWidget().view + val layoutParams = + child.layoutParams + ?: ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + parent.addView(child, index, layoutParams) + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + if (fromIndex == toIndex || count == 0) return + val moved = + List(count) { offset -> + parent.getChildAt(fromIndex + offset) to + parent.getChildAt(fromIndex + offset).layoutParams + } + parent.removeViews(fromIndex, count) + val destination = if (fromIndex > toIndex) toIndex else toIndex - count + moved.forEachIndexed { offset, (view, params) -> + parent.addView(view, destination + offset, params) + } + } + + override fun remove( + index: Int, + count: Int, + ) { + parent.removeViews(index, count) + } + + private fun FlareWidget.requireAndroidWidget(): AndroidNativeWidget = + this as? AndroidNativeWidget + ?: error("Android View backend received non-Android widget $this.") +} diff --git a/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/FlareAndroidViewHost.kt b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/FlareAndroidViewHost.kt new file mode 100644 index 0000000000..735b8e4149 --- /dev/null +++ b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/android/FlareAndroidViewHost.kt @@ -0,0 +1,152 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui.android + +import android.content.Context +import android.os.Looper +import android.widget.FrameLayout +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.ui.platform.AndroidUiDispatcher +import dev.dimension.flare.ui.FlareComposition +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.LowLevelFlareApi +import dev.dimension.flare.ui.ProvideFlareNativeControllerOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Standalone Android View host supplied by Flare Runtime. + * Compose applications can embed this class with `AndroidView`. + */ +public class FlareAndroidViewHost private constructor( + context: Context, + private val widgetSystem: FlareWidgetSystem, + nativeControllerOwner: NativeControllerOwnerBox, +) : FrameLayout(context) { + public constructor( + context: Context, + widgetSystem: FlareWidgetSystem, + ) : this(context, widgetSystem, NativeControllerOwnerBox(null)) + + @LowLevelFlareApi + public constructor( + context: Context, + widgetSystem: FlareWidgetSystem, + nativeControllerOwner: FlareNativeControllerOwner, + ) : this(context, widgetSystem, NativeControllerOwnerBox(nativeControllerOwner)) + + private val nativeControllerOwner = nativeControllerOwner.value + private var content: FlareContent? = null + private var composition: FlareComposition? = null + private var recomposer: Recomposer? = null + private var recomposerScope: CoroutineScope? = null + private var snapshotManagerAcquired: Boolean = false + + public fun setContent(content: FlareContent) { + checkMainThread() + this.content = content + val current = composition + if (current == null && isAttachedToWindow) { + createComposition() + } else if (current != null) { + current.setContent(hostedContent(content)) + } + } + + public fun disposeComposition() { + checkMainThread() + val currentComposition = composition + composition = null + try { + currentComposition?.dispose() + } finally { + releaseRuntime() + } + } + + private fun releaseRuntime() { + val currentRecomposer = recomposer + recomposer = null + val currentScope = recomposerScope + recomposerScope = null + try { + currentRecomposer?.cancel() + currentScope?.cancel() + } finally { + if (snapshotManagerAcquired) { + snapshotManagerAcquired = false + AndroidFlareSnapshotManager.release() + } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (composition == null && content != null) { + createComposition() + } + } + + override fun onDetachedFromWindow() { + disposeComposition() + super.onDetachedFromWindow() + } + + private fun createComposition() { + check(isAttachedToWindow) { + "FlareAndroidViewHost can create a composition only while attached to a window." + } + val currentContent = content ?: return + AndroidFlareSnapshotManager.acquire() + snapshotManagerAcquired = true + try { + val coroutineContext = AndroidUiDispatcher.Main + SupervisorJob() + checkNotNull(coroutineContext[MonotonicFrameClock]) { + "AndroidUiDispatcher.Main must provide its Choreographer frame clock." + } + val scope = CoroutineScope(coroutineContext) + val newRecomposer = Recomposer(coroutineContext) + val newComposition = + FlareComposition( + root = AndroidViewChildren(this), + widgetSystem = widgetSystem, + backend = AndroidViewBackend(context), + parent = newRecomposer, + ) + + recomposerScope = scope + recomposer = newRecomposer + composition = newComposition + scope.launch { + newRecomposer.runRecomposeAndApplyChanges() + } + newComposition.setContent(hostedContent(currentContent)) + } catch (throwable: Throwable) { + disposeComposition() + throw throwable + } + } + + private fun checkMainThread() { + check(Looper.myLooper() === Looper.getMainLooper()) { + "FlareAndroidViewHost must be used from the Android main thread." + } + } + + private fun hostedContent(value: FlareContent): FlareContent = + { + ProvideFlareNativeControllerOwner( + owner = nativeControllerOwner, + content = value, + ) + } +} + +private class NativeControllerOwnerBox( + val value: FlareNativeControllerOwner?, +) diff --git a/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeWidget.kt b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeWidget.kt new file mode 100644 index 0000000000..1459ce77bf --- /dev/null +++ b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/compose/AndroidComposeWidget.kt @@ -0,0 +1,158 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) +@file:Suppress("ktlint:standard:annotation") + +package dev.dimension.flare.ui.compose + +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.UiComposable +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import dev.dimension.flare.ui.AbstractFlareWidget +import dev.dimension.flare.ui.EmitFlareWidget +import dev.dimension.flare.ui.FlareBackend +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareRendererPlugin +import dev.dimension.flare.ui.FlareSize +import dev.dimension.flare.ui.FlareUiComposable +import dev.dimension.flare.ui.FlareWidget +import dev.dimension.flare.ui.FlareWidgetRegistrar + +/** Strong type token for the Jetpack Compose renderer. */ +public data object AndroidComposeBackend : FlareBackend + +/** Snapshot-backed renderer node consumed by [FlareComposeHost]. */ +public interface AndroidComposeWidget : FlareWidget { + @Composable + @UiComposable + public fun Render() +} + +/** Base for typed Compose primitive renderers. */ +public abstract class AbstractAndroidComposeWidget : + AbstractFlareWidget(), + AndroidComposeWidget { + protected var composeModifier: Modifier by mutableStateOf(Modifier) + private set + + final override fun onModifierChanged( + previous: FlareModifier, + current: FlareModifier, + ) { + var result: Modifier = Modifier + current.testTag?.let { result = result.testTag(it) } + result = + when (val width = current.width) { + FlareSize.Wrap -> result + FlareSize.Fill -> result.fillMaxWidth() + is FlareSize.Fixed -> result.width(width.value.dp) + } + result = + when (val height = current.height) { + FlareSize.Wrap -> result + FlareSize.Fill -> result.fillMaxHeight() + is FlareSize.Fixed -> result.height(height.value.dp) + } + composeModifier = result + } +} + +/** Observable child container used by Compose-backed layout primitives. */ +public class AndroidComposeChildren : FlareChildren { + private val widgets = mutableStateListOf() + + override fun insert( + index: Int, + widget: FlareWidget, + ) { + widgets.add(index, widget.requireAndroidComposeWidget()) + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + if (fromIndex == toIndex || count == 0) return + val moved = widgets.subList(fromIndex, fromIndex + count).toList() + widgets.removeRange(fromIndex, fromIndex + count) + val destination = if (fromIndex > toIndex) toIndex else toIndex - count + widgets.addAll(destination, moved) + } + + override fun remove( + index: Int, + count: Int, + ) { + widgets.removeRange(index, index + count) + } + + @Composable + @UiComposable + public fun Render() { + widgets.forEach { widget -> + key(widget) { + widget.Render() + } + } + } + + private fun FlareWidget.requireAndroidComposeWidget(): AndroidComposeWidget = + this as? AndroidComposeWidget + ?: error("Android Compose backend received non-Compose widget $this.") +} + +/** Compose UI content rendered directly inside a Flare Compose tree. */ +public typealias AndroidComposeContent = @Composable @UiComposable () -> Unit + +/** Escape hatch for Android-only components which already expose a Compose API. */ +@Composable +@FlareUiComposable +public fun AndroidCompose(content: AndroidComposeContent) { + EmitFlareWidget( + componentType = AndroidComposeContentWidget::class, + update = { + set(content, AndroidComposeContentWidget::setContent) + }, + ) +} + +/** Registration required by [AndroidCompose]. */ +public object AndroidComposeRuntimeRendererPlugin : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(AndroidComposeContentWidget::class) { _ -> + AndroidComposeContentWidget() + } + } +} + +private class AndroidComposeContentWidget : + AbstractFlareWidget(), + AndroidComposeWidget { + private var renderedContent: AndroidComposeContent by mutableStateOf({}) + + fun setContent(value: AndroidComposeContent) { + renderedContent = value + } + + @Composable + @UiComposable + override fun Render() { + renderedContent() + } + + override fun dispose() { + renderedContent = {} + } +} diff --git a/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/compose/FlareComposeHost.kt b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/compose/FlareComposeHost.kt new file mode 100644 index 0000000000..39c36fda3b --- /dev/null +++ b/flareUI/runtime/src/androidMain/kotlin/dev/dimension/flare/ui/compose/FlareComposeHost.kt @@ -0,0 +1,41 @@ +package dev.dimension.flare.ui.compose + +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.UiComposable +import dev.dimension.flare.ui.FlareComposition +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareWidgetSystem + +/** Hosts a Flare composition which renders real Jetpack Compose UI nodes. */ +@Composable +@UiComposable +public fun FlareComposeHost( + widgetSystem: FlareWidgetSystem, + content: FlareContent, +) { + val parent = rememberCompositionContext() + val root = remember(parent, widgetSystem) { AndroidComposeChildren() } + val currentContent = rememberUpdatedState(content) + + DisposableEffect(parent, root, widgetSystem) { + val composition = + FlareComposition( + root = root, + widgetSystem = widgetSystem, + backend = AndroidComposeBackend, + parent = parent, + ) + composition.setContent { + currentContent.value() + } + onDispose(composition::dispose) + } + + // ponytail: This adds one state-tree hop. Rework applier polymorphism only if profiling shows + // that the extra invalidation misses real frame budgets. + root.Render() +} diff --git a/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/AppleHostController.kt b/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/AppleHostController.kt new file mode 100644 index 0000000000..9f4dd9aa39 --- /dev/null +++ b/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/AppleHostController.kt @@ -0,0 +1,87 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui + +import platform.Foundation.NSThread + +/** Shared lifecycle controller behind the UIKit and AppKit host adapters. */ +internal class AppleHostController( + private val root: FlareChildren, + private val widgetSystem: FlareWidgetSystem, + private val backend: B, + private val hostName: String, +) { + private var content: FlareContent? = null + private var composition: FlareAppleComposition? = null + private var attached: Boolean = false + private var disposed: Boolean = false + + init { + checkAppleMainThread(hostName) + } + + fun setContent(value: FlareContent) { + checkAppleMainThread(hostName) + check(!disposed) { "$hostName is already disposed." } + content = value + val current = composition + if (current != null) { + current.setContent(value) + } else if (attached) { + createComposition() + } + } + + fun attachmentChanged(isAttached: Boolean) { + checkAppleMainThread(hostName) + if (disposed || attached == isAttached) return + attached = isAttached + if (isAttached) { + createComposition() + } else { + disposeComposition() + } + } + + fun dispose() { + checkAppleMainThread(hostName) + if (disposed) return + disposed = true + attached = false + content = null + disposeComposition() + } + + private fun createComposition() { + if (composition != null) return + val currentContent = content ?: return + val newComposition = + FlareAppleComposition( + root = root, + widgetSystem = widgetSystem, + backend = backend, + hostName = hostName, + ) + composition = newComposition + try { + newComposition.setContent(currentContent) + } catch (throwable: Throwable) { + disposeComposition() + throw throwable + } + } + + private fun disposeComposition() { + composition?.dispose() + composition = null + } +} + +internal fun checkAppleMainThread(hostName: String) { + check(NSThread.isMainThread) { + "$hostName must be used from the Apple main thread." + } +} diff --git a/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/AppleMonotonicTime.kt b/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/AppleMonotonicTime.kt new file mode 100644 index 0000000000..8a645f9c1e --- /dev/null +++ b/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/AppleMonotonicTime.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui + +import kotlinx.cinterop.convert +import platform.posix.CLOCK_MONOTONIC_RAW +import platform.posix.clock_gettime_nsec_np + +/** Darwin monotonic time source, adapted from Cash App Molecule's display-link clock. */ +internal fun monotonicFrameTimeNanos(): Long = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW.toUInt()).convert() diff --git a/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/FlareAppleComposition.kt b/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/FlareAppleComposition.kt new file mode 100644 index 0000000000..f5b38c819d --- /dev/null +++ b/flareUI/runtime/src/appleMain/kotlin/dev/dimension/flare/ui/FlareAppleComposition.kt @@ -0,0 +1,141 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui + +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.snapshots.ObserverHandle +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import platform.Foundation.NSThread +import kotlin.coroutines.CoroutineContext +import kotlin.native.HiddenFromObjC + +/** + * Shared Compose Runtime driver for Apple renderer hosts. + * + * UIKit and AppKit own different widget trees, but they must use the same snapshot notification, + * frame-clock, and disposal rules. Keeping that machinery here prevents each backend from + * installing a subtly different Apple recomposer. + */ +@HiddenFromObjC +@LowLevelFlareApi +public class FlareAppleComposition( + root: FlareChildren, + widgetSystem: FlareWidgetSystem, + backend: B, + private val hostName: String, +) { + init { + checkMainThread() + } + + private val runtime: AppleRecomposerRuntime = AppleRecomposerRuntimePool.acquire() + private val composition = + try { + FlareComposition( + root = root, + widgetSystem = widgetSystem, + backend = backend, + parent = runtime.recomposer, + ) + } catch (throwable: Throwable) { + AppleRecomposerRuntimePool.release(runtime) + throw throwable + } + private var disposed = false + + public fun setContent(content: FlareContent) { + checkMainThread() + check(!disposed) { "$hostName is already disposed." } + composition.setContent(content) + } + + public fun dispose() { + checkMainThread() + if (disposed) return + disposed = true + try { + composition.dispose() + } finally { + AppleRecomposerRuntimePool.release(runtime) + } + } + + private fun checkMainThread() { + check(NSThread.isMainThread) { + "$hostName must be used from the Apple main thread." + } + } +} + +private class AppleRecomposerRuntime { + private val frameClock: AppleFrameClock = createAppleFrameClock() + private val coroutineContext = appleRecomposerContext(frameClock) + private val scope = CoroutineScope(coroutineContext) + private val notifications = Channel(capacity = Channel.CONFLATED) + private val observerHandle: ObserverHandle = + Snapshot.registerGlobalWriteObserver { + notifications.trySend(Unit) + } + val recomposer: Recomposer = Recomposer(coroutineContext) + + init { + // Keep delivery out of the global write observer to avoid snapshot re-entry. + scope.launch { + for (notification in notifications) { + Snapshot.sendApplyNotifications() + } + } + scope.launch { + recomposer.runRecomposeAndApplyChanges() + } + } + + fun dispose() { + observerHandle.dispose() + notifications.close() + recomposer.cancel() + scope.cancel() + } +} + +private object AppleRecomposerRuntimePool { + private var runtime: AppleRecomposerRuntime? = null + private var users: Int = 0 + + fun acquire(): AppleRecomposerRuntime { + check(NSThread.isMainThread) { + "The Apple Flare runtime must be acquired on the main thread." + } + val current = runtime ?: AppleRecomposerRuntime().also { runtime = it } + users += 1 + return current + } + + fun release(value: AppleRecomposerRuntime) { + check(NSThread.isMainThread) { + "The Apple Flare runtime must be released on the main thread." + } + check(runtime === value && users > 0) { + "The Apple Flare runtime was released without a matching acquisition." + } + users -= 1 + if (users == 0) { + runtime = null + value.dispose() + } + } +} + +internal interface AppleFrameClock : MonotonicFrameClock + +internal expect fun createAppleFrameClock(): AppleFrameClock + +private fun appleRecomposerContext(frameClock: MonotonicFrameClock): CoroutineContext = + Dispatchers.Main.immediate + frameClock + SupervisorJob() diff --git a/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareBackend.kt b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareBackend.kt new file mode 100644 index 0000000000..7c12f563c1 --- /dev/null +++ b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareBackend.kt @@ -0,0 +1,4 @@ +package dev.dimension.flare.ui + +/** Compile-time identity for one renderer family. */ +public interface FlareBackend diff --git a/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareModifier.kt b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareModifier.kt new file mode 100644 index 0000000000..0e1a083271 --- /dev/null +++ b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareModifier.kt @@ -0,0 +1,50 @@ +package dev.dimension.flare.ui + +import androidx.compose.runtime.Immutable + +/** One-axis size requested from a renderer. Values use dp on Android and points on Apple platforms. */ +@Immutable +public sealed interface FlareSize { + public data object Wrap : FlareSize + + public data object Fill : FlareSize + + @Immutable + public data class Fixed( + public val value: Float, + ) : FlareSize { + init { + require(value.isFinite() && value >= 0f) { + "A fixed Flare size must be finite and non-negative." + } + } + } +} + +/** Immutable metadata applied to one primitive. */ +@Immutable +public data class FlareModifier( + public val testTag: String? = null, + public val width: FlareSize = FlareSize.Wrap, + public val height: FlareSize = FlareSize.Wrap, +) { + init { + require(testTag == null || testTag.isNotBlank()) { + "A test tag cannot be blank." + } + } + + public companion object { + public val None: FlareModifier = FlareModifier() + } + + public fun fillMaxWidth(): FlareModifier = copy(width = FlareSize.Fill) + + public fun fillMaxHeight(): FlareModifier = copy(height = FlareSize.Fill) + + public fun fillMaxSize(): FlareModifier = copy(width = FlareSize.Fill, height = FlareSize.Fill) + + public fun width(value: Float): FlareModifier = copy(width = FlareSize.Fixed(value)) + + public fun height(value: Float): FlareModifier = copy(height = FlareSize.Fixed(value)) +} diff --git a/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareNativeControllerOwner.kt b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareNativeControllerOwner.kt new file mode 100644 index 0000000000..36551fc618 --- /dev/null +++ b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareNativeControllerOwner.kt @@ -0,0 +1,39 @@ +@file:OptIn(dev.dimension.flare.ui.LowLevelFlareApi::class) + +package dev.dimension.flare.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Opaque native controller-containment owner supplied by a controller-aware platform host. + * + * Runtime deliberately assigns no navigation semantics to this owner. Optional modules provide + * platform-specific owner implementations and interpret them at their renderer seam. + */ +@LowLevelFlareApi +public interface FlareNativeControllerOwner + +private val LocalFlareNativeControllerOwner = + staticCompositionLocalOf { null } + +/** Returns the nearest native controller owner, or null inside a view-only host. */ +@LowLevelFlareApi +@Composable +@FlareUiComposable +public fun currentFlareNativeControllerOwner(): FlareNativeControllerOwner? = LocalFlareNativeControllerOwner.current + +/** Provides a native controller owner to this Flare content and every deferred subcomposition. */ +@LowLevelFlareApi +@Composable +@FlareUiComposable +public fun ProvideFlareNativeControllerOwner( + owner: FlareNativeControllerOwner?, + content: FlareContent, +) { + CompositionLocalProvider( + LocalFlareNativeControllerOwner provides owner, + content = content, + ) +} diff --git a/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareRuntime.kt b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareRuntime.kt new file mode 100644 index 0000000000..1c388643ff --- /dev/null +++ b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareRuntime.kt @@ -0,0 +1,298 @@ +@file:Suppress("ktlint:standard:annotation") + +package dev.dimension.flare.ui + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableTargetMarker +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ReusableComposition +import androidx.compose.runtime.Updater +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.staticCompositionLocalOf +import kotlin.reflect.KClass + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This is a low-level Flare renderer API.", +) +@Retention(AnnotationRetention.BINARY) +public annotation class LowLevelFlareApi + +@Retention(AnnotationRetention.BINARY) +@ComposableTargetMarker(description = "Flare UI") +@Target( + AnnotationTarget.FILE, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, +) +public annotation class FlareUiComposable + +public typealias FlareContent = @Composable @FlareUiComposable () -> Unit + +/** Creates independently disposable Flare compositions which share the current renderer context. */ +@LowLevelFlareApi +public interface FlareSubcompositionFactory { + public fun create(root: FlareChildren): FlareSubcomposition +} + +/** One independently disposable Flare composition created for deferred content such as a list item. */ +@LowLevelFlareApi +public interface FlareSubcomposition { + public fun setContent(content: FlareContent) + + /** Stops observations and remembered effects while preserving the emitted widget tree. */ + public fun deactivate() + + public fun dispose() +} + +private val LocalFlareWidgetFactory = + staticCompositionLocalOf { + error("No FlareWidgetSystem was provided.") + } + +private interface BoundFlareWidgetFactory { + fun create(componentType: KClass): W +} + +@OptIn(LowLevelFlareApi::class) +private class DefaultBoundFlareWidgetFactory( + private val widgetSystem: FlareWidgetSystem, + private val backend: B, +) : BoundFlareWidgetFactory { + override fun create(componentType: KClass): W = widgetSystem.create(backend, componentType) +} + +/** + * Owns one Compose Runtime composition which mutates widgets supplied by the selected backend. + * + * The platform host owns the [parent] recomposer and is responsible for its frame clock and thread. + */ +public class FlareComposition( + root: FlareChildren, + widgetSystem: FlareWidgetSystem, + backend: B, + parent: CompositionContext, +) { + private val delegate = + DefaultFlareSubcomposition( + root = root, + widgetFactory = DefaultBoundFlareWidgetFactory(widgetSystem, backend), + parent = parent, + ) + + public fun setContent(content: FlareContent) { + delegate.setContent(content) + } + + public fun dispose() { + delegate.dispose() + } +} + +/** Remembers an owner for deferred child compositions and closes every child with its parent. */ +@LowLevelFlareApi +@Composable +@FlareUiComposable +public fun rememberFlareSubcompositionFactory(): FlareSubcompositionFactory { + val parent = rememberCompositionContext() + val widgetFactory = LocalFlareWidgetFactory.current + val factory = + remember(parent, widgetFactory) { + DefaultFlareSubcompositionFactory( + parent = parent, + widgetFactory = widgetFactory, + ) + } + DisposableEffect(factory) { + onDispose(factory::dispose) + } + return factory +} + +@OptIn(LowLevelFlareApi::class) +private class DefaultFlareSubcompositionFactory( + private val parent: CompositionContext, + private val widgetFactory: BoundFlareWidgetFactory, +) : FlareSubcompositionFactory { + private val compositions = mutableSetOf() + private var disposed: Boolean = false + + override fun create(root: FlareChildren): FlareSubcomposition { + check(!disposed) { "FlareSubcompositionFactory is already disposed." } + lateinit var result: DefaultFlareSubcomposition + result = + DefaultFlareSubcomposition( + root = root, + widgetFactory = widgetFactory, + parent = parent, + onDisposed = { compositions.remove(result) }, + ) + compositions += result + return result + } + + fun dispose() { + if (disposed) return + disposed = true + val current = compositions.toList() + compositions.clear() + current.forEach(DefaultFlareSubcomposition::dispose) + } +} + +@OptIn(LowLevelFlareApi::class) +private class DefaultFlareSubcomposition( + root: FlareChildren, + private val widgetFactory: BoundFlareWidgetFactory, + parent: CompositionContext, + private val onDisposed: () -> Unit = {}, +) : FlareSubcomposition { + private val rootNode = RootRuntimeNode(root) + private val composition: ReusableComposition = + ReusableComposition( + applier = FlareApplier(rootNode), + parent = parent, + ) + private var disposed: Boolean = false + + override fun setContent(content: FlareContent) { + check(!disposed) { "FlareSubcomposition is already disposed." } + composition.setContent { + CompositionLocalProvider(LocalFlareWidgetFactory provides widgetFactory) { + content() + } + } + } + + override fun deactivate() { + check(!disposed) { "FlareSubcomposition is already disposed." } + composition.deactivate() + } + + override fun dispose() { + if (disposed) return + disposed = true + try { + composition.dispose() + } finally { + try { + rootNode.clear() + } finally { + onDisposed() + } + } + } +} + +/** + * Typed update surface consumed by primitive functions. + * + * It deliberately exposes typed values one at a time rather than passing an untyped props object + * through the renderer registry. + */ +@LowLevelFlareApi +public class FlareWidgetUpdater internal constructor( + private val updater: Updater, +) { + public fun set( + value: V, + update: W.(V) -> Unit, + ) { + updater.set(value) { + @Suppress("UNCHECKED_CAST") + (requireWidgetNode().widget as W).update(it) + } + } + + internal fun setModifier(modifier: FlareModifier) { + updater.set(modifier) { + requireWidgetNode().setModifier(it) + } + } +} + +/** + * Emits one renderer-provided primitive. Normally called only by primitive APIs. + */ +@LowLevelFlareApi +@Composable +@FlareUiComposable +public fun EmitFlareWidget( + componentType: KClass, + modifier: FlareModifier = FlareModifier.None, + update: FlareWidgetUpdater.() -> Unit = {}, + content: FlareContent? = null, +) { + val widgetFactory = LocalFlareWidgetFactory.current + ComposeNode( + factory = { + WidgetRuntimeNode( + widget = widgetFactory.create(componentType), + ) + }, + update = { + FlareWidgetUpdater(this).apply { + setModifier(modifier) + update() + } + }, + content = content ?: {}, + ) +} + +private fun RuntimeNode.requireWidgetNode(): WidgetRuntimeNode = + this as? WidgetRuntimeNode + ?: error("A primitive property update was applied to a non-widget runtime node.") + +private class FlareApplier( + private val rootNode: RootRuntimeNode, +) : AbstractApplier(rootNode) { + override fun onBeginChanges() { + rootNode.onBeginChanges() + } + + override fun onEndChanges() { + rootNode.onEndChanges() + } + + override fun insertTopDown( + index: Int, + instance: RuntimeNode, + ) { + current.prepareInsert(index, instance) + } + + override fun insertBottomUp( + index: Int, + instance: RuntimeNode, + ) { + current.commitInsert(index, instance) + } + + override fun remove( + index: Int, + count: Int, + ) { + current.remove(index, count) + } + + override fun move( + from: Int, + to: Int, + count: Int, + ) { + current.move(from, to, count) + } + + override fun onClear() { + root.clear() + } +} diff --git a/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareWidget.kt b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareWidget.kt new file mode 100644 index 0000000000..f671d8a273 --- /dev/null +++ b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareWidget.kt @@ -0,0 +1,83 @@ +package dev.dimension.flare.ui + +/** + * One backend primitive managed by Flare's Compose Runtime applier. + * + * Implementations wrap an Android View, UIView, or NSView, or hold observable renderer state for + * Compose UI. Layout primitives normally expose their backend container through [children]. + */ +public interface FlareWidget { + public val modifier: FlareModifier + + public fun updateModifier(modifier: FlareModifier) + + /** The primitive's single child container, or null for a leaf primitive. */ + public val children: FlareChildren? + get() = null + + /** Releases callbacks and platform resources. Called exactly once. */ + public fun dispose(): Unit = Unit +} + +/** + * Structural operations for one backend child container. + * + * Runtime lifecycle callbacks are dispatched by Flare itself so every backend observes the same + * ordering. + */ +public interface FlareChildren { + /** Called before one Compose Runtime apply transaction mutates this tree. */ + public fun onBeginChanges(): Unit = Unit + + /** Called after one Compose Runtime apply transaction has finished mutating this tree. */ + public fun onEndChanges(): Unit = Unit + + public fun insert( + index: Int, + widget: FlareWidget, + ) + + public fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) + + public fun remove( + index: Int, + count: Int, + ) +} + +/** + * Convenience base which owns modifier state while leaving platform application to subclasses. + */ +public abstract class AbstractFlareWidget : FlareWidget { + final override var modifier: FlareModifier = FlareModifier.None + private set + + final override fun updateModifier(modifier: FlareModifier) { + if (this.modifier == modifier) return + val previous = this.modifier + this.modifier = modifier + onModifierChanged(previous, modifier) + } + + protected open fun onModifierChanged( + previous: FlareModifier, + current: FlareModifier, + ): Unit = Unit +} + +/** Scoped registration surface supplied to one renderer plugin. */ +public interface FlareWidgetRegistrar { + public fun register( + componentType: kotlin.reflect.KClass, + factory: (B) -> W, + ) +} + +/** Installable group of native primitive renderers for one strongly typed backend. */ +public interface FlareRendererPlugin { + public fun register(registrar: FlareWidgetRegistrar) +} diff --git a/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareWidgetSystem.kt b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareWidgetSystem.kt new file mode 100644 index 0000000000..e41dc1a602 --- /dev/null +++ b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/FlareWidgetSystem.kt @@ -0,0 +1,45 @@ +package dev.dimension.flare.ui + +import kotlin.reflect.KClass + +/** + * Immutable, statically assembled set of native widget factories for one backend type. + * + * The backend instance is supplied only when a widget is created. A reusable widget system + * therefore cannot accidentally retain a host-owned Android Context or Apple view hierarchy. + */ +public class FlareWidgetSystem( + vararg plugins: FlareRendererPlugin, +) { + private val factories: Map, (B) -> FlareWidget> = + run { + val result = + linkedMapOf, (B) -> FlareWidget>() + val registrar = + object : FlareWidgetRegistrar { + override fun register( + componentType: KClass, + factory: (B) -> W, + ) { + check(componentType !in result) { + "Widget system already has a renderer for $componentType." + } + result[componentType] = factory + } + } + plugins.forEach { plugin -> plugin.register(registrar) } + result.toMap() + } + + @LowLevelFlareApi + public fun create( + backend: B, + componentType: KClass, + ): W { + val factory = + factories[componentType] + ?: error("Backend $backend has no renderer for $componentType.") + @Suppress("UNCHECKED_CAST") + return factory(backend) as W + } +} diff --git a/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/RuntimeNode.kt b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/RuntimeNode.kt new file mode 100644 index 0000000000..9149d1e96d --- /dev/null +++ b/flareUI/runtime/src/commonMain/kotlin/dev/dimension/flare/ui/RuntimeNode.kt @@ -0,0 +1,116 @@ +package dev.dimension.flare.ui + +internal abstract class RuntimeNode { + protected val nodes: MutableList = mutableListOf() + protected abstract val target: FlareChildren + + open fun prepareInsert( + index: Int, + instance: RuntimeNode, + ) { + require(instance is WidgetRuntimeNode) { + "A Flare container can contain only primitive widgets." + } + require(index in 0..nodes.size) { "Invalid child insertion index $index." } + } + + fun commitInsert( + index: Int, + instance: RuntimeNode, + ) { + val child = instance as WidgetRuntimeNode + target.insert(index, child.widget) + nodes.add(index, child) + } + + fun remove( + index: Int, + count: Int, + ) { + requireRange(index, count) + val removed = nodes.subList(index, index + count).map { it as WidgetRuntimeNode } + target.remove(index, count) + nodes.subList(index, index + count).clear() + removed.forEach(WidgetRuntimeNode::disposeSubtree) + } + + fun move( + from: Int, + to: Int, + count: Int, + ) { + if (from == to || count == 0) return + target.move(from, to, count) + nodes.moveRange(from, to, count) + } + + fun clear() { + if (nodes.isNotEmpty()) { + remove(0, nodes.size) + } + } + + abstract fun disposeSubtree() + + private fun requireRange( + index: Int, + count: Int, + ) { + require(index >= 0 && count >= 0 && index + count <= nodes.size) { + "Invalid child range index=$index, count=$count, size=${nodes.size}." + } + } +} + +internal class RootRuntimeNode( + override val target: FlareChildren, +) : RuntimeNode() { + fun onBeginChanges() { + target.onBeginChanges() + } + + fun onEndChanges() { + target.onEndChanges() + } + + override fun disposeSubtree() { + clear() + } +} + +internal class WidgetRuntimeNode( + val widget: FlareWidget, +) : RuntimeNode() { + private var disposed: Boolean = false + + override val target: FlareChildren + get() = checkNotNull(widget.children) { "$widget does not accept children." } + + fun setModifier(modifier: FlareModifier) { + widget.updateModifier(modifier) + } + + override fun disposeSubtree() { + if (disposed) return + disposed = true + clear() + widget.dispose() + } +} + +private fun MutableList.moveRange( + from: Int, + to: Int, + count: Int, +) { + if (from == to || count == 0) return + require(from >= 0 && count >= 0 && from + count <= size) { + "Invalid move source from=$from, count=$count, size=$size." + } + require(to >= 0 && to <= size) { "Invalid move destination to=$to, size=$size." } + + val moved = subList(from, from + count).toList() + subList(from, from + count).clear() + val destination = if (from > to) to else to - count + addAll(destination, moved) +} diff --git a/flareUI/runtime/src/commonTest/kotlin/dev/dimension/flare/ui/FlareRuntimeTest.kt b/flareUI/runtime/src/commonTest/kotlin/dev/dimension/flare/ui/FlareRuntimeTest.kt new file mode 100644 index 0000000000..bd160bb658 --- /dev/null +++ b/flareUI/runtime/src/commonTest/kotlin/dev/dimension/flare/ui/FlareRuntimeTest.kt @@ -0,0 +1,393 @@ +@file:OptIn(LowLevelFlareApi::class) + +package dev.dimension.flare.ui + +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class FlareRuntimeTest { + @Test + fun createsAndDisposesIndependentSubcomposition() { + val events = mutableListOf() + val parentRoot = RecordingChildren() + val itemRoot = RecordingChildren() + val system = testWidgetSystem(events) + lateinit var factory: FlareSubcompositionFactory + + HeadlessTestHost(parentRoot, system, TestBackend).use { host -> + host.setContent { + factory = rememberFlareSubcompositionFactory() + } + + val itemComposition = factory.create(itemRoot) + itemComposition.setContent { + TestLeaf("item") + } + + assertEquals("item", (itemRoot.widgets.single() as RecordingLeafWidget).renderedText) + + itemComposition.dispose() + + assertEquals(emptyList(), itemRoot.widgets) + assertEquals(listOf("dispose:leaf"), events) + } + } + + @Test + fun deactivatesSubcompositionEffectsWhilePreservingItsWidgetTree() { + val events = mutableListOf() + val parentRoot = RecordingChildren() + val itemRoot = RecordingChildren() + val system = testWidgetSystem(events) + var activeEffects = 0 + var disposedEffects = 0 + lateinit var factory: FlareSubcompositionFactory + val content: (String) -> FlareContent = { label -> + { + DisposableEffect(Unit) { + activeEffects += 1 + onDispose { + activeEffects -= 1 + disposedEffects += 1 + } + } + TestLeaf(label) + } + } + + HeadlessTestHost(parentRoot, system, TestBackend).use { host -> + host.setContent { + factory = rememberFlareSubcompositionFactory() + } + val itemComposition = factory.create(itemRoot) + itemComposition.setContent(content("first")) + val preservedWidget = itemRoot.widgets.single() + assertEquals(1, activeEffects) + + itemComposition.deactivate() + + assertEquals(0, activeEffects) + assertEquals(1, disposedEffects) + assertSame(preservedWidget, itemRoot.widgets.single()) + assertEquals(emptyList(), events) + + itemComposition.setContent(content("second")) + + assertEquals(1, activeEffects) + assertEquals(1, itemRoot.widgets.size) + assertEquals("second", (itemRoot.widgets.single() as RecordingLeafWidget).renderedText) + } + + assertEquals(0, activeEffects) + assertEquals(2, disposedEffects) + assertEquals(listOf("dispose:leaf", "dispose:leaf"), events) + } + + @Test + fun parentDisposesOwnedSubcompositions() { + val events = mutableListOf() + val parentRoot = RecordingChildren() + val itemRoot = RecordingChildren() + val system = testWidgetSystem(events) + lateinit var factory: FlareSubcompositionFactory + + HeadlessTestHost(parentRoot, system, TestBackend).use { host -> + host.setContent { + factory = rememberFlareSubcompositionFactory() + } + factory.create(itemRoot).setContent { + TestLeaf("item") + } + assertEquals(1, itemRoot.widgets.size) + } + + assertEquals(emptyList(), itemRoot.widgets) + assertEquals(listOf("dispose:leaf"), events) + assertFailsWith { + factory.create(RecordingChildren()) + } + } + + @Test + fun directlyBuildsNativeTreeAndDisposesBottomUp() { + val events = mutableListOf() + val root = RecordingChildren() + val system = testWidgetSystem(events) + val content: FlareContent = { + TestContainer { + TestLeaf("first") + } + } + val composition = + FlareComposition( + root = root, + widgetSystem = system, + backend = TestBackend, + parent = Recomposer(EmptyCoroutineContext), + ) + + composition.setContent(content) + + val container = root.widgets.single() as RecordingContainerWidget + val leaf = container.content.widgets.single() as RecordingLeafWidget + assertEquals("first", leaf.renderedText) + assertEquals(1, root.beginChangesCount) + assertEquals(1, root.endChangesCount) + + composition.dispose() + + assertEquals( + listOf( + "dispose:leaf", + "dispose:container", + ), + events, + ) + } + + @Test + fun rejectsDuplicateComponentRenderer() { + val failure = + assertFailsWith { + FlareWidgetSystem( + testPlugin(mutableListOf()), + object : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(LeafType) { _ -> + RecordingLeafWidget(mutableListOf()) + } + } + }, + ) + } + + checkNotNull(failure.message) + assertEquals(true, failure.message!!.contains(LeafType.toString())) + } + + @Test + fun recomposesHeadlesslyAndRetainsWidgetIdentity() { + val root = RecordingChildren() + val system = testWidgetSystem(mutableListOf()) + + HeadlessTestHost(root, system, TestBackend).use { host -> + host.setContent { + var count by remember { mutableIntStateOf(0) } + TestLeaf( + text = "Count: $count", + onClick = { count += 1 }, + ) + } + + val initial = root.widgets.single() as RecordingLeafWidget + assertEquals("Count: 0", initial.renderedText) + + initial.click() + host.awaitIdle() + + assertSame(initial, root.widgets.single()) + assertEquals("Count: 1", initial.renderedText) + } + } +} + +private data object TestBackend : FlareBackend + +private val ContainerType = TestContainerWidget::class +private val LeafType = TestLeafWidget::class + +private interface TestContainerWidget : FlareWidget + +private interface TestLeafWidget : FlareWidget { + fun setText(value: String) + + fun setOnClick(value: () -> Unit) +} + +@Composable +@FlareUiComposable +private fun TestContainer(content: FlareContent) { + EmitFlareWidget( + componentType = ContainerType, + content = content, + ) +} + +@Composable +@FlareUiComposable +private fun TestLeaf( + text: String, + onClick: () -> Unit = {}, +) { + EmitFlareWidget( + componentType = LeafType, + update = { + set(text, TestLeafWidget::setText) + set(onClick, TestLeafWidget::setOnClick) + }, + ) +} + +private fun testWidgetSystem(events: MutableList): FlareWidgetSystem = FlareWidgetSystem(testPlugin(events)) + +private fun testPlugin(events: MutableList): FlareRendererPlugin = + object : FlareRendererPlugin { + override fun register(registrar: FlareWidgetRegistrar) { + registrar.register(ContainerType) { _ -> + RecordingContainerWidget(events) + } + registrar.register(LeafType) { _ -> + RecordingLeafWidget(events) + } + } + } + +private class RecordingChildren : FlareChildren { + val widgets = mutableListOf() + var beginChangesCount: Int = 0 + var endChangesCount: Int = 0 + + override fun onBeginChanges() { + beginChangesCount += 1 + } + + override fun onEndChanges() { + endChangesCount += 1 + } + + override fun insert( + index: Int, + widget: FlareWidget, + ) { + widgets.add(index, widget) + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + val moved = widgets.subList(fromIndex, fromIndex + count).toList() + widgets.subList(fromIndex, fromIndex + count).clear() + val destination = if (fromIndex > toIndex) toIndex else toIndex - count + widgets.addAll(destination, moved) + } + + override fun remove( + index: Int, + count: Int, + ) { + widgets.subList(index, index + count).clear() + } +} + +private class RecordingContainerWidget( + private val events: MutableList, +) : AbstractFlareWidget(), + TestContainerWidget { + override val children: RecordingChildren = RecordingChildren() + val content: RecordingChildren + get() = children + + override fun dispose() { + events += "dispose:container" + } +} + +private class RecordingLeafWidget( + private val events: MutableList, +) : AbstractFlareWidget(), + TestLeafWidget { + var renderedText: String = "" + private var onClick: () -> Unit = {} + + override fun setText(value: String) { + renderedText = value + } + + override fun setOnClick(value: () -> Unit) { + onClick = value + } + + fun click() { + onClick() + } + + override fun dispose() { + onClick = {} + events += "dispose:leaf" + } +} + +private class HeadlessTestHost( + root: FlareChildren, + widgetSystem: FlareWidgetSystem, + backend: B, +) : AutoCloseable { + private var frameTimeNanos: Long = 0L + private val frameClock: BroadcastFrameClock = createFrameClock() + private val scope = + CoroutineScope( + Dispatchers.Unconfined + + SupervisorJob() + + frameClock, + ) + private val recomposer = Recomposer(scope.coroutineContext) + private val composition = FlareComposition(root, widgetSystem, backend, recomposer) + + init { + scope.launch { + recomposer.runRecomposeAndApplyChanges() + } + } + + fun setContent(content: FlareContent) { + composition.setContent(content) + awaitIdle() + } + + fun awaitIdle() { + Snapshot.sendApplyNotifications() + runBlocking { + recomposer.awaitIdle() + } + } + + override fun close() { + composition.dispose() + recomposer.cancel() + scope.cancel() + } + + private fun createFrameClock(): BroadcastFrameClock { + lateinit var clock: BroadcastFrameClock + clock = + BroadcastFrameClock { + frameTimeNanos += FRAME_DURATION_NANOS + clock.sendFrame(frameTimeNanos) + } + return clock + } + + private companion object { + const val FRAME_DURATION_NANOS: Long = 16_666_667L + } +} diff --git a/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/AppleFrameClock.ios.kt b/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/AppleFrameClock.ios.kt new file mode 100644 index 0000000000..63508108c9 --- /dev/null +++ b/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/AppleFrameClock.ios.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn( + kotlinx.cinterop.BetaInteropApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui + +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.MonotonicFrameClock +import kotlinx.cinterop.ObjCAction +import platform.Foundation.NSRunLoop +import platform.Foundation.NSRunLoopCommonModes +import platform.Foundation.NSSelectorFromString +import platform.Foundation.NSThread +import platform.QuartzCore.CADisplayLink +import platform.darwin.NSObject + +internal actual fun createAppleFrameClock(): AppleFrameClock = IOSDisplayLinkFrameClock + +/** On-demand CADisplayLink clock adapted from Cash App Molecule's iOS clock. */ +private object IOSDisplayLinkFrameClock : AppleFrameClock { + private val target = DisplayLinkTarget(this) + private val displayLink = + CADisplayLink.displayLinkWithTarget( + target = target, + selector = NSSelectorFromString(DisplayLinkTarget::tickClock.name), + ) + private val broadcastFrameClock = + BroadcastFrameClock { + displayLink.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) + } + + init { + check(NSThread.isMainThread) { + "The iOS frame clock must be created on the main thread." + } + } + + override suspend fun withFrameNanos(onFrame: (Long) -> R): R = broadcastFrameClock.withFrameNanos(onFrame) + + private fun tickClock() { + // Remove the completed request before resuming frame awaiters. Resumed work can request + // another frame synchronously; removing afterwards would detach that newly scheduled tick. + displayLink.removeFromRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) + broadcastFrameClock.sendFrame(monotonicFrameTimeNanos()) + } + + /** Objective-C selector bridge for the Kotlin [MonotonicFrameClock] object. */ + private class DisplayLinkTarget( + private val frameClock: IOSDisplayLinkFrameClock, + ) : NSObject() { + @ObjCAction + fun tickClock() { + frameClock.tickClock() + } + } +} diff --git a/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/FlareUIKitHost.kt b/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/FlareUIKitHost.kt new file mode 100644 index 0000000000..d269430850 --- /dev/null +++ b/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/FlareUIKitHost.kt @@ -0,0 +1,90 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.AppleHostController +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.LowLevelFlareApi +import dev.dimension.flare.ui.ProvideFlareNativeControllerOwner +import platform.CoreGraphics.CGRectMake +import platform.UIKit.UILayoutConstraintAxisVertical +import platform.UIKit.UIStackView +import platform.UIKit.UIStackViewAlignmentLeading +import kotlin.native.HiddenFromObjC + +/** + * Direct UIKit host supplied by Flare Runtime. + * Its [view] can be embedded directly in any UIKit view hierarchy. + */ +public class FlareUIKitHost private constructor( + widgetSystem: FlareWidgetSystem, + nativeControllerOwner: NativeControllerOwnerBox, +) { + public constructor(widgetSystem: FlareWidgetSystem) : + this(widgetSystem, NativeControllerOwnerBox(null)) + + @LowLevelFlareApi + public constructor( + widgetSystem: FlareWidgetSystem, + nativeControllerOwner: FlareNativeControllerOwner, + ) : this(widgetSystem, NativeControllerOwnerBox(nativeControllerOwner)) + + private val nativeControllerOwner = nativeControllerOwner.value + private val hostView = + UIKitHostView().apply { + axis = UILayoutConstraintAxisVertical + alignment = UIStackViewAlignmentLeading + } + private val controller = + AppleHostController( + root = UIKitChildren(hostView), + widgetSystem = widgetSystem, + backend = UIKitBackend, + hostName = HOST_NAME, + ) + + public val view: UIStackView + get() = hostView + + init { + hostView.onAttachmentChanged = controller::attachmentChanged + } + + @HiddenFromObjC + public fun setContent(content: FlareContent) { + controller.setContent(hostedContent(content)) + } + + public fun dispose() { + controller.dispose() + hostView.onAttachmentChanged = null + } + + private fun hostedContent(value: FlareContent): FlareContent = + { + ProvideFlareNativeControllerOwner( + owner = nativeControllerOwner, + content = value, + ) + } +} + +private class NativeControllerOwnerBox( + val value: FlareNativeControllerOwner?, +) + +private class UIKitHostView : UIStackView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + var onAttachmentChanged: ((Boolean) -> Unit)? = null + + override fun didMoveToWindow() { + super.didMoveToWindow() + onAttachmentChanged?.invoke(window != null) + } +} + +private const val HOST_NAME: String = "FlareUIKitHost" diff --git a/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitWidget.kt b/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitWidget.kt new file mode 100644 index 0000000000..2c4d78e98e --- /dev/null +++ b/flareUI/runtime/src/iosMain/kotlin/dev/dimension/flare/ui/uikit/UIKitWidget.kt @@ -0,0 +1,135 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.AbstractFlareWidget +import dev.dimension.flare.ui.FlareBackend +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareSize +import dev.dimension.flare.ui.FlareWidget +import platform.Foundation.setValue +import platform.UIKit.NSLayoutConstraint +import platform.UIKit.UIStackView +import platform.UIKit.UIView + +/** Strong type token for UIKit renderer plugins. */ +public data object UIKitBackend : FlareBackend + +/** Renderer contract implemented by UIKit-backed primitive plugins. */ +public interface UIKitNativeWidget : FlareWidget { + public val view: UIView +} + +public abstract class AbstractUIKitWidget( + final override val view: V, +) : AbstractFlareWidget(), + UIKitNativeWidget { + private var widthConstraint: NSLayoutConstraint? = null + private var heightConstraint: NSLayoutConstraint? = null + + override fun onModifierChanged( + previous: FlareModifier, + current: FlareModifier, + ) { + val previousTestTag = previous.testTag + val currentTestTag = current.testTag + if (previousTestTag != currentTestTag) { + view.setValue( + value = currentTestTag, + forKey = ACCESSIBILITY_IDENTIFIER_KEY, + ) + } + if (previous.width != current.width || previous.height != current.height) { + refreshSizingConstraints() + } + } + + internal fun refreshSizingConstraints() { + NSLayoutConstraint.deactivateConstraints(listOfNotNull(widthConstraint, heightConstraint)) + widthConstraint = modifier.width.toConstraint(view, isWidth = true) + heightConstraint = modifier.height.toConstraint(view, isWidth = false) + NSLayoutConstraint.activateConstraints(listOfNotNull(widthConstraint, heightConstraint)) + } +} + +public class UIKitChildren( + private val parent: UIStackView, +) : FlareChildren { + override fun insert( + index: Int, + widget: FlareWidget, + ) { + val child = widget.requireUIKitWidget().view + child.translatesAutoresizingMaskIntoConstraints = false + parent.insertArrangedSubview( + view = child, + atIndex = index.toULong(), + ) + (widget as? AbstractUIKitWidget<*>)?.refreshSizingConstraints() + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + if (fromIndex == toIndex || count == 0) return + val moved = + List(count) { offset -> + parent.arrangedSubviews[fromIndex + offset] as UIView + } + moved.forEach(parent::removeArrangedSubview) + val destination = if (fromIndex > toIndex) toIndex else toIndex - count + moved.forEachIndexed { offset, child -> + parent.insertArrangedSubview(child, atIndex = (destination + offset).toULong()) + } + } + + override fun remove( + index: Int, + count: Int, + ) { + repeat(count) { + removeChild(parent.arrangedSubviews[index] as UIView) + } + } + + private fun removeChild(view: UIView) { + parent.removeArrangedSubview(view) + view.removeFromSuperview() + } + + private fun FlareWidget.requireUIKitWidget(): UIKitNativeWidget = + this as? UIKitNativeWidget + ?: error("UIKit backend received non-UIKit widget $this.") +} + +private fun FlareSize.toConstraint( + view: UIView, + isWidth: Boolean, +): NSLayoutConstraint? = + when (this) { + FlareSize.Wrap -> { + null + } + + FlareSize.Fill -> { + val parent = view.superview ?: return null + if (isWidth) { + view.widthAnchor.constraintEqualToAnchor(parent.widthAnchor) + } else { + view.heightAnchor.constraintEqualToAnchor(parent.heightAnchor) + } + } + + is FlareSize.Fixed -> { + if (isWidth) { + view.widthAnchor.constraintEqualToConstant(value.toDouble()) + } else { + view.heightAnchor.constraintEqualToConstant(value.toDouble()) + } + } + } + +private const val ACCESSIBILITY_IDENTIFIER_KEY: String = "accessibilityIdentifier" diff --git a/flareUI/runtime/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitWidgetTest.kt b/flareUI/runtime/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitWidgetTest.kt new file mode 100644 index 0000000000..f7c5e1ebf3 --- /dev/null +++ b/flareUI/runtime/src/iosTest/kotlin/dev/dimension/flare/ui/uikit/UIKitWidgetTest.kt @@ -0,0 +1,92 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.uikit + +import dev.dimension.flare.ui.FlareModifier +import kotlinx.cinterop.useContents +import platform.CoreGraphics.CGRectMake +import platform.Foundation.valueForKey +import platform.UIKit.NSLayoutConstraint +import platform.UIKit.UIButton +import platform.UIKit.UIButtonTypeSystem +import platform.UIKit.UILayoutConstraintAxisVertical +import platform.UIKit.UIStackView +import platform.UIKit.UIStackViewAlignmentFill +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Verifies the UIKit widget bridge independently of the demo application. */ +public class UIKitWidgetTest { + @Test + public fun testTagSetsAndClearsAccessibilityIdentifierOnButton() { + val button = UIButton.buttonWithType(UIButtonTypeSystem) + val widget = TestButtonWidget(button) + + widget.updateModifier(FlareModifier(testTag = "demo-button")) + assertEquals( + expected = "demo-button", + actual = button.valueForKey(ACCESSIBILITY_IDENTIFIER_KEY), + ) + + widget.updateModifier(FlareModifier.None) + assertNull(button.valueForKey(ACCESSIBILITY_IDENTIFIER_KEY)) + } + + @Test + public fun hierarchyOperationsApplyDirectly() { + val stack = UIStackView() + val children = UIKitChildren(stack) + val first = TestButtonWidget(UIButton.buttonWithType(UIButtonTypeSystem)) + val second = TestButtonWidget(UIButton.buttonWithType(UIButtonTypeSystem)) + + children.insert(0, first) + children.insert(1, second) + + assertEquals(2, stack.arrangedSubviews.size) + assertEquals(first.view, stack.arrangedSubviews[0]) + assertEquals(second.view, stack.arrangedSubviews[1]) + + children.move(fromIndex = 0, toIndex = 2, count = 1) + assertEquals(second.view, stack.arrangedSubviews[0]) + assertEquals(first.view, stack.arrangedSubviews[1]) + + children.remove(index = 0, count = 1) + assertEquals(listOf(first.view), stack.arrangedSubviews) + } + + @Test + public fun fixedAndFillSizesBecomeNativeConstraints() { + val stack = + UIStackView(frame = CGRectMake(0.0, 0.0, 200.0, 100.0)).apply { + axis = UILayoutConstraintAxisVertical + alignment = UIStackViewAlignmentFill + } + val widget = TestButtonWidget(UIButton.buttonWithType(UIButtonTypeSystem)) + widget.updateModifier(FlareModifier.None.fillMaxWidth().height(32f)) + + UIKitChildren(stack).insert(0, widget) + stack.layoutIfNeeded() + + widget.view.frame.useContents { + assertEquals(200.0, size.width, absoluteTolerance = 0.5) + } + assertTrue( + widget.view.constraints.filterIsInstance().any { constraint -> + constraint.active && constraint.constant == 32.0 + }, + "The fixed height must be represented by an active native constraint.", + ) + } + + private class TestButtonWidget( + view: UIButton, + ) : AbstractUIKitWidget( + view = view, + ) + + private companion object { + const val ACCESSIBILITY_IDENTIFIER_KEY: String = "accessibilityIdentifier" + } +} diff --git a/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/AppleFrameClock.macos.kt b/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/AppleFrameClock.macos.kt new file mode 100644 index 0000000000..04127af1ee --- /dev/null +++ b/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/AppleFrameClock.macos.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:Suppress("DEPRECATION") + +package dev.dimension.flare.ui + +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.MonotonicFrameClock +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.StableRef +import kotlinx.cinterop.alloc +import kotlinx.cinterop.asStableRef +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.value +import platform.CoreGraphics.CGMainDisplayID +import platform.CoreVideo.CVDisplayLinkCreateWithActiveCGDisplays +import platform.CoreVideo.CVDisplayLinkCreateWithCGDisplay +import platform.CoreVideo.CVDisplayLinkRef +import platform.CoreVideo.CVDisplayLinkRefVar +import platform.CoreVideo.CVDisplayLinkSetOutputCallback +import platform.CoreVideo.CVDisplayLinkStart +import platform.CoreVideo.CVDisplayLinkStop +import platform.CoreVideo.CVOptionFlags +import platform.CoreVideo.CVOptionFlagsVar +import platform.CoreVideo.CVTimeStamp +import platform.CoreVideo.kCVReturnDisplayLinkAlreadyRunning +import platform.CoreVideo.kCVReturnSuccess +import platform.Foundation.NSThread +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue + +internal actual fun createAppleFrameClock(): AppleFrameClock = MacOSDisplayLinkFrameClock + +/** + * Process-scoped, on-demand Core Video display-link clock for AppKit. + * + * Adapted from Cash App Molecule's macOS [MonotonicFrameClock]. The native callback and stable + * reference intentionally live for the process lifetime; the link itself sleeps without awaiters. + */ +private object MacOSDisplayLinkFrameClock : AppleFrameClock { + private val displayLink: CVDisplayLinkRef = createDisplayLink() + private val broadcastFrameClock = + BroadcastFrameClock { + checkDisplayLinkStart(CVDisplayLinkStart(displayLink)) + } + private val clockReference: StableRef = + StableRef.create(broadcastFrameClock) + + init { + check(NSThread.isMainThread) { + "The macOS frame clock must be created on the main thread." + } + checkDisplayLink( + CVDisplayLinkSetOutputCallback( + displayLink = displayLink, + callback = staticCFunction(::displayLinkCallback), + userInfo = clockReference.asCPointer(), + ), + ) + } + + override suspend fun withFrameNanos(onFrame: (Long) -> R): R = broadcastFrameClock.withFrameNanos(onFrame) +} + +private fun createDisplayLink(): CVDisplayLinkRef = + memScoped { + val displayLink = alloc() + val activeDisplaysResult = CVDisplayLinkCreateWithActiveCGDisplays(displayLink.ptr) + if (activeDisplaysResult != kCVReturnSuccess) { + checkDisplayLink(CVDisplayLinkCreateWithCGDisplay(CGMainDisplayID(), displayLink.ptr)) + } + requireNotNull(displayLink.value) { + "CVDisplayLinkCreateWithActiveCGDisplays returned no display link." + } + } + +private fun checkDisplayLink(result: Int) { + check(result == kCVReturnSuccess) { + "Could not operate the macOS CVDisplayLink. Error code $result." + } +} + +private fun checkDisplayLinkStart(result: Int) { + check(result == kCVReturnSuccess || result == kCVReturnDisplayLinkAlreadyRunning) { + "Could not start the macOS CVDisplayLink. Error code $result." + } +} + +@Suppress("UNUSED_PARAMETER") +private fun displayLinkCallback( + displayLink: CVDisplayLinkRef?, + currentTime: CPointer?, + outputTime: CPointer?, + inputFlags: CVOptionFlags, + outputFlags: CPointer?, + userInfo: COpaquePointer?, +): Int { + val clock = + userInfo + ?.asStableRef() + ?.get() + val frameTimeNanos = monotonicFrameTimeNanos() + + // Sleep after one delivered frame. BroadcastFrameClock restarts the link for new awaiters. + val stopResult = CVDisplayLinkStop(displayLink) + if (clock != null) { + dispatch_async(dispatch_get_main_queue()) { + clock.sendFrame(frameTimeNanos) + } + } + return stopResult +} diff --git a/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitWidget.kt b/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitWidget.kt new file mode 100644 index 0000000000..730d99e00c --- /dev/null +++ b/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/AppKitWidget.kt @@ -0,0 +1,138 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.AbstractFlareWidget +import dev.dimension.flare.ui.FlareBackend +import dev.dimension.flare.ui.FlareChildren +import dev.dimension.flare.ui.FlareModifier +import dev.dimension.flare.ui.FlareSize +import dev.dimension.flare.ui.FlareWidget +import platform.AppKit.NSLayoutConstraint +import platform.AppKit.NSStackView +import platform.AppKit.NSView +import platform.AppKit.heightAnchor +import platform.AppKit.translatesAutoresizingMaskIntoConstraints +import platform.AppKit.widthAnchor +import platform.Foundation.setValue + +/** Strong type token for AppKit renderer plugins. */ +public data object AppKitBackend : FlareBackend + +/** Renderer contract implemented by AppKit-backed primitive plugins. */ +public interface AppKitNativeWidget : FlareWidget { + public val view: NSView +} + +public abstract class AbstractAppKitWidget( + final override val view: V, +) : AbstractFlareWidget(), + AppKitNativeWidget { + private var widthConstraint: NSLayoutConstraint? = null + private var heightConstraint: NSLayoutConstraint? = null + + override fun onModifierChanged( + previous: FlareModifier, + current: FlareModifier, + ) { + val previousTestTag = previous.testTag + val currentTestTag = current.testTag + if (previousTestTag != currentTestTag) { + view.setValue( + value = currentTestTag, + forKey = ACCESSIBILITY_IDENTIFIER_KEY, + ) + } + if (previous.width != current.width || previous.height != current.height) { + refreshSizingConstraints() + } + } + + internal fun refreshSizingConstraints() { + NSLayoutConstraint.deactivateConstraints(listOfNotNull(widthConstraint, heightConstraint)) + widthConstraint = modifier.width.toConstraint(view, isWidth = true) + heightConstraint = modifier.height.toConstraint(view, isWidth = false) + NSLayoutConstraint.activateConstraints(listOfNotNull(widthConstraint, heightConstraint)) + } +} + +public class AppKitChildren( + private val parent: NSStackView, +) : FlareChildren { + override fun insert( + index: Int, + widget: FlareWidget, + ) { + val child = widget.requireAppKitWidget().view + child.translatesAutoresizingMaskIntoConstraints = false + parent.insertArrangedSubview( + view = child, + atIndex = index.toLong(), + ) + (widget as? AbstractAppKitWidget<*>)?.refreshSizingConstraints() + } + + override fun move( + fromIndex: Int, + toIndex: Int, + count: Int, + ) { + if (fromIndex == toIndex || count == 0) return + val moved = + List(count) { offset -> + parent.arrangedSubviews[fromIndex + offset] as NSView + } + moved.forEach(parent::removeArrangedSubview) + val destination = if (fromIndex > toIndex) toIndex else toIndex - count + moved.forEachIndexed { offset, child -> + parent.insertArrangedSubview(child, atIndex = (destination + offset).toLong()) + } + } + + override fun remove( + index: Int, + count: Int, + ) { + repeat(count) { + removeChild(parent.arrangedSubviews[index] as NSView) + } + } + + private fun removeChild(view: NSView) { + parent.removeArrangedSubview(view) + view.removeFromSuperview() + } + + private fun FlareWidget.requireAppKitWidget(): AppKitNativeWidget = + this as? AppKitNativeWidget + ?: error("AppKit backend received non-AppKit widget $this.") +} + +private fun FlareSize.toConstraint( + view: NSView, + isWidth: Boolean, +): NSLayoutConstraint? = + when (this) { + FlareSize.Wrap -> { + null + } + + FlareSize.Fill -> { + val parent = view.superview ?: return null + if (isWidth) { + view.widthAnchor.constraintEqualToAnchor(parent.widthAnchor) + } else { + view.heightAnchor.constraintEqualToAnchor(parent.heightAnchor) + } + } + + is FlareSize.Fixed -> { + if (isWidth) { + view.widthAnchor.constraintEqualToConstant(value.toDouble()) + } else { + view.heightAnchor.constraintEqualToConstant(value.toDouble()) + } + } + } + +private const val ACCESSIBILITY_IDENTIFIER_KEY: String = "accessibilityIdentifier" diff --git a/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/FlareAppKitHost.kt b/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/FlareAppKitHost.kt new file mode 100644 index 0000000000..99eaf5da33 --- /dev/null +++ b/flareUI/runtime/src/macosMain/kotlin/dev/dimension/flare/ui/appkit/FlareAppKitHost.kt @@ -0,0 +1,87 @@ +@file:OptIn( + dev.dimension.flare.ui.LowLevelFlareApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.AppleHostController +import dev.dimension.flare.ui.FlareContent +import dev.dimension.flare.ui.FlareNativeControllerOwner +import dev.dimension.flare.ui.FlareWidgetSystem +import dev.dimension.flare.ui.LowLevelFlareApi +import dev.dimension.flare.ui.ProvideFlareNativeControllerOwner +import platform.AppKit.NSLayoutAttributeLeading +import platform.AppKit.NSStackView +import platform.AppKit.NSUserInterfaceLayoutOrientationVertical +import platform.CoreGraphics.CGRectMake +import kotlin.native.HiddenFromObjC + +/** Direct AppKit host supplied by Flare Runtime. */ +public class FlareAppKitHost private constructor( + widgetSystem: FlareWidgetSystem, + nativeControllerOwner: NativeControllerOwnerBox, +) { + public constructor(widgetSystem: FlareWidgetSystem) : + this(widgetSystem, NativeControllerOwnerBox(null)) + + @LowLevelFlareApi + public constructor( + widgetSystem: FlareWidgetSystem, + nativeControllerOwner: FlareNativeControllerOwner, + ) : this(widgetSystem, NativeControllerOwnerBox(nativeControllerOwner)) + + private val nativeControllerOwner = nativeControllerOwner.value + private val hostView = + AppKitHostView().apply { + orientation = NSUserInterfaceLayoutOrientationVertical + alignment = NSLayoutAttributeLeading + } + private val controller = + AppleHostController( + root = AppKitChildren(hostView), + widgetSystem = widgetSystem, + backend = AppKitBackend, + hostName = HOST_NAME, + ) + + public val view: NSStackView + get() = hostView + + init { + hostView.onAttachmentChanged = controller::attachmentChanged + } + + @HiddenFromObjC + public fun setContent(content: FlareContent) { + controller.setContent(hostedContent(content)) + } + + public fun dispose() { + controller.dispose() + hostView.onAttachmentChanged = null + } + + private fun hostedContent(value: FlareContent): FlareContent = + { + ProvideFlareNativeControllerOwner( + owner = nativeControllerOwner, + content = value, + ) + } +} + +private class NativeControllerOwnerBox( + val value: FlareNativeControllerOwner?, +) + +private class AppKitHostView : NSStackView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)) { + var onAttachmentChanged: ((Boolean) -> Unit)? = null + + override fun viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onAttachmentChanged?.invoke(window != null) + } +} + +private const val HOST_NAME: String = "FlareAppKitHost" diff --git a/flareUI/runtime/src/macosTest/kotlin/dev/dimension/flare/ui/AppleFrameClockTest.kt b/flareUI/runtime/src/macosTest/kotlin/dev/dimension/flare/ui/AppleFrameClockTest.kt new file mode 100644 index 0000000000..82dab4bafe --- /dev/null +++ b/flareUI/runtime/src/macosTest/kotlin/dev/dimension/flare/ui/AppleFrameClockTest.kt @@ -0,0 +1,56 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import platform.CoreFoundation.CFRunLoopRunInMode +import platform.CoreFoundation.kCFRunLoopDefaultMode +import platform.Foundation.NSThread +import kotlin.test.Test +import kotlin.test.assertTrue + +public class AppleFrameClockTest { + @Test + public fun displayLinkProducesMonotonicFrames() { + val frameClock = createAppleFrameClock() + val firstFrame = frameClock.awaitFrame { frameTimeNanos -> frameTimeNanos } + val secondFrame = frameClock.awaitFrame { frameTimeNanos -> frameTimeNanos } + + assertTrue(firstFrame > 0L) + assertTrue(secondFrame > firstFrame) + } + + @Test + public fun displayLinkDeliversFrameBlockOnMainThread() { + val frameClock = createAppleFrameClock() + + frameClock.awaitFrame { + assertTrue(NSThread.isMainThread, "Frame callbacks must run on the AppKit main thread.") + } + } + + private fun AppleFrameClock.awaitFrame(onFrame: (Long) -> R): R { + check(NSThread.isMainThread) { "The test must drive the AppKit run loop from the main thread." } + val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + var result: Result? = null + scope.launch { + result = runCatching { withFrameNanos(onFrame) } + } + val deadlineNanos = monotonicFrameTimeNanos() + FRAME_TIMEOUT_NANOS + while (result == null && monotonicFrameTimeNanos() < deadlineNanos) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, RUN_LOOP_STEP_SECONDS, true) + } + scope.cancel() + return result?.getOrThrow() + ?: error("Timed out waiting for an AppKit display-link frame.") + } + + private companion object { + const val FRAME_TIMEOUT_NANOS: Long = 5_000_000_000L + const val RUN_LOOP_STEP_SECONDS: Double = 0.01 + } +} diff --git a/flareUI/runtime/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitWidgetTest.kt b/flareUI/runtime/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitWidgetTest.kt new file mode 100644 index 0000000000..ae01764078 --- /dev/null +++ b/flareUI/runtime/src/macosTest/kotlin/dev/dimension/flare/ui/appkit/AppKitWidgetTest.kt @@ -0,0 +1,82 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.ui.appkit + +import dev.dimension.flare.ui.FlareModifier +import kotlinx.cinterop.useContents +import platform.AppKit.NSButton +import platform.AppKit.NSStackView +import platform.AppKit.NSUserInterfaceLayoutOrientationVertical +import platform.CoreGraphics.CGRectMake +import platform.Foundation.valueForKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Verifies the AppKit widget adapter independently of the demo application. */ +public class AppKitWidgetTest { + @Test + public fun testTagSetsAndClearsAccessibilityIdentifierOnButton() { + val button = NSButton() + val widget = TestButtonWidget(button) + + widget.updateModifier(FlareModifier(testTag = "demo-button")) + assertEquals( + expected = "demo-button", + actual = button.valueForKey(ACCESSIBILITY_IDENTIFIER_KEY), + ) + + widget.updateModifier(FlareModifier.None) + assertNull(button.valueForKey(ACCESSIBILITY_IDENTIFIER_KEY)) + } + + @Test + public fun hierarchyOperationsApplyDirectly() { + val stack = NSStackView() + val children = AppKitChildren(stack) + val first = TestButtonWidget(NSButton()) + val second = TestButtonWidget(NSButton()) + + children.insert(0, first) + children.insert(1, second) + + assertEquals(2, stack.arrangedSubviews.size) + assertEquals(first.view, stack.arrangedSubviews[0]) + assertEquals(second.view, stack.arrangedSubviews[1]) + + children.move(fromIndex = 0, toIndex = 2, count = 1) + assertEquals(second.view, stack.arrangedSubviews[0]) + assertEquals(first.view, stack.arrangedSubviews[1]) + + children.remove(index = 0, count = 1) + assertEquals(listOf(first.view), stack.arrangedSubviews) + } + + @Test + public fun fixedAndFillSizesBecomeNativeConstraints() { + val stack = + NSStackView(frame = CGRectMake(0.0, 0.0, 200.0, 100.0)).apply { + orientation = NSUserInterfaceLayoutOrientationVertical + } + val widget = TestButtonWidget(NSButton()) + widget.updateModifier(FlareModifier.None.fillMaxWidth().height(32f)) + + AppKitChildren(stack).insert(0, widget) + stack.layoutSubtreeIfNeeded() + + widget.view.frame.useContents { + assertEquals(200.0, size.width, absoluteTolerance = 0.5) + assertEquals(32.0, size.height, absoluteTolerance = 0.5) + } + } + + private class TestButtonWidget( + view: NSButton, + ) : AbstractAppKitWidget( + view = view, + ) + + private companion object { + const val ACCESSIBILITY_IDENTIFIER_KEY: String = "accessibilityIdentifier" + } +} diff --git a/flareUI/settings.gradle.kts b/flareUI/settings.gradle.kts new file mode 100644 index 0000000000..00f7bd19ba --- /dev/null +++ b/flareUI/settings.gradle.kts @@ -0,0 +1,31 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "flare-ui" + +include(":flare-runtime") +project(":flare-runtime").projectDir = file("runtime") +include(":foundation") +include(":flare-lazy-layout") +project(":flare-lazy-layout").projectDir = file("lazy-layout") +include(":flare-navigation") +project(":flare-navigation").projectDir = file("navigation") +include(":flare-resources-moko") +project(":flare-resources-moko").projectDir = file("resources-moko") +include(":demo:androidApp") +include(":demo:shared") + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") diff --git a/gradle.properties b/gradle.properties index 8fa385ac07..2f58a5b9ba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx16g -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30ad37ed8b..acd15818ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ firebase-bom = "34.18.0" google-services = "4.5.0" firebase-crashlytics = "3.0.8" materialKolor = "5.0.0" +materialComponents = "1.14.0" room = "3.0.1" sqlite = "2.7.0" compose-multiplatform = "1.11.1" @@ -114,6 +115,7 @@ ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha26" } +material-components = { group = "com.google.android.material", name = "material", version.ref = "materialComponents" } material3WindowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f57c3b201c..84a92ecb1f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,7 @@ dependencyResolutionManagement { } rootProject.name = "Flare" +includeBuild("flareUI") include(":app") include(":shared") include(":social:bluesky")