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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/flare-ui.yml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion apple-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ kotlin {
commonExportedProjects.forEach { exportedProject ->
export(exportedProject)
}

if (appleTarget.name.startsWith("ios")) {
export(projects.social.nostr)
}
Expand Down
198 changes: 198 additions & 0 deletions flareUI/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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<W>` 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<B>` is an immutable map of `KClass` keys to `(B) -> FlareWidget` factories.
`FlareRendererPlugin<B>` 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.
Loading