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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
5 changes: 5 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
41 changes: 41 additions & 0 deletions swift-java-ui-showcase/.gitignore
Original file line number Diff line number Diff line change
@@ -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
156 changes: 156 additions & 0 deletions swift-java-ui-showcase/README.md
Original file line number Diff line number Diff line change
@@ -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) |
|---|---|---|
| ![Home screen](resources/home.png) | ![Buttons screen](resources/buttons.png) | ![Form validation errors](resources/form-validation.png) |

## 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.
Binary file added swift-java-ui-showcase/resources/buttons.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added swift-java-ui-showcase/resources/home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions swift-java-ui-showcase/showcase-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
75 changes: 75 additions & 0 deletions swift-java-ui-showcase/showcase-app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 21 additions & 0 deletions swift-java-ui-showcase/showcase-app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading