diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0b2ba7d..d1b1f14 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -262,6 +262,9 @@ jobs:
- name: Build swift-java-weather-app APK
run: ./gradlew :swift-java-weather-app-weather-app:assemble${{ matrix.configuration }} --stacktrace
+ - name: Build swift-java-ui-showcase APK
+ run: ./gradlew :swift-java-ui-showcase-showcase-app:assemble${{ matrix.configuration }} --stacktrace
+
- name: Build hello-cpp-swift cpp-lib
working-directory: hello-cpp-swift/cpp-lib
run: ./build-android-static.sh
diff --git a/README.md b/README.md
index 28b8132..567906c 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,14 @@ The **[swift-java-weather-app](swift-java-weather-app/)** example showcases auto
Swift language features, such as `async` functions and implementing a Swift `protocol` in Java,
which can be passed ~~back~~ to Swift.
+The **[swift-java-ui-showcase](swift-java-ui-showcase/)** example is a "kitchen sink"
+catalog of UI components (buttons, selection controls, sliders, text inputs, and a
+validated form) where Swift owns the whole UI model: each screen's component tree,
+state, event handling, and validation are declared in Swift, and a small generic
+Kotlin/Compose renderer draws whatever Swift describes over a single JSON/JNI
+boundary. See [swift-java-ui-showcase/README.md](swift-java-ui-showcase/README.md)
+for the architecture and for how to add your own screen without touching Kotlin.
+
## C++ Integration Example
The **[hello-cpp-swift](hello-cpp-swift/)** example demonstrates how to integrate
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 62424a8..9d44098 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,6 +11,7 @@ constraintlayout = "2.2.1"
lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1"
composeBom = "2024.09.00"
+navigationCompose = "2.8.9"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -30,6 +31,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index a4f557b..def24a1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -48,6 +48,11 @@ project(":swift-java-weather-app-weather-lib").projectDir = file("swift-java-wea
include(":swift-java-weather-app-weather-app")
project(":swift-java-weather-app-weather-app").projectDir = file("swift-java-weather-app/weather-app")
+include(":swift-java-ui-showcase-showcase-lib")
+project(":swift-java-ui-showcase-showcase-lib").projectDir = file("swift-java-ui-showcase/showcase-lib")
+include(":swift-java-ui-showcase-showcase-app")
+project(":swift-java-ui-showcase-showcase-app").projectDir = file("swift-java-ui-showcase/showcase-app")
+
// raw-jni examples
include(":hello-swift-raw-jni")
include(":hello-swift-raw-jni-callback")
diff --git a/swift-java-ui-showcase/.gitignore b/swift-java-ui-showcase/.gitignore
new file mode 100644
index 0000000..6e582d1
--- /dev/null
+++ b/swift-java-ui-showcase/.gitignore
@@ -0,0 +1,41 @@
+.DS_STORE
+
+# Swift
+.build/
+.swiftpm/
+Package.resolved
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.aab
+*.apk
+output-metadata.json
+
+# IntelliJ
+*.iml
+.idea/
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
\ No newline at end of file
diff --git a/swift-java-ui-showcase/PLAN.md b/swift-java-ui-showcase/PLAN.md
new file mode 100644
index 0000000..879bb32
--- /dev/null
+++ b/swift-java-ui-showcase/PLAN.md
@@ -0,0 +1,146 @@
+# Swift-Java UI Showcase — new example app
+
+## Context
+
+Swift Mentorship Program project: add a new example to `swift-android-examples` — a "kitchen sink" showcase app navigating between screens that demo UI component categories (buttons, selection controls, sliders, text inputs, and a form with validation).
+
+**Agreed architecture** (confirmed with user): Swift owns everything conceptual — the declarative component model, all state, event handling, and form validation. Jetpack Compose cannot be invoked from Swift (it's a Kotlin compiler-plugin feature), so "instantiate components in Swift" is realized as Swift declaring per-screen component trees and a **thin generic Kotlin/Compose (Material 3) renderer** interpreting them — a unidirectional data-flow loop with Swift as the single source of truth. Built on the repo's recommended **swift-java/JExtract** pattern, cloned from `hello-swift-java/`.
+
+**Design constraints**: keep it as simple as a reference example demands (tiny diff against the canonical `hello-swift-java` example, minimal dependencies and files), and treat documentation as a per-milestone deliverable, not an afterthought.
+
+## Key decisions
+
+- **Boundary = JSON strings over three top-level Swift functions** (`showcaseScreens()`, `showcaseScreen(id)`, `showcaseDispatch(screenId, componentId, eventJSON)` — all `(String…) -> String`, the exact shape JExtract `mode:"jni"` proves out with `SwiftHashing.hash`). No `enableJavaCallbacks` — avoids the `--disable-sandbox` flag weather-lib needs. Pull-based loop: Kotlin pulls screen JSON → user event → dispatch → new screen JSON → recompose.
+- **Exactly one new repo dependency**: `androidx.navigation:navigation-compose` **2.8.9** (compatible with Compose BOM 2024.09.00 / Compose 1.7 — do NOT use 2.9+). No ViewModel: the current screen JSON lives in `remember { mutableStateOf(...) }` (same pattern `hashing-app/MainActivity.kt` already uses); real state lives in Swift, so it survives navigation regardless.
+- **Kotlin decodes with `org.json`** (platform built-in; no serialization plugin, no wrapper data classes — render straight from `JSONObject`).
+- **Swift model**: `enum Component` with hand-written `Codable` (flat `"kind"`-discriminated JSON, exact wire schema pinned by Swift tests). `FoundationEssentials` JSONEncoder is already in the `swiftRuntimeLibs` allowlist.
+- **Minimal component surface, one variant each** (variants are follow-up PRs / good first issues): filled button only (no `enabled` flag), no `steps` on slider, textField keeps `keyboard: text|email|number` (the form demo needs it) but no `secure`.
+- **File budget** — Kotlin: `MainActivity.kt` (nav graph + home list + screen scaffold), `ComponentRenderer.kt` (the single `when`), copied `ui/theme/` files; ~250 lines total. Swift: `Component.swift`, `Event.swift`, `ShowcaseStore.swift` (registry + dispatch + JSON), `Screens.swift` (all five screens — they're short), `ShowcaseAPI.swift` (the three entry points, heavily commented — it's *the* boundary readers came to see).
+- **Naming**: dir `swift-java-ui-showcase/` with `showcase-lib/` + `showcase-app/`; Gradle `:swift-java-ui-showcase-showcase-lib` / `:swift-java-ui-showcase-showcase-app`; Swift package/target `ShowcaseKit`; `javaPackage` `com.example.showcasekit` (generated class `com.example.showcasekit.ShowcaseKit`); lib namespace `com.example.showcaselib`; app namespace/appId `com.example.showcaseapp`.
+
+## JSON wire schema (contract, pinned by Swift tests)
+
+```jsonc
+// showcaseScreens()
+{"screens":[{"id":"buttons","title":"Buttons"},{"id":"selection","title":"Selection Controls"},
+ {"id":"sliders","title":"Sliders"},{"id":"textInputs","title":"Text Inputs"},
+ {"id":"form","title":"Form & Validation"}]}
+
+// showcaseScreen("buttons") — also the return of showcaseDispatch
+{"id":"buttons","title":"Buttons","components":[
+ {"kind":"sectionHeader","id":"hdr1","text":"Buttons"},
+ {"kind":"text","id":"tapCount","text":"Tapped 0 times"},
+ {"kind":"button","id":"tapMe","label":"Tap me"},
+ {"kind":"toggle","id":"wifi","label":"Wi-Fi","isOn":true},
+ {"kind":"checkbox","id":"terms","label":"Accept terms","isChecked":false},
+ {"kind":"radioGroup","id":"size","label":"Size","options":["S","M","L"],"selectedIndex":1},
+ {"kind":"slider","id":"volume","label":"Volume","value":0.5,"min":0.0,"max":1.0},
+ {"kind":"textField","id":"email","label":"Email","text":"","placeholder":"you@example.com",
+ "keyboard":"email","error":null}]}
+
+// eventJSON for showcaseDispatch:
+{"type":"tap"} | {"type":"setBool","value":true} | {"type":"setString","value":"abc"} |
+{"type":"setNumber","value":0.75} | {"type":"select","index":2}
+```
+
+## Milestone 0 — Branch + committed plan
+
+- `git switch -c swift-java-ui-showcase-plan` (from `main`).
+- Write this plan (merged: decisions + simplicity/docs revision) to `swift-java-ui-showcase/PLAN.md` (`.md` is `.licenseignore`-exempt, no header needed) and commit it: `git add swift-java-ui-showcase/PLAN.md && git commit -m "Add swift-java-ui-showcase example implementation plan"`.
+
+## Milestone 1 — Scaffolding + registration (repo builds again by end of milestone)
+
+**`swift-java-ui-showcase/showcase-lib/`** (clone of `hello-swift-java/hashing-lib/`):
+- `Package.swift`: rename package/target to `ShowcaseKit`, drop swift-crypto, keep swift-java `from: "0.1.2"` + `JExtractSwiftPlugin` + `.swiftLanguageMode(.v5)`, add `ShowcaseKitTests` test target.
+- `Sources/ShowcaseKit/swift-java.config`: `{"javaPackage": "com.example.showcasekit", "mode": "jni"}`.
+- `build.gradle` (Groovy): copy **weather-lib's** version (already `compileSdkVersion 36`; hashing-lib is stuck at 34), then change: `namespace "com.example.showcaselib"`; `inputs.dir(… "Sources/ShowcaseKit")`; the JExtract outputs path segment `…/ShowcaseKit/destination/JExtractSwiftPlugin/src/generated/java`; remove `--disable-sandbox` + its comment (no Java callbacks). Keep `swiftRuntimeLibs` as-is. **These two hardcoded target-name paths are the #1 copy-paste failure** — add "why" comments at both.
+- `Sources/ShowcaseKit/ShowcaseAPI.swift` stub with the three public functions; copy `gradle.properties` and `.gitignore` (at `swift-java-ui-showcase/.gitignore`).
+
+**`swift-java-ui-showcase/showcase-app/`** (clone of `hello-swift-java/hashing-app/`, near-verbatim `build.gradle.kts` diff):
+- `build.gradle.kts`: namespace/appId `com.example.showcaseapp`; deps `project(":swift-java-ui-showcase-showcase-lib")`, `org.swift.swiftkit:swiftkit-core:+`, `libs.androidx.navigation.compose`.
+- Copy manifest, `res/**` (app label in `strings.xml`), `ui/theme/*.kt` → `ShowcaseAppTheme`, stock unit/instrumented tests, `proguard-rules.pro`, `.gitignore`.
+
+**Registration edits:**
+- `settings.gradle.kts` — after line 49 (weather-app block), same include+projectDir-remap pattern:
+ ```kotlin
+ include(":swift-java-ui-showcase-showcase-lib")
+ project(":swift-java-ui-showcase-showcase-lib").projectDir = file("swift-java-ui-showcase/showcase-lib")
+ include(":swift-java-ui-showcase-showcase-app")
+ project(":swift-java-ui-showcase-showcase-app").projectDir = file("swift-java-ui-showcase/showcase-app")
+ ```
+- `gradle/libs.versions.toml` — add `navigationCompose = "2.8.9"` and the `androidx-navigation-compose` library entry.
+
+Verify: `./gradlew :swift-java-ui-showcase-showcase-app:assembleDebug --stacktrace` (needs swiftly + Android Swift SDK + swiftkit-core published to mavenLocal, per `hello-swift-java/README.md`).
+
+## Milestone 2 — Walking skeleton + first README
+
+Swift `ShowcaseStore` with a single `buttons` screen holding a tap counter; `dispatch` increments and re-renders. Kotlin: minimal `ComponentRenderer` (`text` + `button` only); `MainActivity` shows the screen directly (no nav yet), JSON held in `remember { mutableStateOf(...) }`. On emulator: tap increments label — proves JExtract, JSON, state, recomposition.
+
+Swift shapes (`Sources/ShowcaseKit/`):
+```swift
+// Component.swift — hand-written Codable, flat keys + "kind" discriminator
+public enum Component: Equatable {
+ case sectionHeader(id: String, text: String)
+ case text(id: String, text: String)
+ case button(id: String, label: String)
+ case toggle(id: String, label: String, isOn: Bool)
+ case checkbox(id: String, label: String, isChecked: Bool)
+ case radioGroup(id: String, label: String, options: [String], selectedIndex: Int?)
+ case slider(id: String, label: String, value: Double, min: Double, max: Double)
+ case textField(id: String, label: String, text: String, placeholder: String,
+ keyboard: Keyboard, error: String?)
+}
+// Event.swift — "type" discriminator
+public enum Event: Codable { case tap, setBool(Bool), setString(String), setNumber(Double), select(Int) }
+// ScreenDefinition.swift protocol lives in ShowcaseStore.swift or Screens.swift:
+protocol ScreenDefinition: AnyObject {
+ var id: String { get }; var title: String { get }
+ func handle(_ event: Event, componentId: String)
+ func body() -> [Component]
+}
+// ShowcaseStore.swift — singleton registry + JSON encode, main-thread only (documented invariant)
+```
+
+Kotlin renderer core (`ComponentRenderer.kt`): one `when (c.getString("kind"))` → Material 3 composables, emitting event `JSONObject`s back through an `onEvent(id, event)` lambda. Text-field note (inline comment in code): keep in-flight text in local `remember` state keyed on the Swift value; dispatch `setString` per change but don't re-seed from JSON while focused (avoids IME cursor jitter).
+
+**Docs (this milestone)**: create `swift-java-ui-showcase/README.md` modeled on `hello-swift-java/README.md` — what it demonstrates, setup (link to hashing-lib README for swiftly/SDK/`publishToMavenLocal` long-form), and an ASCII sequence diagram of the loop: `Compose tap → showcaseDispatch(screen, id, eventJSON) → Swift mutates state → returns screen JSON → recompose`.
+
+## Milestone 3 — Full model, five screens, validation, Swift tests
+
+- `Screens.swift`: `ButtonsScreen` (tap counter), `SelectionScreen` (switch, checkboxes, radio group whose selection affects another component — cross-component state in Swift), `SlidersScreen` (value readout formatted in Swift), `TextInputsScreen` (text/email/number keyboards), `FormScreen` (name/email/age + submit; `Validation` pure functions → `textField.error`; success replaces form with a success `text`).
+- `Tests/ShowcaseKitTests/` (swift-testing `@Test`/`#expect`, mirroring `SwiftHashingTests`): exact-JSON schema lock for a known screen (references the README schema table by name), event decoding, dispatch round-trip, validation rules.
+- Verify: `cd swift-java-ui-showcase/showcase-lib && swift test` (pure Swift model — host build, no JNI).
+- **Docs**: expand README with the **JSON schema table** (every `kind`, its keys, events it emits) — the contract document; `///` doc comments on every public Swift symbol.
+
+## Milestone 4 — Navigation
+
+`MainActivity.kt`: `NavHost` with `"home"` (Scaffold + TopAppBar + LazyColumn of screens from `showcaseScreens()`) and `"screen/{screenId}"` (load JSON, render components, back nav). Graph is data-driven from Swift's registry — adding a Swift screen requires zero Kotlin changes.
+
+## Milestone 5 — CI + final docs
+
+- `.github/workflows/ci.yml` — add after the weather-app step (~line 263):
+ ```yaml
+ - name: Build swift-java-ui-showcase APK
+ run: ./gradlew :swift-java-ui-showcase-showcase-app:assemble${{ matrix.configuration }} --stacktrace
+ ```
+- Finish `swift-java-ui-showcase/README.md`: one screenshot per screen in `swift-java-ui-showcase/resources/` (repo convention); **"Add your own screen in ~20 lines"** tutorial (conform to `ScreenDefinition`, append to registry, done — no Kotlin changes) and **"Add your own component kind"** tutorial (four touch points: enum case, Codable case, `when` branch, schema table row).
+- Root `README.md`: 5-6 line subsection under "Additional swift-java examples" — Swift-owned declarative UI state over a single JSON/JNI boundary, link to `swift-java-ui-showcase/README.md`.
+- License sweep: every new `.swift`, `.kt`, `.gradle`, `.gradle.kts` gets the `//===---…===//` Apache-2.0 Swift.org header, `Copyright (c) 2026` (CI soundness-enforced; `.xml`, `.md`, `.json`, `.config`, `Package.swift` exempt per `.licenseignore`).
+
+## Verification (end-to-end)
+
+1. `cd swift-java-ui-showcase/showcase-lib && swift build && swift test` (host).
+2. `./gradlew :swift-java-ui-showcase-showcase-lib:buildSwiftAll` — generated Java appears under `showcase-lib/.build/plugins/outputs/showcase-lib/ShowcaseKit/destination/…`.
+3. `./gradlew :swift-java-ui-showcase-showcase-app:assembleDebug --stacktrace`, then `assembleRelease`.
+4. Emulator: navigate all five screens; tap counter works, toggle state survives navigation (state lives in Swift), form errors + success path.
+5. `./gradlew assembleDebug` — all other examples still build.
+
+## Risks / mitigations
+
+- **JExtract jni-mode limits** → only `(String…) -> String` top-level functions cross the boundary; never structs/enums/optionals.
+- **Schema drift** → hand-written Codable + exact-JSON Swift tests; Kotlin uses `opt*` with defaults.
+- **navigation-compose clash** → pin 2.8.9; don't bump the Compose BOM.
+- **Stale/wrong JExtract paths in build.gradle** → the two `ShowcaseKit` path edits called out in Milestone 1, with inline comments.
+- **`swiftkit-core:+` unresolved locally** → README documents `publishToMavenLocal` (CI already does it).
+- **IME jitter on per-keystroke JNI dispatch** → local buffered text state in the textField renderer.
+- **Singleton concurrency warnings** → language mode v5 keeps them warnings; all entry points are main-thread, documented invariant.
diff --git a/swift-java-ui-showcase/README.md b/swift-java-ui-showcase/README.md
new file mode 100644
index 0000000..231a6e3
--- /dev/null
+++ b/swift-java-ui-showcase/README.md
@@ -0,0 +1,156 @@
+# Swift-driven UI showcase
+
+A "kitchen sink" catalog app where **Swift declares the UI and owns all of its
+state**, and Kotlin is only a thin generic renderer. Browse screens that demo
+buttons, selection controls (switch, checkboxes, radio group), sliders, text
+inputs, and a form with validation — every component you see was declared by
+Swift code, and every interaction is handled by Swift code.
+
+| Home (Swift's screen registry) | Buttons (Swift tap counter) | Form (Swift validation) |
+|---|---|---|
+|  |  |  |
+
+## Overview
+
+The example consists of two components:
+
+- **showcase-lib**: A Swift package (`ShowcaseKit`) that declares the component
+ tree of every screen, holds all UI state, reduces every action, and validates
+ the form. It is exposed to Java by
+ [swift-java](https://github.com/swiftlang/swift-java)'s JExtract in JNI mode.
+- **showcase-app**: A Kotlin Android app whose Compose code never hard-codes a
+ screen. It interprets whatever component tree Swift returns
+ (`ComponentRenderer.kt` is a single `when` over the component kind) and
+ builds its navigation graph from Swift's screen registry.
+
+Jetpack Compose cannot be called from Swift (composables are a Kotlin compiler
+feature), so "the UI is written in Swift" takes this shape: a unidirectional
+data-flow loop with Swift as the single source of truth.
+
+```
+ Compose (Kotlin) ShowcaseKit (Swift)
+ ┌──────────────────────────┐ ┌────────────────────────────┐
+ │ user taps / types / drags │──── action ───▶│ showcaseDispatch(screen, │
+ │ │ (JSON) │ component, action) │
+ │ render component tree │ │ → screen reduces action, │
+ │ (one `when` over "kind") │◀─ new screen ───│ mutates its Swift state│
+ └──────────────────────────┘ (JSON) └────────────────────────────┘
+```
+
+Only three functions cross the boundary, all `(String...) -> String`
+(see `showcase-lib/Sources/ShowcaseKit/ShowcaseAPI.swift`):
+
+| Function | Purpose |
+|---|---|
+| `showcaseScreens()` | Screen registry (ids + titles) — drives the navigation graph |
+| `showcaseScreen(id)` | Current component tree of one screen |
+| `showcaseDispatch(screenId, componentId, actionJSON)` | Apply a user action, return the new tree |
+
+Because state lives in Swift, it survives navigation: toggle a switch, leave
+the screen, come back — the switch is still on, with no ViewModel or saved
+state on the Kotlin side.
+
+## Prerequisites
+
+Same as [hello-swift-java](../hello-swift-java/README.md): a Swift 6.3+
+toolchain via `swiftly`, the Swift SDK for Android, and the `swiftkit-core`
+package published to your local Maven repository. Follow the
+[Setup and Configuration](../hello-swift-java/README.md#setup-and-configuration)
+steps there (run the `swift package resolve` / `publishToMavenLocal` step from
+`swift-java-ui-showcase/showcase-lib` or any other swift-java example module —
+the published package is shared).
+
+## Running the example
+
+```console
+./gradlew :swift-java-ui-showcase-showcase-app:assembleDebug
+```
+
+or open the repository in Android Studio and run the
+`swift-java-ui-showcase-showcase-app` configuration on an emulator or device.
+
+To iterate on the Swift model without an Android device, the package is plain
+Swift — build and test it on your host machine:
+
+```console
+cd swift-java-ui-showcase/showcase-lib
+swift test
+```
+
+## JSON schema
+
+The wire format between Swift and Kotlin. Every component is a flat JSON
+object discriminated by `"kind"`; the schema is pinned by
+`buttonsScreenSchemaIsStable()` in
+`showcase-lib/Tests/ShowcaseKitTests/ShowcaseKitTests.swift` — update this
+table and that test together.
+
+`showcaseScreens()` returns `{"screens": [{"id", "title"}, ...]}`.
+`showcaseScreen(id)` and `showcaseDispatch(...)` return
+`{"id", "title", "components": [...]}` where each component is:
+
+| `kind` | Keys | Actions it emits |
+|---|---|---|
+| `sectionHeader` | `id`, `text` | — |
+| `text` | `id`, `text` | — |
+| `button` | `id`, `label`, `role` (`primary`\|`secondary`) | `{"type":"tap"}` |
+| `toggle` | `id`, `label`, `isOn` | `{"type":"setBool","value":…}` |
+| `checkbox` | `id`, `label`, `isChecked` | `{"type":"setBool","value":…}` |
+| `radioGroup` | `id`, `label`, `options`, `selectedIndex` (nullable) | `{"type":"select","index":…}` |
+| `segmentedControl` | `id`, `label`, `options`, `selectedIndex` | `{"type":"select","index":…}` |
+| `slider` | `id`, `label`, `value`, `min`, `max` | `{"type":"setNumber","value":…}` |
+| `progressIndicator` | `id`, `label`, `value` (`0.0`–`1.0`) | — (display-only) |
+| `stepper` | `id`, `label`, `value`, `min`, `max` (all `Int`) | `{"type":"setNumber","value":±1}` (a delta, not the new value) |
+| `datePicker` | `id`, `label`, `date` (`"yyyy-MM-dd"`) | `{"type":"setString","value":…}` |
+| `alert` | `id`, `title`, `message`, `confirmLabel`, `cancelLabel` | `{"type":"select","index":0}` (confirm) or `{"type":"select","index":1}` (cancel/dismiss) |
+| `textField` | `id`, `label`, `text`, `placeholder`, `keyboard` (`text`\|`email`\|`number`), `error` (nullable) | `{"type":"setString","value":…}` |
+| `textEditor` | `id`, `label`, `text`, `placeholder` | `{"type":"setString","value":…}` |
+| `code` | `id`, `title`, `code` | — (opens a fullscreen modal with Copy and Wrap actions; modal visibility and wrap state stay Kotlin-local) |
+
+## Add your own screen (~20 lines, no Kotlin)
+
+Conform to `ScreenDefinition` in its own file under
+`showcase-lib/Sources/ShowcaseKit/` (one screen per file, e.g. `ButtonsScreen.swift`):
+
+```swift
+final class GreetingScreen: ScreenDefinition {
+ let id = "greeting"
+ let title = "Greeting"
+
+ private var name = ""
+
+ func reduce(_ action: Action, componentId: String) {
+ if case .setString(let value) = action, componentId == "name" { name = value }
+ }
+
+ func body() -> [Component] {
+ [
+ .textField(id: "name", label: "Your name", text: name,
+ placeholder: "World", keyboard: .text, error: nil),
+ .text(id: "greeting", text: "Hello, \(name.isEmpty ? "World" : name)!"),
+ ]
+ }
+}
+```
+
+then append `GreetingScreen()` to `defaultScreens()` in `ScreenRegistry.swift`.
+That's it — the home list and navigation are data-driven from Swift's
+registry, so the new screen appears with no Kotlin changes.
+
+## Add your own component kind
+
+Four touch points:
+
+1. A new case on `Component` in `showcase-lib/Sources/ShowcaseKit/Component.swift`
+2. Its `encode(to:)` / `init(from:)` clauses in the same file
+3. A new branch in the `when` in `showcase-app/.../ComponentRenderer.kt`
+4. A row in the schema table above (and, if you change existing shapes, the
+ pinned-schema test)
+
+## Troubleshooting
+
+See the [hello-swift-java troubleshooting section](../hello-swift-java/README.md#troubleshooting).
+The two paths in `showcase-lib/build.gradle` that hardcode the Swift target
+name (`Sources/ShowcaseKit` and the JExtract generated-java output directory)
+are the usual suspects if generated Java classes are missing or stale after
+renaming things.
diff --git a/swift-java-ui-showcase/resources/buttons.png b/swift-java-ui-showcase/resources/buttons.png
new file mode 100644
index 0000000..4d600df
Binary files /dev/null and b/swift-java-ui-showcase/resources/buttons.png differ
diff --git a/swift-java-ui-showcase/resources/form-validation.png b/swift-java-ui-showcase/resources/form-validation.png
new file mode 100644
index 0000000..12fa452
Binary files /dev/null and b/swift-java-ui-showcase/resources/form-validation.png differ
diff --git a/swift-java-ui-showcase/resources/home.png b/swift-java-ui-showcase/resources/home.png
new file mode 100644
index 0000000..8dd4d06
Binary files /dev/null and b/swift-java-ui-showcase/resources/home.png differ
diff --git a/swift-java-ui-showcase/showcase-app/.gitignore b/swift-java-ui-showcase/showcase-app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/build.gradle.kts b/swift-java-ui-showcase/showcase-app/build.gradle.kts
new file mode 100644
index 0000000..9570405
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/build.gradle.kts
@@ -0,0 +1,75 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.example.showcaseapp"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.example.showcaseapp"
+ minSdk = 28
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.navigation.compose)
+ implementation("org.swift.swiftkit:swiftkit-core:+")
+ implementation(project(":swift-java-ui-showcase-showcase-lib"))
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
diff --git a/swift-java-ui-showcase/showcase-app/proguard-rules.pro b/swift-java-ui-showcase/showcase-app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/src/androidTest/java/com/example/showcaseapp/ExampleInstrumentedTest.kt b/swift-java-ui-showcase/showcase-app/src/androidTest/java/com/example/showcaseapp/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..6373d96
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/androidTest/java/com/example/showcaseapp/ExampleInstrumentedTest.kt
@@ -0,0 +1,38 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+package com.example.showcaseapp
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.example.showcaseapp", appContext.packageName)
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-app/src/main/AndroidManifest.xml b/swift-java-ui-showcase/showcase-app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..339da58
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ComponentRenderer.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ComponentRenderer.kt
new file mode 100644
index 0000000..3c0e59e
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ComponentRenderer.kt
@@ -0,0 +1,413 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+package com.example.showcaseapp
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneOffset
+import org.json.JSONObject
+
+/**
+ * The generic interpreter for ShowcaseKit's component model: one Material 3
+ * composable per `"kind"` the Swift library can declare. All state shown
+ * here comes from the Swift-provided JSON; every interaction is reported
+ * back to Swift through [onEvent] and re-rendered from Swift's response.
+ */
+@Composable
+fun ComponentView(component: JSONObject, onEvent: (componentId: String, event: JSONObject) -> Unit) {
+ val id = component.getString("id")
+ when (val kind = component.getString("kind")) {
+ "sectionHeader" -> Text(
+ text = component.getString("text"),
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ "text" -> Text(
+ text = component.getString("text"),
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ "button" -> when (component.getString("role")) {
+ "secondary" -> OutlinedButton(onClick = { onEvent(id, event("tap")) }) {
+ Text(component.getString("label"))
+ }
+ else -> Button(onClick = { onEvent(id, event("tap")) }) {
+ Text(component.getString("label"))
+ }
+ }
+
+ "toggle" -> Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(component.getString("label"))
+ Switch(
+ checked = component.getBoolean("isOn"),
+ onCheckedChange = { onEvent(id, event("setBool").put("value", it)) }
+ )
+ }
+
+ "checkbox" -> Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(
+ checked = component.getBoolean("isChecked"),
+ onCheckedChange = { onEvent(id, event("setBool").put("value", it)) }
+ )
+ Text(component.getString("label"))
+ }
+
+ "radioGroup" -> Column {
+ Text(component.getString("label"))
+ val options = component.getJSONArray("options")
+ val selectedIndex = component.optInt("selectedIndex", -1)
+ for (index in 0 until options.length()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(
+ selected = index == selectedIndex,
+ onClick = { onEvent(id, event("select").put("index", index)) }
+ )
+ Text(options.getString(index))
+ }
+ }
+ }
+
+ "segmentedControl" -> Column {
+ Text(component.getString("label"))
+ val options = component.getJSONArray("options")
+ val selectedIndex = component.getInt("selectedIndex")
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ for (index in 0 until options.length()) {
+ SegmentedButton(
+ selected = index == selectedIndex,
+ onClick = { onEvent(id, event("select").put("index", index)) },
+ shape = SegmentedButtonDefaults.itemShape(index, options.length())
+ ) {
+ Text(options.getString(index))
+ }
+ }
+ }
+ }
+
+ "progressIndicator" -> Column {
+ Text(component.getString("label"))
+ val value = component.getDouble("value").toFloat()
+ LinearProgressIndicator(
+ progress = { value },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ "alert" -> AlertDialog(
+ onDismissRequest = { onEvent(id, event("select").put("index", 1)) },
+ title = { Text(component.getString("title")) },
+ text = { Text(component.getString("message")) },
+ confirmButton = {
+ TextButton(onClick = { onEvent(id, event("select").put("index", 0)) }) {
+ Text(component.getString("confirmLabel"))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { onEvent(id, event("select").put("index", 1)) }) {
+ Text(component.getString("cancelLabel"))
+ }
+ }
+ )
+
+ "slider" -> Column {
+ Text(component.getString("label"))
+ Slider(
+ value = component.getDouble("value").toFloat(),
+ valueRange = component.getDouble("min").toFloat()..component.getDouble("max").toFloat(),
+ onValueChange = { onEvent(id, event("setNumber").put("value", it.toDouble())) }
+ )
+ }
+
+ "stepper" -> Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(component.getString("label"))
+ val value = component.getInt("value")
+ val min = component.getInt("min")
+ val max = component.getInt("max")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(
+ onClick = { onEvent(id, event("setNumber").put("value", -1)) },
+ enabled = value > min
+ ) { Text("−", style = MaterialTheme.typography.titleLarge) }
+ Text(
+ text = value.toString(),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 12.dp)
+ )
+ IconButton(
+ onClick = { onEvent(id, event("setNumber").put("value", 1)) },
+ enabled = value < max
+ ) { Text("+", style = MaterialTheme.typography.titleLarge) }
+ }
+ }
+
+ "datePicker" -> DatePickerView(component, id, onEvent)
+
+ "textField" -> TextFieldView(component, id, onEvent)
+
+ "textEditor" -> TextEditorView(component, id, onEvent)
+
+ "code" -> CodeSnippetView(component, sectionTitle = component.getString("title"))
+
+ else -> Text("Unknown component kind: $kind")
+ }
+}
+
+/**
+ * An info icon that opens the Swift source driving the section it sits in,
+ * in a fullscreen modal. Placed at the top-trailing corner of each section
+ * card by [ShowcaseScreen] — its position is what identifies which section
+ * it belongs to, so no label is needed here. Whether the modal is open never
+ * round-trips through Swift: like scroll position, it is presentation-only
+ * state, so it stays Kotlin-local while all app state remains Swift-owned.
+ */
+@Composable
+fun CodeSnippetView(component: JSONObject, sectionTitle: String) {
+ var showModal by remember { mutableStateOf(false) }
+ IconButton(onClick = { showModal = true }) {
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = "View Swift code for $sectionTitle",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ if (showModal) {
+ CodeSnippetDialog(
+ title = component.getString("title"),
+ code = component.getString("code"),
+ onDismiss = { showModal = false }
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CodeSnippetDialog(title: String, code: String, onDismiss: () -> Unit) {
+ val clipboard = LocalClipboardManager.current
+ val context = LocalContext.current
+ var wrapEnabled by remember { mutableStateOf(false) }
+
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close")
+ }
+ },
+ actions = {
+ TextButton(onClick = { wrapEnabled = !wrapEnabled }) {
+ Text(if (wrapEnabled) "No wrap" else "Wrap")
+ }
+ TextButton(onClick = {
+ clipboard.setText(AnnotatedString(code))
+ Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
+ }) {
+ Text("Copy")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ // Long lines either scroll horizontally (default, so the Swift
+ // indentation stays intact) or wrap to the modal's width,
+ // toggled by the "Wrap" action above. Wrap state is
+ // presentation-only, so it stays local like `showModal`.
+ val verticalScroll = rememberScrollState()
+ val horizontalScroll = rememberScrollState()
+ var codeModifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .verticalScroll(verticalScroll)
+ if (!wrapEnabled) {
+ codeModifier = codeModifier.horizontalScroll(horizontalScroll)
+ }
+ Text(
+ text = code,
+ style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
+ modifier = codeModifier.padding(16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun TextFieldView(
+ component: JSONObject,
+ id: String,
+ onEvent: (componentId: String, event: JSONObject) -> Unit
+) {
+ // Buffer the in-flight text locally, re-seeding only when the
+ // Swift-provided value changes. Feeding every dispatched round trip
+ // straight back into the field would fight the IME cursor.
+ val swiftText = component.getString("text")
+ var localText by remember(swiftText) { mutableStateOf(swiftText) }
+ val error = if (component.isNull("error")) null else component.getString("error")
+
+ OutlinedTextField(
+ value = localText,
+ onValueChange = {
+ localText = it
+ onEvent(id, event("setString").put("value", it))
+ },
+ label = { Text(component.getString("label")) },
+ placeholder = { Text(component.getString("placeholder")) },
+ isError = error != null,
+ supportingText = { error?.let { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = when (component.getString("keyboard")) {
+ "email" -> KeyboardType.Email
+ "number" -> KeyboardType.Number
+ else -> KeyboardType.Text
+ }
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+/**
+ * A button showing the selected date; tapping it opens Material 3's calendar
+ * dialog. The wire value is a plain "yyyy-MM-dd" string — the same `setString`
+ * action every other text-bearing component already dispatches.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DatePickerView(
+ component: JSONObject,
+ id: String,
+ onEvent: (componentId: String, event: JSONObject) -> Unit
+) {
+ var showPicker by remember { mutableStateOf(false) }
+ val label = component.getString("label")
+ val dateText = component.getString("text")
+
+ OutlinedButton(onClick = { showPicker = true }, modifier = Modifier.fillMaxWidth()) {
+ Text("$label: $dateText")
+ }
+
+ if (showPicker) {
+ val initialMillis = runCatching {
+ LocalDate.parse(dateText).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
+ }.getOrNull()
+ val state = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
+ DatePickerDialog(
+ onDismissRequest = { showPicker = false },
+ confirmButton = {
+ TextButton(onClick = {
+ showPicker = false
+ state.selectedDateMillis?.let { millis ->
+ val isoDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
+ onEvent(id, event("setString").put("value", isoDate.toString()))
+ }
+ }) { Text("OK") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showPicker = false }) { Text("Cancel") }
+ }
+ ) {
+ DatePicker(state = state)
+ }
+ }
+}
+
+/** A multi-line text area — SwiftUI's TextEditor to [TextFieldView]'s TextField. */
+@Composable
+private fun TextEditorView(
+ component: JSONObject,
+ id: String,
+ onEvent: (componentId: String, event: JSONObject) -> Unit
+) {
+ // Same IME-buffering pattern as TextFieldView.
+ val swiftText = component.getString("text")
+ var localText by remember(swiftText) { mutableStateOf(swiftText) }
+
+ OutlinedTextField(
+ value = localText,
+ onValueChange = {
+ localText = it
+ onEvent(id, event("setString").put("value", it))
+ },
+ label = { Text(component.getString("label")) },
+ placeholder = { Text(component.getString("placeholder")) },
+ minLines = 4,
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+private fun event(type: String) = JSONObject().put("type", type)
diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/MainActivity.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/MainActivity.kt
new file mode 100644
index 0000000..44b1135
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/MainActivity.kt
@@ -0,0 +1,197 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+package com.example.showcaseapp
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.example.showcaseapp.ui.theme.ShowcaseAppTheme
+import com.example.showcasekit.ShowcaseKit
+import org.json.JSONArray
+import org.json.JSONObject
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ ShowcaseAppTheme {
+ ShowcaseNavHost()
+ }
+ }
+ }
+}
+
+/**
+ * The navigation graph is data-driven: the list of destinations comes from
+ * Swift's screen registry, so adding a screen in ShowcaseKit requires no
+ * change on the Kotlin side.
+ */
+@Composable
+fun ShowcaseNavHost() {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = "home") {
+ composable("home") {
+ HomeScreen(onOpenScreen = { navController.navigate("screen/$it") })
+ }
+ composable("screen/{screenId}") { backStackEntry ->
+ ShowcaseScreen(
+ screenId = backStackEntry.arguments?.getString("screenId").orEmpty(),
+ onBack = { navController.popBackStack() }
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(onOpenScreen: (String) -> Unit) {
+ // This calls the Swift function `showcaseScreens` from ShowcaseAPI.swift.
+ val screens = remember { JSONObject(ShowcaseKit.showcaseScreens()).getJSONArray("screens") }
+
+ Scaffold(topBar = { TopAppBar(title = { Text("UI Showcase") }) }) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
+ items(count = screens.length()) { index ->
+ val screen = screens.getJSONObject(index)
+ ListItem(
+ headlineContent = { Text(screen.getString("title")) },
+ modifier = Modifier.clickable { onOpenScreen(screen.getString("id")) }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShowcaseScreen(screenId: String, onBack: () -> Unit) {
+ // Swift is the single source of truth: this holds only the latest JSON
+ // returned by ShowcaseKit, and every event replaces it wholesale.
+ var screen by remember {
+ // This calls the Swift function `showcaseScreen` from ShowcaseAPI.swift.
+ mutableStateOf(JSONObject(ShowcaseKit.showcaseScreen(screenId)))
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(screen.getString("title")) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ // Sections aren't a wire concept — Swift only ever emits a flat
+ // component list. Every screen happens to start each section with a
+ // `sectionHeader`, so grouping on that boundary here is enough to
+ // give each section its own visually distinct card.
+ val sections = groupIntoSections(screen.getJSONArray("components"))
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(count = sections.size) { index ->
+ val section = sections[index]
+ // The header and its code snippet move into one top row —
+ // title leading, the snippet's info icon trailing — instead
+ // of flowing through the section like an ordinary component.
+ val header = section.firstOrNull { it.getString("kind") == "sectionHeader" }
+ val codeSnippet = section.firstOrNull { it.getString("kind") == "code" }
+ val bodyComponents = section.filterNot { it === header || it === codeSnippet }
+
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ val headerText = header?.getString("text").orEmpty()
+ Text(text = headerText, style = MaterialTheme.typography.titleMedium)
+ if (codeSnippet != null) {
+ CodeSnippetView(codeSnippet, sectionTitle = headerText)
+ }
+ }
+ for (component in bodyComponents) {
+ ComponentView(component) { componentId, event ->
+ // This calls the Swift function
+ // `showcaseDispatch` from ShowcaseAPI.swift;
+ // Swift handles the event and returns the
+ // screen's new component tree.
+ screen = JSONObject(
+ ShowcaseKit.showcaseDispatch(screenId, componentId, event.toString())
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Splits a flat component list into sections, starting a new one at every `sectionHeader`. */
+private fun groupIntoSections(components: JSONArray): List> {
+ val sections = mutableListOf>()
+ for (index in 0 until components.length()) {
+ val component = components.getJSONObject(index)
+ if (sections.isEmpty() || component.getString("kind") == "sectionHeader") {
+ sections.add(mutableListOf())
+ }
+ sections.last().add(component)
+ }
+ return sections
+}
diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Color.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Color.kt
new file mode 100644
index 0000000..d232562
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Color.kt
@@ -0,0 +1,25 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+package com.example.showcaseapp.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Theme.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Theme.kt
new file mode 100644
index 0000000..9ef4db6
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Theme.kt
@@ -0,0 +1,72 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+package com.example.showcaseapp.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun ShowcaseAppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Type.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Type.kt
new file mode 100644
index 0000000..eb88b50
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Type.kt
@@ -0,0 +1,48 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+package com.example.showcaseapp.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_background.xml b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_foreground.xml b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher.xml b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/values/colors.xml b/swift-java-ui-showcase/showcase-app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/values/strings.xml b/swift-java-ui-showcase/showcase-app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..a7cceb8
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ UI Showcase
+
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/values/themes.xml b/swift-java-ui-showcase/showcase-app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..4e55e4b
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/xml/backup_rules.xml b/swift-java-ui-showcase/showcase-app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/xml/data_extraction_rules.xml b/swift-java-ui-showcase/showcase-app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/swift-java-ui-showcase/showcase-app/src/test/java/com/example/showcaseapp/ExampleUnitTest.kt b/swift-java-ui-showcase/showcase-app/src/test/java/com/example/showcaseapp/ExampleUnitTest.kt
new file mode 100644
index 0000000..62af87a
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-app/src/test/java/com/example/showcaseapp/ExampleUnitTest.kt
@@ -0,0 +1,31 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+package com.example.showcaseapp
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Package.swift b/swift-java-ui-showcase/showcase-lib/Package.swift
new file mode 100644
index 0000000..f86a071
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Package.swift
@@ -0,0 +1,38 @@
+// swift-tools-version: 6.2
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import CompilerPluginSupport
+import PackageDescription
+
+let package = Package(
+ name: "ShowcaseKit",
+ platforms: [.macOS(.v15), .iOS(.v18)],
+ products: [
+ .library(
+ name: "ShowcaseKit",
+ type: .dynamic,
+ targets: ["ShowcaseKit"]
+ )
+ ],
+ dependencies: [
+ .package(url: "https://github.com/swiftlang/swift-java", from: "0.5.1")
+ ],
+ targets: [
+ .target(
+ name: "ShowcaseKit",
+ dependencies: [
+ .product(name: "SwiftJava", package: "swift-java")
+ ],
+ swiftSettings: [
+ .swiftLanguageMode(.v5)
+ ],
+ plugins: [
+ .plugin(name: "JExtractSwiftPlugin", package: "swift-java")
+ ]
+ ),
+ .testTarget(
+ name: "ShowcaseKitTests",
+ dependencies: ["ShowcaseKit"]
+ ),
+ ]
+)
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/Action.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/Action.swift
new file mode 100644
index 0000000..31d925e
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/Action.swift
@@ -0,0 +1,74 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// A user interaction sent from the Kotlin renderer to Swift, addressed to a
+/// component id. The wire format is a JSON object discriminated by `"type"`.
+///
+/// Named `Action` (rather than, say, `Event`) to match the vocabulary of
+/// ReSwift and similar unidirectional-data-flow libraries in the Swift
+/// community — see `ScreenDefinition.reduce(_:componentId:)` for the one
+/// place this departs from that vocabulary's usual contract.
+enum Action: Equatable {
+ case tap
+ case setBool(Bool)
+ case setString(String)
+ case setNumber(Double)
+ case select(Int)
+}
+
+extension Action: Codable {
+ private enum CodingKeys: String, CodingKey {
+ case type, value, index
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: .type)
+ switch type {
+ case "tap":
+ self = .tap
+ case "setBool":
+ self = .setBool(try container.decode(Bool.self, forKey: .value))
+ case "setString":
+ self = .setString(try container.decode(String.self, forKey: .value))
+ case "setNumber":
+ self = .setNumber(try container.decode(Double.self, forKey: .value))
+ case "select":
+ self = .select(try container.decode(Int.self, forKey: .index))
+ default:
+ throw DecodingError.dataCorruptedError(
+ forKey: .type, in: container, debugDescription: "Unknown action type: \(type)")
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .tap:
+ try container.encode("tap", forKey: .type)
+ case .setBool(let value):
+ try container.encode("setBool", forKey: .type)
+ try container.encode(value, forKey: .value)
+ case .setString(let value):
+ try container.encode("setString", forKey: .type)
+ try container.encode(value, forKey: .value)
+ case .setNumber(let value):
+ try container.encode("setNumber", forKey: .type)
+ try container.encode(value, forKey: .value)
+ case .select(let index):
+ try container.encode("select", forKey: .type)
+ try container.encode(index, forKey: .index)
+ }
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ButtonsScreen.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ButtonsScreen.swift
new file mode 100644
index 0000000..47098a9
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ButtonsScreen.swift
@@ -0,0 +1,53 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// Buttons: a tap counter driven entirely by Swift state.
+final class ButtonsScreen: ScreenDefinition {
+ let id = "buttons"
+ let title = "Buttons"
+
+ private var tapCount = 0
+
+ func reduce(_ action: Action, componentId: String) {
+ switch (componentId, action) {
+ case ("tap", .tap):
+ tapCount += 1
+ case ("reset", .tap):
+ tapCount = 0
+ default:
+ break
+ }
+ }
+
+ func body() -> [Component] {
+ [
+ .sectionHeader(id: "header", text: "Buttons"),
+ .text(id: "tapCount", text: "Tapped \(tapCount) time\(tapCount == 1 ? "" : "s")"),
+ .button(id: "tap", label: "Tap me", role: .primary),
+ .button(id: "reset", label: "Reset", role: .secondary),
+ .code(
+ id: "buttonsCode", title: "Swift code",
+ code: #"""
+ // body()
+ .text(id: "tapCount", text: "Tapped \(tapCount) times")
+ .button(id: "tap", label: "Tap me", role: .primary)
+ .button(id: "reset", label: "Reset", role: .secondary)
+
+ // reduce(action)
+ case ("tap", .tap): tapCount += 1
+ case ("reset", .tap): tapCount = 0
+ """#),
+ ]
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/Component.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/Component.swift
new file mode 100644
index 0000000..b95233d
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/Component.swift
@@ -0,0 +1,278 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// The keyboard type a text field asks the Android IME for.
+enum Keyboard: String, Codable {
+ case text
+ case email
+ case number
+}
+
+/// A button's visual weight: `primary` renders as a filled Material 3
+/// `Button`, `secondary` as an `OutlinedButton`. Purely presentational —
+/// both roles emit the same `{"type":"tap"}` event.
+enum ButtonRole: String, Codable {
+ case primary
+ case secondary
+}
+
+/// A single UI component declared by Swift and rendered by the Kotlin/Compose
+/// interpreter in `showcase-app`.
+///
+/// Each case maps to exactly one Material 3 composable on the Kotlin side.
+/// The wire format is a flat JSON object discriminated by a `"kind"` key —
+/// see the schema table in `swift-java-ui-showcase/README.md`.
+enum Component: Equatable {
+ case sectionHeader(id: String, text: String)
+ case text(id: String, text: String)
+ case button(id: String, label: String, role: ButtonRole)
+ case toggle(id: String, label: String, isOn: Bool)
+ case checkbox(id: String, label: String, isChecked: Bool)
+ case radioGroup(id: String, label: String, options: [String], selectedIndex: Int?)
+ case segmentedControl(id: String, label: String, options: [String], selectedIndex: Int)
+ case slider(id: String, label: String, value: Double, min: Double, max: Double)
+ case stepper(id: String, label: String, value: Int, min: Int, max: Int)
+ case datePicker(id: String, label: String, date: String)
+ case textField(
+ id: String, label: String, text: String, placeholder: String,
+ keyboard: Keyboard, error: String?)
+ case textEditor(id: String, label: String, text: String, placeholder: String)
+ case progressIndicator(id: String, label: String, value: Double)
+ case alert(id: String, title: String, message: String, confirmLabel: String, cancelLabel: String)
+ case code(id: String, title: String, code: String)
+}
+
+extension Component: Codable {
+ private enum CodingKeys: String, CodingKey {
+ case kind, id, text, label, isOn, isChecked, options, selectedIndex
+ case value, min, max, placeholder, keyboard, error, title, code, role
+ case message, confirmLabel, cancelLabel
+ }
+
+ /// The stable identifier events are addressed to.
+ var id: String {
+ switch self {
+ case .sectionHeader(let id, _), .text(let id, _), .button(let id, _, _):
+ return id
+ case .toggle(let id, _, _), .checkbox(let id, _, _):
+ return id
+ case .radioGroup(let id, _, _, _):
+ return id
+ case .segmentedControl(let id, _, _, _):
+ return id
+ case .slider(let id, _, _, _, _):
+ return id
+ case .stepper(let id, _, _, _, _):
+ return id
+ case .datePicker(let id, _, _):
+ return id
+ case .textField(let id, _, _, _, _, _):
+ return id
+ case .textEditor(let id, _, _, _):
+ return id
+ case .progressIndicator(let id, _, _):
+ return id
+ case .alert(let id, _, _, _, _):
+ return id
+ case .code(let id, _, _):
+ return id
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .sectionHeader(let id, let text):
+ try container.encode("sectionHeader", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(text, forKey: .text)
+ case .text(let id, let text):
+ try container.encode("text", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(text, forKey: .text)
+ case .button(let id, let label, let role):
+ try container.encode("button", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(role, forKey: .role)
+ case .toggle(let id, let label, let isOn):
+ try container.encode("toggle", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(isOn, forKey: .isOn)
+ case .checkbox(let id, let label, let isChecked):
+ try container.encode("checkbox", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(isChecked, forKey: .isChecked)
+ case .radioGroup(let id, let label, let options, let selectedIndex):
+ try container.encode("radioGroup", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(options, forKey: .options)
+ try container.encode(selectedIndex, forKey: .selectedIndex)
+ case .segmentedControl(let id, let label, let options, let selectedIndex):
+ try container.encode("segmentedControl", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(options, forKey: .options)
+ try container.encode(selectedIndex, forKey: .selectedIndex)
+ case .slider(let id, let label, let value, let min, let max):
+ try container.encode("slider", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(value, forKey: .value)
+ try container.encode(min, forKey: .min)
+ try container.encode(max, forKey: .max)
+ case .stepper(let id, let label, let value, let min, let max):
+ try container.encode("stepper", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(value, forKey: .value)
+ try container.encode(min, forKey: .min)
+ try container.encode(max, forKey: .max)
+ case .datePicker(let id, let label, let date):
+ try container.encode("datePicker", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(date, forKey: .text)
+ case .textField(let id, let label, let text, let placeholder, let keyboard, let error):
+ try container.encode("textField", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(text, forKey: .text)
+ try container.encode(placeholder, forKey: .placeholder)
+ try container.encode(keyboard, forKey: .keyboard)
+ try container.encode(error, forKey: .error)
+ case .textEditor(let id, let label, let text, let placeholder):
+ try container.encode("textEditor", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(text, forKey: .text)
+ try container.encode(placeholder, forKey: .placeholder)
+ case .progressIndicator(let id, let label, let value):
+ try container.encode("progressIndicator", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(label, forKey: .label)
+ try container.encode(value, forKey: .value)
+ case .alert(let id, let title, let message, let confirmLabel, let cancelLabel):
+ try container.encode("alert", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(title, forKey: .title)
+ try container.encode(message, forKey: .message)
+ try container.encode(confirmLabel, forKey: .confirmLabel)
+ try container.encode(cancelLabel, forKey: .cancelLabel)
+ case .code(let id, let title, let code):
+ try container.encode("code", forKey: .kind)
+ try container.encode(id, forKey: .id)
+ try container.encode(title, forKey: .title)
+ try container.encode(code, forKey: .code)
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let kind = try container.decode(String.self, forKey: .kind)
+ switch kind {
+ case "sectionHeader":
+ self = .sectionHeader(
+ id: try container.decode(String.self, forKey: .id),
+ text: try container.decode(String.self, forKey: .text))
+ case "text":
+ self = .text(
+ id: try container.decode(String.self, forKey: .id),
+ text: try container.decode(String.self, forKey: .text))
+ case "button":
+ self = .button(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ role: try container.decode(ButtonRole.self, forKey: .role))
+ case "toggle":
+ self = .toggle(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ isOn: try container.decode(Bool.self, forKey: .isOn))
+ case "checkbox":
+ self = .checkbox(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ isChecked: try container.decode(Bool.self, forKey: .isChecked))
+ case "radioGroup":
+ self = .radioGroup(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ options: try container.decode([String].self, forKey: .options),
+ selectedIndex: try container.decodeIfPresent(Int.self, forKey: .selectedIndex))
+ case "segmentedControl":
+ self = .segmentedControl(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ options: try container.decode([String].self, forKey: .options),
+ selectedIndex: try container.decode(Int.self, forKey: .selectedIndex))
+ case "slider":
+ self = .slider(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ value: try container.decode(Double.self, forKey: .value),
+ min: try container.decode(Double.self, forKey: .min),
+ max: try container.decode(Double.self, forKey: .max))
+ case "stepper":
+ self = .stepper(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ value: try container.decode(Int.self, forKey: .value),
+ min: try container.decode(Int.self, forKey: .min),
+ max: try container.decode(Int.self, forKey: .max))
+ case "datePicker":
+ self = .datePicker(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ date: try container.decode(String.self, forKey: .text))
+ case "textField":
+ self = .textField(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ text: try container.decode(String.self, forKey: .text),
+ placeholder: try container.decode(String.self, forKey: .placeholder),
+ keyboard: try container.decode(Keyboard.self, forKey: .keyboard),
+ error: try container.decodeIfPresent(String.self, forKey: .error))
+ case "textEditor":
+ self = .textEditor(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ text: try container.decode(String.self, forKey: .text),
+ placeholder: try container.decode(String.self, forKey: .placeholder))
+ case "progressIndicator":
+ self = .progressIndicator(
+ id: try container.decode(String.self, forKey: .id),
+ label: try container.decode(String.self, forKey: .label),
+ value: try container.decode(Double.self, forKey: .value))
+ case "alert":
+ self = .alert(
+ id: try container.decode(String.self, forKey: .id),
+ title: try container.decode(String.self, forKey: .title),
+ message: try container.decode(String.self, forKey: .message),
+ confirmLabel: try container.decode(String.self, forKey: .confirmLabel),
+ cancelLabel: try container.decode(String.self, forKey: .cancelLabel))
+ case "code":
+ self = .code(
+ id: try container.decode(String.self, forKey: .id),
+ title: try container.decode(String.self, forKey: .title),
+ code: try container.decode(String.self, forKey: .code))
+ default:
+ throw DecodingError.dataCorruptedError(
+ forKey: .kind, in: container, debugDescription: "Unknown component kind: \(kind)")
+ }
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/FeedbackScreen.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/FeedbackScreen.swift
new file mode 100644
index 0000000..bfa1bea
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/FeedbackScreen.swift
@@ -0,0 +1,94 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// Feedback: a display-only progress indicator and an alert. The alert's
+/// presence in `body()` *is* its visibility — mirroring how `FormScreen`
+/// swaps its whole body on `submitted` — so Swift stays the only owner of
+/// whether it's shown, the same as every other screen.
+final class FeedbackScreen: ScreenDefinition {
+ let id = "feedback"
+ let title = "Feedback"
+
+ private var progress = 0.0
+ private var showAlert = false
+ private var itemStatus = "Nothing deleted yet"
+
+ func reduce(_ action: Action, componentId: String) {
+ switch (componentId, action) {
+ case ("advance", .tap):
+ let next = progress + 0.25
+ progress = next > 1.0 ? 0.0 : next
+ case ("delete", .tap):
+ showAlert = true
+ case ("deleteAlert", .select(0)):
+ showAlert = false
+ itemStatus = "Item deleted"
+ case ("deleteAlert", .select(1)):
+ showAlert = false
+ itemStatus = "Kept the item"
+ default:
+ break
+ }
+ }
+
+ func body() -> [Component] {
+ var components: [Component] = [
+ .sectionHeader(id: "progressHeader", text: "Progress"),
+ .text(id: "progressStatus", text: "Progress: \(Int(progress * 100))%"),
+ .button(id: "advance", label: "Advance", role: .primary),
+ .progressIndicator(id: "loadProgress", label: "Progress", value: progress),
+ .code(
+ id: "progressCode", title: "Swift code",
+ code: #"""
+ // body()
+ .text(id: "progressStatus",
+ text: "Progress: \(Int(progress * 100))%")
+ .button(id: "advance", label: "Advance", role: .primary)
+ .progressIndicator(id: "loadProgress", label: "Progress",
+ value: progress)
+
+ // reduce(action)
+ case ("advance", .tap):
+ let next = progress + 0.25
+ progress = next > 1.0 ? 0.0 : next
+ """#),
+ .sectionHeader(id: "alertHeader", text: "Alert"),
+ .text(id: "itemStatus", text: itemStatus),
+ .button(id: "delete", label: "Delete item", role: .secondary),
+ ]
+ if showAlert {
+ components.append(
+ .alert(
+ id: "deleteAlert", title: "Delete item?", message: "This can't be undone.",
+ confirmLabel: "Delete", cancelLabel: "Cancel"))
+ }
+ components.append(
+ .code(
+ id: "alertCode", title: "Swift code",
+ code: #"""
+ // body() (only while showAlert is true)
+ .alert(id: "deleteAlert", title: "Delete item?",
+ message: "This can't be undone.",
+ confirmLabel: "Delete", cancelLabel: "Cancel")
+
+ // reduce(action)
+ case ("delete", .tap): showAlert = true
+ case ("deleteAlert", .select(0)):
+ showAlert = false; itemStatus = "Item deleted"
+ case ("deleteAlert", .select(1)):
+ showAlert = false; itemStatus = "Kept the item"
+ """#))
+ return components
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/FormScreen.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/FormScreen.swift
new file mode 100644
index 0000000..2ae5272
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/FormScreen.swift
@@ -0,0 +1,120 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// Pure validation rules used by `FormScreen`. Kept free of state so the
+/// Swift tests can pin them directly.
+enum Validation {
+ static func requiredField(_ value: String, name: String) -> String? {
+ value.allSatisfy(\.isWhitespace) ? "\(name) is required" : nil
+ }
+
+ static func email(_ value: String) -> String? {
+ if let error = requiredField(value, name: "Email") { return error }
+ let parts = value.split(separator: "@")
+ guard parts.count == 2, parts[1].contains(".") else {
+ return "Enter a valid email address"
+ }
+ return nil
+ }
+
+ static func age(_ value: String) -> String? {
+ if let error = requiredField(value, name: "Age") { return error }
+ guard let age = Int(value), (1...130).contains(age) else {
+ return "Enter an age between 1 and 130"
+ }
+ return nil
+ }
+}
+
+/// A small form: Swift stores the field values, validates on submit, and
+/// swaps the body for a success message when validation passes.
+final class FormScreen: ScreenDefinition {
+ let id = "form"
+ let title = "Form & Validation"
+
+ private var name = ""
+ private var email = ""
+ private var age = ""
+ private var errors: [String: String] = [:]
+ private var submitted = false
+
+ func reduce(_ action: Action, componentId: String) {
+ switch (componentId, action) {
+ case ("name", .setString(let value)):
+ name = value
+ errors["name"] = nil
+ case ("email", .setString(let value)):
+ email = value
+ errors["email"] = nil
+ case ("age", .setString(let value)):
+ age = value
+ errors["age"] = nil
+ case ("submit", .tap):
+ errors = validate()
+ submitted = errors.isEmpty
+ case ("reset", .tap):
+ (name, email, age, errors, submitted) = ("", "", "", [:], false)
+ default:
+ break
+ }
+ }
+
+ private func validate() -> [String: String] {
+ var errors: [String: String] = [:]
+ errors["name"] = Validation.requiredField(name, name: "Name")
+ errors["email"] = Validation.email(email)
+ errors["age"] = Validation.age(age)
+ return errors.compactMapValues { $0 }
+ }
+
+ func body() -> [Component] {
+ if submitted {
+ return [
+ .sectionHeader(id: "header", text: "Form & Validation"),
+ .text(id: "success", text: "Thanks, \(name)! Your form was submitted."),
+ .button(id: "reset", label: "Start over", role: .primary),
+ ]
+ }
+ return [
+ .sectionHeader(id: "header", text: "Form & Validation"),
+ .textField(
+ id: "name", label: "Name", text: name, placeholder: "Grace Hopper",
+ keyboard: .text, error: errors["name"]),
+ .textField(
+ id: "email", label: "Email", text: email, placeholder: "you@example.com",
+ keyboard: .email, error: errors["email"]),
+ .textField(
+ id: "age", label: "Age", text: age, placeholder: "42",
+ keyboard: .number, error: errors["age"]),
+ .button(id: "submit", label: "Submit", role: .primary),
+ .code(
+ id: "formCode", title: "Swift code",
+ code: #"""
+ // body()
+ .textField(id: "email", label: "Email", text: email,
+ placeholder: "you@example.com", keyboard: .email,
+ error: errors["email"])
+ .button(id: "submit", label: "Submit", role: .primary)
+
+ // reduce(action)
+ case ("submit", .tap):
+ errors = validate()
+ submitted = errors.isEmpty
+
+ // validate() builds on pure rules in `Validation`
+ errors["email"] = Validation.email(email)
+ """#),
+ ]
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/PickersScreen.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/PickersScreen.swift
new file mode 100644
index 0000000..09688c1
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/PickersScreen.swift
@@ -0,0 +1,68 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// Pickers: controls whose value comes from a bounded set or a calendar,
+/// rather than free-form typing — kept separate from Text Inputs since a
+/// stepper or a date picker isn't a text field.
+final class PickersScreen: ScreenDefinition {
+ let id = "pickers"
+ let title = "Pickers"
+
+ private var quantity = 3
+ private var birthday = "2000-01-01"
+
+ func reduce(_ action: Action, componentId: String) {
+ switch (componentId, action) {
+ case ("quantity", .setNumber(let delta)):
+ quantity = max(0, min(10, quantity + Int(delta)))
+ case ("birthday", .setString(let value)):
+ birthday = value
+ default:
+ break
+ }
+ }
+
+ func body() -> [Component] {
+ [
+ .sectionHeader(id: "stepperHeader", text: "Stepper"),
+ .stepper(id: "quantity", label: "Quantity", value: quantity, min: 0, max: 10),
+ .text(id: "quantitySummary", text: "Quantity: \(quantity)"),
+ .code(
+ id: "stepperCode", title: "Swift code",
+ code: #"""
+ // body()
+ .stepper(id: "quantity", label: "Quantity",
+ value: quantity, min: 0, max: 10)
+ .text(id: "quantitySummary", text: "Quantity: \(quantity)")
+
+ // reduce(action)
+ case ("quantity", .setNumber(let delta)):
+ quantity = max(0, min(10, quantity + Int(delta)))
+ """#),
+ .sectionHeader(id: "dateHeader", text: "Date picker"),
+ .datePicker(id: "birthday", label: "Birthday", date: birthday),
+ .text(id: "birthdaySummary", text: "Selected date: \(birthday)"),
+ .code(
+ id: "dateCode", title: "Swift code",
+ code: #"""
+ // body()
+ .datePicker(id: "birthday", label: "Birthday", date: birthday)
+ .text(id: "birthdaySummary", text: "Selected date: \(birthday)")
+
+ // reduce(action)
+ case ("birthday", .setString(let value)): birthday = value
+ """#),
+ ]
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ScreenRegistry.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ScreenRegistry.swift
new file mode 100644
index 0000000..082f8e1
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ScreenRegistry.swift
@@ -0,0 +1,121 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+#if canImport(FoundationEssentials)
+ import FoundationEssentials
+#else
+ import Foundation
+#endif
+
+/// One screen of the showcase: an identity, a title, a reducer, and a
+/// declarative body. Each conforming type is its own independent store —
+/// it owns its screen's state directly, with no shared state between
+/// screens. `ScreenRegistry` below only routes to the right one; it is not
+/// itself a single global store.
+///
+/// To add a new screen, conform to this protocol and append an instance to
+/// `ScreenRegistry.shared` — no Kotlin changes are needed; the navigation
+/// graph is data-driven from this registry.
+protocol ScreenDefinition: AnyObject {
+ var id: String { get }
+ var title: String { get }
+
+ /// Applies an action to this screen's state. Named `reduce` to match
+ /// ReSwift's vocabulary, but — unlike a ReSwift reducer — this mutates the
+ /// conforming instance in place rather than returning new state; there is
+ /// no single immutable app-state tree here, each screen owns its own
+ /// mutable state directly.
+ func reduce(_ action: Action, componentId: String)
+ func body() -> [Component]
+}
+
+/// Wire structs matching the JSON contract in the README.
+struct ScreenSummary: Codable, Equatable {
+ let id: String
+ let title: String
+}
+
+struct ScreenList: Codable, Equatable {
+ let screens: [ScreenSummary]
+}
+
+struct Screen: Codable, Equatable {
+ let id: String
+ let title: String
+ let components: [Component]
+}
+
+/// Routes to the per-screen store whose `id` matches. Holds no UI state of
+/// its own — each `ScreenDefinition` instance in `screens` is the actual
+/// store for its screen. All entry points are called from the Android main
+/// thread — that invariant is what makes the unsynchronized singleton safe.
+final class ScreenRegistry {
+ static let shared = ScreenRegistry()
+
+ private let screens: [any ScreenDefinition]
+
+ init(screens: [any ScreenDefinition] = ScreenRegistry.defaultScreens()) {
+ self.screens = screens
+ }
+
+ static func defaultScreens() -> [any ScreenDefinition] {
+ [
+ ButtonsScreen(),
+ SelectionScreen(),
+ SlidersScreen(),
+ TextInputsScreen(),
+ PickersScreen(),
+ FormScreen(),
+ FeedbackScreen(),
+ ]
+ }
+
+ func screensJSON() -> String {
+ encode(ScreenList(screens: screens.map { ScreenSummary(id: $0.id, title: $0.title) }))
+ }
+
+ func screenJSON(_ id: String) -> String {
+ guard let screen = screens.first(where: { $0.id == id }) else {
+ return encode(errorScreen("Unknown screen: \(id)"))
+ }
+ return encode(Screen(id: screen.id, title: screen.title, components: screen.body()))
+ }
+
+ func dispatch(screenId: String, componentId: String, actionJSON: String) -> String {
+ guard let screen = screens.first(where: { $0.id == screenId }) else {
+ return encode(errorScreen("Unknown screen: \(screenId)"))
+ }
+ do {
+ let action = try JSONDecoder().decode(Action.self, from: Data(actionJSON.utf8))
+ screen.reduce(action, componentId: componentId)
+ } catch {
+ return encode(errorScreen("Could not decode action \(actionJSON): \(error)"))
+ }
+ return encode(Screen(id: screen.id, title: screen.title, components: screen.body()))
+ }
+
+ private func errorScreen(_ message: String) -> Screen {
+ Screen(id: "error", title: "Error", components: [.text(id: "message", text: message)])
+ }
+
+ private func encode(_ value: some Encodable) -> String {
+ let encoder = JSONEncoder()
+ // Sorted keys keep the output deterministic so tests can pin exact JSON.
+ encoder.outputFormatting = [.sortedKeys]
+ guard let data = try? encoder.encode(value) else {
+ return #"{"id":"error","title":"Error","components":[]}"#
+ }
+ return String(decoding: data, as: UTF8.self)
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/SelectionScreen.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/SelectionScreen.swift
new file mode 100644
index 0000000..509b321
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/SelectionScreen.swift
@@ -0,0 +1,111 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// Selection controls: switch, checkboxes, a radio group, and a segmented
+/// control. The summary texts are derived in Swift, showing cross-component
+/// state.
+final class SelectionScreen: ScreenDefinition {
+ let id = "selection"
+ let title = "Selection Controls"
+
+ private var wifiOn = true
+ private var acceptedTerms = false
+ private var subscribed = false
+ private var sizeIndex: Int? = 1
+ private let sizes = ["Small", "Medium", "Large"]
+ private var sortOrder = 0
+ private let sortOptions = ["Newest", "Popular"]
+
+ func reduce(_ action: Action, componentId: String) {
+ switch (componentId, action) {
+ case ("wifi", .setBool(let value)):
+ wifiOn = value
+ case ("terms", .setBool(let value)):
+ acceptedTerms = value
+ case ("newsletter", .setBool(let value)):
+ subscribed = value
+ case ("size", .select(let index)) where sizes.indices.contains(index):
+ sizeIndex = index
+ case ("sort", .select(let index)) where sortOptions.indices.contains(index):
+ sortOrder = index
+ default:
+ break
+ }
+ }
+
+ func body() -> [Component] {
+ [
+ .sectionHeader(id: "switchHeader", text: "Switch"),
+ .toggle(id: "wifi", label: "Wi-Fi", isOn: wifiOn),
+ .text(id: "wifiStatus", text: "Wi-Fi is \(wifiOn ? "on" : "off")"),
+ .code(
+ id: "switchCode", title: "Swift code",
+ code: #"""
+ // body()
+ .toggle(id: "wifi", label: "Wi-Fi", isOn: wifiOn)
+ .text(id: "wifiStatus", text: "Wi-Fi is \(wifiOn ? "on" : "off")")
+
+ // reduce(action)
+ case ("wifi", .setBool(let value)): wifiOn = value
+ """#),
+ .sectionHeader(id: "checkboxHeader", text: "Checkboxes"),
+ .checkbox(id: "terms", label: "Accept the terms", isChecked: acceptedTerms),
+ .checkbox(id: "newsletter", label: "Subscribe to the newsletter", isChecked: subscribed),
+ .code(
+ id: "checkboxCode", title: "Swift code",
+ code: #"""
+ // body()
+ .checkbox(id: "terms", label: "Accept the terms",
+ isChecked: acceptedTerms)
+ .checkbox(id: "newsletter",
+ label: "Subscribe to the newsletter", isChecked: subscribed)
+
+ // reduce(action)
+ case ("terms", .setBool(let value)): acceptedTerms = value
+ case ("newsletter", .setBool(let value)): subscribed = value
+ """#),
+ .sectionHeader(id: "radioHeader", text: "Radio group"),
+ .radioGroup(id: "size", label: "T-shirt size", options: sizes, selectedIndex: sizeIndex),
+ .text(id: "summary", text: "Selected size: \(sizeIndex.map { sizes[$0] } ?? "none")"),
+ .code(
+ id: "radioCode", title: "Swift code",
+ code: #"""
+ // body()
+ .radioGroup(id: "size", label: "T-shirt size",
+ options: sizes, selectedIndex: sizeIndex)
+ .text(id: "summary",
+ text: "Selected size: \(sizeIndex.map { sizes[$0] } ?? "none")")
+
+ // reduce(action)
+ case ("size", .select(let index)): sizeIndex = index
+ """#),
+ .sectionHeader(id: "segmentedHeader", text: "Segmented control"),
+ .segmentedControl(
+ id: "sort", label: "Sort by", options: sortOptions, selectedIndex: sortOrder),
+ .text(id: "sortSummary", text: "Sorting by: \(sortOptions[sortOrder])"),
+ .code(
+ id: "segmentedCode", title: "Swift code",
+ code: #"""
+ // body()
+ .segmentedControl(id: "sort", label: "Sort by",
+ options: sortOptions, selectedIndex: sortOrder)
+ .text(id: "sortSummary",
+ text: "Sorting by: \(sortOptions[sortOrder])")
+
+ // reduce(action)
+ case ("sort", .select(let index)): sortOrder = index
+ """#),
+ ]
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ShowcaseAPI.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ShowcaseAPI.swift
new file mode 100644
index 0000000..11d91da
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/ShowcaseAPI.swift
@@ -0,0 +1,51 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+// The Swift <-> Java boundary of the showcase.
+//
+// These three functions are the only public symbols in the package, so they
+// are all that JExtract exposes to Java (as static methods on the generated
+// `com.example.showcasekit.ShowcaseKit` class). Everything crosses the
+// boundary as a JSON string, in the schema documented in
+// `swift-java-ui-showcase/README.md`:
+//
+// Compose interaction -> showcaseDispatch(screen, component, action JSON)
+// -> Swift mutates state -> returns new screen JSON -> Compose re-renders
+//
+// Plain `(String...) -> String` top-level functions are used deliberately:
+// they are the shape JExtract's JNI mode bridges most simply, and the JSON
+// payload keeps richer Swift types (enums with payloads, optionals, arrays
+// of structs) from ever needing to cross the boundary themselves.
+//
+// All three functions must be called from the Android main thread.
+
+/// Returns the registry of showcase screens (ids and titles) as JSON.
+/// The Kotlin navigation graph is built from this list.
+public func showcaseScreens() -> String {
+ ScreenRegistry.shared.screensJSON()
+}
+
+/// Returns the current component tree of one screen as JSON.
+public func showcaseScreen(_ id: String) -> String {
+ ScreenRegistry.shared.screenJSON(id)
+}
+
+/// Applies a user action to a component on a screen and returns the screen's
+/// new component tree as JSON.
+public func showcaseDispatch(_ screenId: String, _ componentId: String, _ actionJSON: String)
+ -> String
+{
+ ScreenRegistry.shared.dispatch(
+ screenId: screenId, componentId: componentId, actionJSON: actionJSON)
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/SlidersScreen.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/SlidersScreen.swift
new file mode 100644
index 0000000..95f4c24
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/SlidersScreen.swift
@@ -0,0 +1,57 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// Sliders: the value readouts are formatted by Swift on every change.
+final class SlidersScreen: ScreenDefinition {
+ let id = "sliders"
+ let title = "Sliders"
+
+ private var volume = 0.5
+ private var brightness = 80.0
+
+ func reduce(_ action: Action, componentId: String) {
+ switch (componentId, action) {
+ case ("volume", .setNumber(let value)):
+ volume = value
+ case ("brightness", .setNumber(let value)):
+ brightness = value
+ default:
+ break
+ }
+ }
+
+ func body() -> [Component] {
+ [
+ .sectionHeader(id: "header", text: "Sliders"),
+ .slider(id: "volume", label: "Volume: \(Int(volume * 100))%", value: volume, min: 0, max: 1),
+ .slider(
+ id: "brightness", label: "Brightness: \(Int(brightness))", value: brightness, min: 0,
+ max: 100),
+ .code(
+ id: "slidersCode", title: "Swift code",
+ code: #"""
+ // body()
+ .slider(id: "volume", label: "Volume: \(Int(volume * 100))%",
+ value: volume, min: 0, max: 1)
+ .slider(id: "brightness",
+ label: "Brightness: \(Int(brightness))",
+ value: brightness, min: 0, max: 100)
+
+ // reduce(action)
+ case ("volume", .setNumber(let value)): volume = value
+ case ("brightness", .setNumber(let value)): brightness = value
+ """#),
+ ]
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/TextInputsScreen.swift b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/TextInputsScreen.swift
new file mode 100644
index 0000000..c8adb11
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/TextInputsScreen.swift
@@ -0,0 +1,72 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+/// Text inputs: one field per keyboard type, with a Swift-computed readout.
+final class TextInputsScreen: ScreenDefinition {
+ let id = "textInputs"
+ let title = "Text Inputs"
+
+ private var plain = ""
+ private var email = ""
+ private var amount = ""
+ private var notes = ""
+
+ func reduce(_ action: Action, componentId: String) {
+ switch (componentId, action) {
+ case ("plain", .setString(let value)):
+ plain = value
+ case ("email", .setString(let value)):
+ email = value
+ case ("amount", .setString(let value)):
+ amount = value
+ case ("notes", .setString(let value)):
+ notes = value
+ default:
+ break
+ }
+ }
+
+ func body() -> [Component] {
+ [
+ .sectionHeader(id: "header", text: "Text inputs"),
+ .textField(
+ id: "plain", label: "Plain text", text: plain, placeholder: "Type anything",
+ keyboard: .text, error: nil),
+ .textField(
+ id: "email", label: "Email", text: email, placeholder: "you@example.com",
+ keyboard: .email, error: nil),
+ .textField(
+ id: "amount", label: "Amount", text: amount, placeholder: "0",
+ keyboard: .number, error: nil),
+ .textEditor(
+ id: "notes", label: "Notes", text: notes, placeholder: "Write a few lines"),
+ .text(id: "readout", text: "Plain text has \(plain.count) character\(plain.count == 1 ? "" : "s")"),
+ .code(
+ id: "textInputsCode", title: "Swift code",
+ code: #"""
+ // body()
+ .textField(id: "email", label: "Email", text: email,
+ placeholder: "you@example.com", keyboard: .email, error: nil)
+ .textEditor(id: "notes", label: "Notes", text: notes,
+ placeholder: "Write a few lines")
+ .text(id: "readout",
+ text: "Plain text has \(plain.count) characters")
+
+ // reduce(action)
+ case ("email", .setString(let value)): email = value
+ case ("notes", .setString(let value)): notes = value
+ """#),
+ ]
+ }
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/swift-java.config b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/swift-java.config
new file mode 100644
index 0000000..b14495e
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Sources/ShowcaseKit/swift-java.config
@@ -0,0 +1,4 @@
+{
+ "javaPackage": "com.example.showcasekit",
+ "mode": "jni"
+}
diff --git a/swift-java-ui-showcase/showcase-lib/Tests/ShowcaseKitTests/ShowcaseKitTests.swift b/swift-java-ui-showcase/showcase-lib/Tests/ShowcaseKitTests/ShowcaseKitTests.swift
new file mode 100644
index 0000000..46c6cbc
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/Tests/ShowcaseKitTests/ShowcaseKitTests.swift
@@ -0,0 +1,287 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+import Testing
+
+@testable import ShowcaseKit
+
+#if canImport(FoundationEssentials)
+ import FoundationEssentials
+#else
+ import Foundation
+#endif
+
+// Pins the exact wire format of the buttons screen. This is the JSON schema
+// contract documented in swift-java-ui-showcase/README.md ("JSON schema"
+// section) — if this test needs updating, update that table too.
+@Test func buttonsScreenSchemaIsStable() {
+ let store = ScreenRegistry()
+ #expect(
+ store.screenJSON("buttons") == #"""
+ {"components":[{"id":"header","kind":"sectionHeader","text":"Buttons"},{"id":"tapCount","kind":"text","text":"Tapped 0 times"},{"id":"tap","kind":"button","label":"Tap me","role":"primary"},{"id":"reset","kind":"button","label":"Reset","role":"secondary"},{"code":"\/\/ body()\n.text(id: \"tapCount\", text: \"Tapped \\(tapCount) times\")\n.button(id: \"tap\", label: \"Tap me\", role: .primary)\n.button(id: \"reset\", label: \"Reset\", role: .secondary)\n\n\/\/ reduce(action)\ncase (\"tap\", .tap): tapCount += 1\ncase (\"reset\", .tap): tapCount = 0","id":"buttonsCode","kind":"code","title":"Swift code"}],"id":"buttons","title":"Buttons"}
+ """#)
+}
+
+// Every section header is paired with exactly one code snippet showing the
+// Swift that drives that section.
+@Test(arguments: ["buttons", "selection", "sliders", "textInputs", "pickers", "form", "feedback"])
+func everySectionHasACodeSnippet(screenId: String) throws {
+ let store = ScreenRegistry()
+ let screen = try JSONDecoder().decode(Screen.self, from: Data(store.screenJSON(screenId).utf8))
+ let headerCount = screen.components.count { if case .sectionHeader = $0 { true } else { false } }
+ let snippets = screen.components.compactMap { component -> String? in
+ if case .code(_, _, let code) = component { return code }
+ return nil
+ }
+ #expect(snippets.count == headerCount)
+ #expect(snippets.allSatisfy { !$0.isEmpty })
+}
+
+@Test func stepperComponentRoundTripsThroughJSON() throws {
+ let component = Component.stepper(id: "quantity", label: "Quantity", value: 3, min: 0, max: 10)
+ let encoded = try JSONEncoder().encode(component)
+ #expect(try JSONDecoder().decode(Component.self, from: encoded) == component)
+}
+
+@Test func datePickerComponentRoundTripsThroughJSON() throws {
+ let component = Component.datePicker(id: "birthday", label: "Birthday", date: "2000-01-01")
+ let encoded = try JSONEncoder().encode(component)
+ #expect(try JSONDecoder().decode(Component.self, from: encoded) == component)
+}
+
+@Test func pickersScreenStepperClampsToRange() throws {
+ let store = ScreenRegistry()
+ let decoder = JSONDecoder()
+
+ // Decrementing from the default (3) five times clamps at the minimum (0).
+ var latest: Screen!
+ for _ in 0..<5 {
+ let updated = store.dispatch(
+ screenId: "pickers", componentId: "quantity", actionJSON: #"{"type":"setNumber","value":-1}"#)
+ latest = try decoder.decode(Screen.self, from: Data(updated.utf8))
+ }
+ #expect(
+ latest.components.contains(.stepper(id: "quantity", label: "Quantity", value: 0, min: 0, max: 10)))
+}
+
+@Test func pickersScreenDatePickerDispatchesSetString() throws {
+ let store = ScreenRegistry()
+ let updated = store.dispatch(
+ screenId: "pickers", componentId: "birthday",
+ actionJSON: #"{"type":"setString","value":"1990-05-12"}"#)
+ let screen = try JSONDecoder().decode(Screen.self, from: Data(updated.utf8))
+ #expect(
+ screen.components.contains(.datePicker(id: "birthday", label: "Birthday", date: "1990-05-12")))
+}
+
+@Test func segmentedControlComponentRoundTripsThroughJSON() throws {
+ let component = Component.segmentedControl(
+ id: "sort", label: "Sort by", options: ["Newest", "Popular"], selectedIndex: 0)
+ let encoded = try JSONEncoder().encode(component)
+ #expect(try JSONDecoder().decode(Component.self, from: encoded) == component)
+}
+
+@Test func selectionScreenSegmentedControlDispatchesSelect() throws {
+ let store = ScreenRegistry()
+ let updated = store.dispatch(
+ screenId: "selection", componentId: "sort", actionJSON: #"{"type":"select","index":1}"#)
+ let screen = try JSONDecoder().decode(Screen.self, from: Data(updated.utf8))
+ #expect(
+ screen.components.contains(
+ .segmentedControl(
+ id: "sort", label: "Sort by", options: ["Newest", "Popular"], selectedIndex: 1)))
+}
+
+@Test func progressIndicatorComponentRoundTripsThroughJSON() throws {
+ let component = Component.progressIndicator(id: "loadProgress", label: "Progress", value: 0.5)
+ let encoded = try JSONEncoder().encode(component)
+ #expect(try JSONDecoder().decode(Component.self, from: encoded) == component)
+}
+
+@Test func alertComponentRoundTripsThroughJSON() throws {
+ let component = Component.alert(
+ id: "deleteAlert", title: "Delete item?", message: "This can't be undone.",
+ confirmLabel: "Delete", cancelLabel: "Cancel")
+ let encoded = try JSONEncoder().encode(component)
+ #expect(try JSONDecoder().decode(Component.self, from: encoded) == component)
+}
+
+@Test func feedbackScreenAdvancesProgressAndWraps() throws {
+ let store = ScreenRegistry()
+ let decoder = JSONDecoder()
+
+ var latest: Screen!
+ for expected in [0.25, 0.5, 0.75, 1.0, 0.0] {
+ let updated = store.dispatch(
+ screenId: "feedback", componentId: "advance", actionJSON: #"{"type":"tap"}"#)
+ latest = try decoder.decode(Screen.self, from: Data(updated.utf8))
+ #expect(
+ latest.components.contains(
+ .progressIndicator(id: "loadProgress", label: "Progress", value: expected)))
+ }
+}
+
+@Test func feedbackScreenAlertConfirmAndCancel() throws {
+ let store = ScreenRegistry()
+ let decoder = JSONDecoder()
+
+ // Deleting shows the alert.
+ let afterDelete = store.dispatch(
+ screenId: "feedback", componentId: "delete", actionJSON: #"{"type":"tap"}"#)
+ let deleteScreen = try decoder.decode(Screen.self, from: Data(afterDelete.utf8))
+ #expect(
+ deleteScreen.components.contains(
+ .alert(
+ id: "deleteAlert", title: "Delete item?", message: "This can't be undone.",
+ confirmLabel: "Delete", cancelLabel: "Cancel")))
+
+ // Cancelling hides the alert and leaves the item.
+ let afterCancel = store.dispatch(
+ screenId: "feedback", componentId: "deleteAlert", actionJSON: #"{"type":"select","index":1}"#)
+ let cancelScreen = try decoder.decode(Screen.self, from: Data(afterCancel.utf8))
+ #expect(!cancelScreen.components.contains { if case .alert = $0 { true } else { false } })
+ #expect(cancelScreen.components.contains(.text(id: "itemStatus", text: "Kept the item")))
+
+ // Deleting again, then confirming, hides the alert and marks it deleted.
+ _ = store.dispatch(screenId: "feedback", componentId: "delete", actionJSON: #"{"type":"tap"}"#)
+ let afterConfirm = store.dispatch(
+ screenId: "feedback", componentId: "deleteAlert", actionJSON: #"{"type":"select","index":0}"#)
+ let confirmScreen = try decoder.decode(Screen.self, from: Data(afterConfirm.utf8))
+ #expect(!confirmScreen.components.contains { if case .alert = $0 { true } else { false } })
+ #expect(confirmScreen.components.contains(.text(id: "itemStatus", text: "Item deleted")))
+}
+
+@Test(arguments: [ButtonRole.primary, ButtonRole.secondary])
+func buttonComponentRoundTripsThroughJSON(role: ButtonRole) throws {
+ let component = Component.button(id: "go", label: "Go", role: role)
+ let encoded = try JSONEncoder().encode(component)
+ let decoded = try JSONDecoder().decode(Component.self, from: encoded)
+ #expect(decoded == component)
+ let json = try JSONDecoder().decode([String: String].self, from: encoded)
+ #expect(json["role"] == role.rawValue)
+}
+
+@Test func buttonsScreenAssignsPrimaryAndSecondaryRoles() throws {
+ let store = ScreenRegistry()
+ let screen = try JSONDecoder().decode(Screen.self, from: Data(store.screenJSON("buttons").utf8))
+ #expect(screen.components.contains(.button(id: "tap", label: "Tap me", role: .primary)))
+ #expect(screen.components.contains(.button(id: "reset", label: "Reset", role: .secondary)))
+}
+
+@Test func codeComponentRoundTripsThroughJSON() throws {
+ let component = Component.code(
+ id: "switchCode", title: "Switch",
+ code: #".toggle(id: "wifi", label: "Wi-Fi", isOn: wifiOn)"#)
+ let encoded = try JSONEncoder().encode(component)
+ let kind = try JSONDecoder().decode([String: String].self, from: encoded)["kind"]
+ #expect(kind == "code")
+ #expect(try JSONDecoder().decode(Component.self, from: encoded) == component)
+}
+
+@Test func textEditorComponentRoundTripsThroughJSON() throws {
+ let component = Component.textEditor(
+ id: "notes", label: "Notes", text: "Hello", placeholder: "Write something long")
+ let encoded = try JSONEncoder().encode(component)
+ let kind = try JSONDecoder().decode([String: String].self, from: encoded)["kind"]
+ #expect(kind == "textEditor")
+ #expect(try JSONDecoder().decode(Component.self, from: encoded) == component)
+}
+
+@Test func textInputsScreenEditsNotesThroughTextEditor() throws {
+ let store = ScreenRegistry()
+ let updated = store.dispatch(
+ screenId: "textInputs", componentId: "notes",
+ actionJSON: #"{"type":"setString","value":"Dear diary"}"#)
+ let screen = try JSONDecoder().decode(Screen.self, from: Data(updated.utf8))
+ #expect(
+ screen.components.contains(
+ .textEditor(
+ id: "notes", label: "Notes", text: "Dear diary",
+ placeholder: "Write a few lines")))
+}
+
+@Test func screenListContainsAllScreensInOrder() throws {
+ let store = ScreenRegistry()
+ let list = try JSONDecoder().decode(ScreenList.self, from: Data(store.screensJSON().utf8))
+ #expect(
+ list.screens.map(\.id)
+ == ["buttons", "selection", "sliders", "textInputs", "pickers", "form", "feedback"])
+}
+
+@Test(arguments: [
+ (#"{"type":"tap"}"#, Action.tap),
+ (#"{"type":"setBool","value":true}"#, Action.setBool(true)),
+ (#"{"type":"setString","value":"abc"}"#, Action.setString("abc")),
+ (#"{"type":"setNumber","value":0.75}"#, Action.setNumber(0.75)),
+ (#"{"type":"select","index":2}"#, Action.select(2)),
+])
+func actionDecoding(json: String, expected: Action) throws {
+ #expect(try JSONDecoder().decode(Action.self, from: Data(json.utf8)) == expected)
+}
+
+@Test func tapDispatchRoundTrip() throws {
+ let store = ScreenRegistry()
+ let updated = store.dispatch(
+ screenId: "buttons", componentId: "tap", actionJSON: #"{"type":"tap"}"#)
+ let screen = try JSONDecoder().decode(Screen.self, from: Data(updated.utf8))
+ #expect(screen.components.contains(.text(id: "tapCount", text: "Tapped 1 time")))
+
+ let reset = store.dispatch(
+ screenId: "buttons", componentId: "reset", actionJSON: #"{"type":"tap"}"#)
+ let resetScreen = try JSONDecoder().decode(Screen.self, from: Data(reset.utf8))
+ #expect(resetScreen.components.contains(.text(id: "tapCount", text: "Tapped 0 times")))
+}
+
+@Test func unknownScreenReturnsErrorScreen() throws {
+ let store = ScreenRegistry()
+ let screen = try JSONDecoder().decode(Screen.self, from: Data(store.screenJSON("nope").utf8))
+ #expect(screen.id == "error")
+}
+
+@Test func validationRules() {
+ #expect(Validation.requiredField(" ", name: "Name") == "Name is required")
+ #expect(Validation.requiredField("Grace", name: "Name") == nil)
+ #expect(Validation.email("not-an-email") == "Enter a valid email address")
+ #expect(Validation.email("grace@example.com") == nil)
+ #expect(Validation.age("0") == "Enter an age between 1 and 130")
+ #expect(Validation.age("42") == nil)
+}
+
+@Test func formSubmitWithErrorsThenSuccess() throws {
+ let store = ScreenRegistry()
+ let decoder = JSONDecoder()
+
+ // Submitting the empty form must surface an error on every field.
+ let invalid = store.dispatch(
+ screenId: "form", componentId: "submit", actionJSON: #"{"type":"tap"}"#)
+ let invalidScreen = try decoder.decode(Screen.self, from: Data(invalid.utf8))
+ let fieldErrors = invalidScreen.components.compactMap { component -> String? in
+ if case .textField(_, _, _, _, _, let error) = component { return error }
+ return nil
+ }
+ #expect(fieldErrors.count == 3)
+
+ // Filling every field and resubmitting swaps the body for the success state.
+ for (id, value) in [("name", "Grace"), ("email", "grace@example.com"), ("age", "42")] {
+ _ = store.dispatch(
+ screenId: "form", componentId: id,
+ actionJSON: #"{"type":"setString","value":"\#(value)"}"#)
+ }
+ let valid = store.dispatch(
+ screenId: "form", componentId: "submit", actionJSON: #"{"type":"tap"}"#)
+ let validScreen = try decoder.decode(Screen.self, from: Data(valid.utf8))
+ #expect(
+ validScreen.components.contains(
+ .text(id: "success", text: "Thanks, Grace! Your form was submitted.")))
+}
diff --git a/swift-java-ui-showcase/showcase-lib/build.gradle b/swift-java-ui-showcase/showcase-lib/build.gradle
new file mode 100644
index 0000000..9cca05c
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/build.gradle
@@ -0,0 +1,217 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of Swift.org project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+import java.nio.file.*
+import org.gradle.internal.os.OperatingSystem
+import groovy.json.JsonSlurper
+import kotlinx.serialization.json.*
+
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace "com.example.showcaselib"
+ compileSdkVersion 36
+
+ defaultConfig {
+ minSdkVersion 28
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation('org.swift.swiftkit:swiftkit-core:+')
+}
+
+// Helper function to get swiftly executable path
+def getSwiftlyPath() {
+ def fromConfig = project.findProperty("swiftly.path") ?: System.getenv("SWIFTLY_PATH")
+ if (fromConfig) {
+ return file(fromConfig)
+ }
+
+ // Try to find swiftly in common locations
+ def homeDir = System.getProperty("user.home")
+ def possiblePaths = [
+ "$homeDir/.swiftly/bin/swiftly",
+ "$homeDir/.local/share/swiftly/bin/swiftly",
+ "$homeDir/.local/bin/swiftly",
+ "/usr/local/bin/swiftly",
+ "/opt/homebrew/bin/swiftly",
+ "/root/.local/share/swiftly/bin/swiftly"
+ ]
+
+ for (path in possiblePaths) {
+ if (file(path).exists()) {
+ return path
+ }
+ }
+
+ throw new GradleException("Swift SDK path not found. Please set swiftly.path in the gradle.properties file or set SWIFTLY_PATH environment variable.")
+}
+def getSwiftSDKPath() {
+ def fromConfig = project.findProperty("swift.sdk.path") ?: System.getenv("SWIFT_SDK_PATH")
+ if (fromConfig) {
+ return file(fromConfig)
+ }
+
+ // If no custom path is set, try to find the Swift SDK in common locations.
+ def homeDir = System.getProperty("user.home")
+ def possiblePaths = [
+ "${homeDir}/Library/org.swift.swiftpm/swift-sdks/", // Common on macOS
+ "${homeDir}/.config/swiftpm/swift-sdks/", // Common on Linux
+ "${homeDir}/.swiftpm/swift-sdks/", // Older location
+ "/root/.swiftpm/swift-sdks/" // For builds running as root (e.g., in some CI/Docker environments)
+ ]
+
+ // Iterate through the list of possible paths.
+ for (path in possiblePaths) {
+ // The 'file()' method is a Gradle helper that resolves a path string into a File object.
+ if (file(path).exists()) {
+ // If the directory exists, we've found it. Return the path immediately.
+ return file(path)
+ }
+ }
+
+ // If the loop completes without finding a valid path, throw an exception.
+ throw new GradleException("Swift SDK path not found. Please set swift.sdk.path in the gradle.properties file or set SWIFT_SDK_PATH environment variable.")
+}
+
+// List of Swift runtime libraries we want to include
+def swiftRuntimeLibs = [
+ "swiftCore",
+ "swift_Concurrency",
+ "swift_StringProcessing",
+ "swift_RegexParser",
+ "swift_Builtin_float",
+ "swift_math",
+ "swiftAndroid",
+ "dispatch",
+ "BlocksRuntime",
+ "swiftSwiftOnoneSupport",
+ "swiftDispatch",
+ "Foundation",
+ "FoundationEssentials",
+ "FoundationInternationalization",
+ "_FoundationICU",
+ "swiftSynchronization"
+]
+// Swift toolchain version passed to swiftly (e.g. "6.3", "main-snapshot").
+// Can be overridden via the SWIFT_VERSION environment variable, which is
+// useful for CI matrices that test multiple toolchains.
+def swiftVersion = System.getenv("SWIFT_VERSION") ?: "6.3"
+// Android Swift SDK artifactbundle suffix. Substituted into the bundle
+// directory name as "swift-${androidSdkVersion}.artifactbundle". Can be
+// overridden via the SWIFT_ANDROID_SDK_VERSION environment variable.
+def androidSdkVersion = System.getenv("SWIFT_ANDROID_SDK_VERSION") ?: "${swiftVersion}-RELEASE_android"
+def sdkName = "swift-${androidSdkVersion}.artifactbundle"
+def minSdk = android.defaultConfig.minSdkVersion.apiLevel
+/**
+ * Android ABIs and their Swift triple mappings
+ */
+def abis = [
+ "arm64-v8a" : [triple: "aarch64-unknown-linux-android${minSdk}", androidSdkLibDirectory: "swift-aarch64", ndkDirectory: "aarch64-linux-android"],
+ "armeabi-v7a" : [triple: "armv7-unknown-linux-android${minSdk}", androidSdkLibDirectory: "swift-armv7", ndkDirectory: "arm-linux-android"],
+ "x86_64" : [triple: "x86_64-unknown-linux-android${minSdk}", androidSdkLibDirectory: "swift-x86_64", ndkDirectory: "x86_64-linux-android"]
+]
+def generatedJniLibsDir = layout.buildDirectory.dir("generated/jniLibs")
+def swiftSdkPath = "${getSwiftSDKPath().absolutePath}/${sdkName}"
+
+def buildSwiftAll = tasks.register("buildSwiftAll") {
+ group = "build"
+ description = "Builds the Swift code for all Android ABIs."
+
+ // If the package description changes, we should execute jextract again, maybe we added jextract to new targets
+ inputs.file(new File(projectDir, "Package.swift"))
+ // This path hardcodes the Swift target name: keep it in sync with the
+ // target defined in Package.swift when copying this file to a new example.
+ inputs.dir(new File(layout.projectDirectory.asFile, "Sources/ShowcaseKit".toString()))
+
+ // This path also hardcodes the Swift target name (ShowcaseKit).
+ outputs.dir(layout.buildDirectory.dir("../.build/plugins/outputs/${layout.projectDirectory.asFile.getName().toLowerCase()}/ShowcaseKit/destination/JExtractSwiftPlugin/src/generated/java"))
+}
+
+// Create a build task for each ABI
+abis.each { abi, info ->
+ def task = tasks.register("buildSwift${abi.capitalize()}", Exec) {
+ group = "build"
+ description = "Builds the Swift code for the ${abi} ABI."
+
+ doFirst {
+ println("Building Swift for ${abi} (${info.triple})...")
+ }
+
+ outputs.dir(layout.projectDirectory.dir(".build/${info.triple}/debug"))
+
+ workingDir = layout.projectDirectory
+ executable(getSwiftlyPath())
+
+ args("run", "swift", "build", "+${swiftVersion}", "--swift-sdk", info.triple, "--build-system", "native")
+ }
+
+ buildSwiftAll.configure { dependsOn(task) }
+}
+
+def copyJniLibs = tasks.register("copyJniLibs", Copy) {
+ dependsOn(buildSwiftAll)
+
+ abis.each { abi, info ->
+ // Copy the built .so files
+ from(layout.projectDirectory.dir(".build/${info.triple}/debug")) {
+ include("*.so")
+ into(abi)
+ }
+
+ // Copy libc++_shared.so from NDK
+ from(file("${swiftSdkPath}/swift-android/ndk-sysroot/usr/lib/${info.ndkDirectory}/libc++_shared.so")) {
+ into(abi)
+ }
+
+ doFirst {
+ println("Copying Swift runtime libraries for ${abi}...")
+ }
+
+ // Copy the Swift runtime libraries
+ from(swiftRuntimeLibs.collect { libName ->
+ "${swiftSdkPath}/swift-android/swift-resources/usr/lib/${info.androidSdkLibDirectory}/android/lib${libName}.so"
+ }) {
+ into(abi)
+ }
+ }
+
+ into(generatedJniLibsDir)
+}
+
+// Add the java-swift generated Java sources
+android {
+ sourceSets {
+ main {
+ java {
+ srcDir(buildSwiftAll)
+ }
+
+ jniLibs {
+ srcDir(generatedJniLibsDir)
+ }
+ }
+ }
+}
+
+// Make sure we run our tasks before build
+preBuild.dependsOn(copyJniLibs)
diff --git a/swift-java-ui-showcase/showcase-lib/gradle.properties b/swift-java-ui-showcase/showcase-lib/gradle.properties
new file mode 100644
index 0000000..dddb525
--- /dev/null
+++ b/swift-java-ui-showcase/showcase-lib/gradle.properties
@@ -0,0 +1,4 @@
+# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
+
+org.gradle.configuration-cache=true
+