Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ Package.resolved

/packages/*/node_modules/
*.tgz

packages/capacitor-native-ui/android/build/
packages/capacitor-native-ui/android/.gradle/
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ www
demo/ios/App/App/public
demo/playwright-report
demo/test-results

packages/capacitor-native-ui/android/build/
packages/capacitor-native-ui/android/.gradle/
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Bring the iOS look to your Ionic app, or add native controls to your Capacitor a

**[@rdlabo/capacitor-native-ui](packages/capacitor-native-ui/README.md)** projects supported Ionic controls into UIKit and SwiftUI on iOS. Your existing markup supplies their labels, icons, and state; native actions flow back to your Ionic components. Page content and routing stay in the WebView.

Keep your existing Web appearance or pair it with the iOS theme. Native UI is opt-in and currently a preview; unsupported controls, Android, and browsers retain Web presentation. Your app or theme owns Web layout and styling.
Keep your existing Web appearance or pair it with the iOS theme. Native UI is opt-in and currently a preview; unsupported controls and browsers retain Web presentation. On Android, standard controls stay on the Web; app-local extensions can render with Jetpack Compose. Your app or theme owns Web layout and styling.

[Get started with Native UI](packages/capacitor-native-ui/docs/setup.md) · [Supported controls](packages/capacitor-native-ui/docs/native-ui-shell.md#supported-markup)

Expand All @@ -36,7 +36,7 @@ The same Library screen with Native UI Shell off and on, captured while dragging
| Render Ionic controls natively with your existing Web appearance | [Set up Native UI](packages/capacitor-native-ui/docs/setup.md) |
| Use native buttons without Ionic | [HTML setup](packages/capacitor-native-ui/docs/framework-independent.md) |
| Add only a vertical rail to an Ionic app | [Vertical Bars setup](packages/theme-ios27/docs/iphone-duo-with-original-theme.md) |
| Add custom SwiftUI content | [App-local extensions](packages/capacitor-native-ui/docs/native-ui-extensions.md) |
| Add custom SwiftUI or Compose content | [App-local extensions](packages/capacitor-native-ui/docs/native-ui-extensions.md) |

Already using the iOS theme? [Add Native UI to your theme](packages/theme-ios27/docs/add-native-ui.md) as your next step.

Expand Down
13 changes: 7 additions & 6 deletions demo/src/native-ui-html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ test('ionic.auto false preserves explicit ion-button rails, live toggles, and so
await vi.waitFor(() => expect(host.querySelectorAll('button')).toHaveLength(1));
});

test('disabled automatic controls keep explicit native buttons and extensions eligible', async () => {
test.each(['auto', 'extensions'] as const)('projection mode %s keeps only its eligible controls', async (source) => {
const { root, options } = mount(
'<ion-header><ion-toolbar><ion-button class="ios" data-native-ui="button">Explicit</ion-button><ion-button class="ios">Automatic</ion-button></ion-toolbar></ion-header><section data-counter>Counter</section>',
);
Expand Down Expand Up @@ -375,16 +375,17 @@ test('disabled automatic controls keep explicit native buttons and extensions el
} as unknown as NativeUIShellPlugin;
const runtime = await createRuntime(document, plugin, {
...options,
source: 'auto',
source,
explicitControls: true,
controls: { toolbar: false, tabs: false, segment: false, fab: false },
extensions: [{ name: 'counter', selector: '[data-counter]', read: () => ({ props: {} }) }],
});
handles.push(runtime);
await vi.waitFor(() => expect(runtime.getStatus().projected).toBe(2));
expect(snapshots.at(-1)!.controls.map((item) => item.kind)).toEqual(['button', 'extension']);
expect(snapshots.at(-1)!.controls[0].items[0].label).toBe('Explicit');
expect(root.querySelectorAll('[data-native-ui-shell]')).toHaveLength(2);
const expected = source === 'extensions' ? ['extension'] : ['button', 'extension'];
await vi.waitFor(() => expect(runtime.getStatus().projected).toBe(expected.length));
expect(snapshots.at(-1)!.controls.map((item) => item.kind)).toEqual(expected);
expect(root.querySelectorAll('[data-native-ui-shell]')).toHaveLength(expected.length);
if (source === 'extensions') expect(root.querySelector('ion-button')!.hasAttribute('data-native-ui-shell')).toBe(false);
});

test('explicit Ionic Web rails preserve shadow icons and observe artwork changes', async () => {
Expand Down
10 changes: 5 additions & 5 deletions packages/capacitor-native-ui/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Capacitor Native UI

Render supported Web controls with UIKit and SwiftUI on iOS while keeping your application state, routing, and handlers in the WebView. Use existing Ionic markup or explicit HTML buttons; custom SwiftUI content can be added through app-local extensions.
Render supported Web controls with UIKit and SwiftUI on iOS while keeping your application state, routing, and handlers in the WebView. Use existing Ionic markup or explicit HTML buttons; custom SwiftUI or Android Compose content can be added through app-local extensions.

Native UI is opt-in and currently a preview. Browsers, Android, older iOS, and unsupported controls retain Web presentation. Keep your application's styles and layout; the iOS theme is optional.
Native UI is opt-in and currently a preview. Standard controls on Android, browsers, older iOS, and unsupported controls retain Web presentation. Android supports app-local Compose extensions. Keep your application's styles and layout; the iOS theme is optional.

## Choose what to build

Expand All @@ -12,13 +12,13 @@ Native UI is opt-in and currently a preview. Browsers, Android, older iOS, and u
| Use native buttons without Ionic | [HTML setup](docs/framework-independent.md) |
| Project only tabs and toolbar actions into a side rail | [Vertical Bars step by step](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/packages/theme-ios27/docs/iphone-duo-with-original-theme.md) |
| Add native controls to an existing iOS 27 theme app | [Add Native UI to your theme](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/packages/theme-ios27/docs/add-native-ui.md) |
| Render custom SwiftUI content | [App-local extensions](docs/native-ui-extensions.md) |
| Render custom SwiftUI or Android Compose content | [App-local extensions](docs/native-ui-extensions.md) |

## Requirements

Native startup requires Capacitor 8 and an iOS project. Ordinary native rendering requires iOS 26+ and Xcode 26+. The Ionic adapter supports Ionic `>=8.8.1 <10`; the `/core` HTML adapter requires no Ionic or theme package. [Vertical Bars](docs/vertical-bars.md) and [extension placement](docs/native-ui-extensions.md#content-and-placement-are-independent) have additional device requirements.
Native startup requires Capacitor 8 and an iOS or Android project. Android extensions require API 24+ and Kotlin/Compose. Ordinary native rendering requires iOS 26+ and Xcode 26+. The Ionic adapter supports Ionic `>=8.8.1 <10`; the `/core` HTML adapter requires no Ionic or theme package. [Vertical Bars](docs/vertical-bars.md) and [extension placement](docs/native-ui-extensions.md#content-and-placement-are-independent) have additional device requirements.

Follow the setup guide for your markup before installing dependencies; the Ionic and HTML adapters use different imports.
For standard controls, follow the setup guide for your markup; the Ionic and HTML adapters use different imports. For custom native content, start directly with the [extensions guide](docs/native-ui-extensions.md).

## Reference and examples

Expand Down
12 changes: 12 additions & 0 deletions packages/capacitor-native-ui/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ ext {
}

buildscript {
ext.kotlin_version = project.hasProperty("kotlin_version") ? rootProject.ext.kotlin_version : '2.2.20'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version"
}
}

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'

android {
namespace = "dev.rdlabo.capacitor.nativeui"
Expand All @@ -36,6 +41,8 @@ android {
lintOptions {
abortOnError = false
}
buildFeatures { compose true }
kotlinOptions { jvmTarget = '21' }
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
Expand All @@ -48,10 +55,15 @@ repositories {
}

dependencies {
api platform("androidx.compose:compose-bom:2025.10.00")
api "androidx.compose.ui:ui"
api "androidx.compose.foundation:foundation"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
testImplementation "junit:junit:$junitVersion"
testImplementation "org.json:json:20250517"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package dev.rdlabo.capacitor.nativeui

import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.LayoutDirection
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt

@CapacitorPlugin(name = "IonicNativeUIShell")
class IonicNativeUIShellPlugin : Plugin() {
private val handler = Handler(Looper.getMainLooper())
private var host: FrameLayout? = null
private var revision = 0L
private var sequence = 0L
private var paused = false
private val entries = linkedMapOf<String, Entry>()
private val pending = mutableMapOf<String, (Boolean) -> Unit>()
private val layoutListener = View.OnLayoutChangeListener { _, l, t, r, b, oldL, oldT, oldR, oldB ->
if (l != oldL || t != oldT || r != oldR || b != oldB) {
host?.visibility = View.INVISIBLE
failActions()
notifyListeners("webViewMetricsChange", JSObject().put("radius", 0))
}
}

private class Entry(val view: ComposeView, var node: ExtensionNode) {
val content = mutableStateOf<(@Composable () -> Unit)?>(null)
}

override fun load() {
activity.runOnUiThread { bridge.webView.addOnLayoutChangeListener(layoutListener) }
}

@PluginMethod
fun configure(call: PluginCall) = onMain(call) {
clearViews()
revision = 0
sequence = 0
call.resolve(JSObject().put("supported", true).put("extensions", JSArray(NativeUIShell.renderers.keys.toList())))
}

@PluginMethod
fun getWebViewMetrics(call: PluginCall) = call.resolve(JSObject().put("radius", 0))

@PluginMethod
fun update(call: PluginCall) = onMain(call) {
val snapshot = ShellSnapshot.parse(call.data)
require(snapshot.revision > revision) { "Stale Native UI snapshot" }
val web = bridge.webView
val parent = web.parent as? ViewGroup ?: error("WebView has no host")
require(web.width > 0 && web.height > 0 && web.scaleX == 1f && web.scaleY == 1f) { "WebView geometry unavailable" }
val scale = web.width / snapshot.viewportWidth
val dpScale = scale / web.resources.displayMetrics.density
val accepted = linkedMapOf<String, Pair<ExtensionNode, @Composable () -> Unit>>()
val rejected = snapshot.rejected.toMutableList()
for (node in snapshot.nodes) {
val renderer = NativeUIShell.renderers[node.name]
if (renderer == null || paused) {
rejected.add(node.id)
continue
}
try {
val f = node.frame
require(listOf(f.x * scale, f.y * scale, f.width * scale, f.height * scale).all { it in -1_000_000.0..1_000_000.0 })
val context = NativeUIContext(node.id, f, NativeUIFrame(f.x * dpScale, f.y * dpScale, f.width * dpScale, f.height * dpScale), snapshot.viewportWidth, dpScale, node.dark, node.rtl)
val actions = NativeUIActions(node.actions.keys) { action -> activate(node, action) }
accepted[node.id] = node to renderer(JSONObject(node.props), actions, context)
} catch (_: Exception) {
rejected.add(node.id)
}
}
revision = snapshot.revision
val container = host ?: FrameLayout(activity).also {
it.clipChildren = true
it.clipToPadding = true
parent.addView(it, ViewGroup.LayoutParams(web.width, web.height))
host = it
}
container.layoutParams = container.layoutParams.apply { width = web.width; height = web.height }
container.x = web.x
container.y = web.y
for (id in entries.keys.toList()) if (id !in accepted) removeEntry(id)
for ((id, pair) in accepted) {
val (node, content) = pair
val entry = entries[id] ?: Entry(ComposeView(activity), node).also { entry ->
entry.view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
container.addView(entry.view)
entry.view.setContent { entry.content.value?.invoke() }
entries[id] = entry
}
entry.node = node
entry.content.value = {
CompositionLocalProvider(LocalLayoutDirection provides if (node.rtl) LayoutDirection.Rtl else LayoutDirection.Ltr) { content() }
}
entry.view.layoutParams = FrameLayout.LayoutParams((node.frame.width * scale).roundToInt().coerceAtLeast(1), (node.frame.height * scale).roundToInt().coerceAtLeast(1)).apply {
leftMargin = (node.frame.x * scale).roundToInt()
topMargin = (node.frame.y * scale).roundToInt()
}
}
container.visibility = if (paused) View.INVISIBLE else View.VISIBLE
call.resolve(JSObject().put("revision", revision).put("rejectedControls", JSArray(rejected)))
}

private suspend fun activate(node: ExtensionNode, name: String): Unit = withContext(Dispatchers.Main.immediate) {
check(!paused && host?.visibility == View.VISIBLE && entries[node.id]?.node == node) { "Native UI action is stale" }
val target = node.actions[name] ?: error("Native UI action is unavailable")
suspendCancellableCoroutine { continuation ->
val request = UUID.randomUUID().toString()
val timeout = Runnable { pending.remove(request)?.invoke(false) }
pending[request] = { accepted ->
handler.removeCallbacks(timeout)
if (continuation.isActive) {
if (accepted) continuation.resume(Unit)
else continuation.resumeWithException(IllegalStateException("Native UI action was not accepted"))
}
}
continuation.invokeOnCancellation { handler.post { pending.remove(request); handler.removeCallbacks(timeout) } }
handler.postDelayed(timeout, 5000)
notifyListeners("activate", JSObject().put("revision", revision).put("id", target).put("sequence", ++sequence).put("requestId", request))
}
}

@PluginMethod
fun completeExtensionAction(call: PluginCall) = onMain(call) {
val request = call.getString("requestId") ?: error("Missing requestId")
pending.remove(request)?.invoke(call.getBoolean("accepted", false) == true)
call.resolve()
}

@PluginMethod
fun clear(call: PluginCall) = onMain(call) {
val next = call.data.getLong("revision")
require(next > revision) { "Stale Native UI clear" }
revision = next
clearViews()
call.resolve()
}

private fun onMain(call: PluginCall, block: () -> Unit) {
activity.runOnUiThread {
try { block() } catch (error: Exception) { call.reject(error.message ?: "Native UI failed", error) }
}
}
private fun failActions() {
val callbacks = pending.values.toList()
pending.clear()
callbacks.forEach { it(false) }
}
private fun removeEntry(id: String) {
entries.remove(id)?.let { it.view.disposeComposition(); host?.removeView(it.view) }
}
private fun clearViews() {
failActions()
entries.keys.toList().forEach(::removeEntry)
(host?.parent as? ViewGroup)?.removeView(host)
host = null
}
override fun handleOnPause() {
paused = true
host?.visibility = View.INVISIBLE
failActions()
}
override fun handleOnResume() {
paused = false
notifyListeners("webViewMetricsChange", JSObject().put("radius", 0))
}
override fun handleOnDestroy() {
bridge.webView.removeOnLayoutChangeListener(layoutListener)
clearViews()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package dev.rdlabo.capacitor.nativeui

import android.os.Looper
import androidx.compose.runtime.Composable
import org.json.JSONObject
import java.util.Collections

/** App-local Compose renderers. Register on the main thread before starting the shell. */
object NativeUIShell {
internal val renderers = linkedMapOf<String, (JSONObject, NativeUIActions, NativeUIContext) -> (@Composable () -> Unit)>()

fun <Props> register(
name: String,
decode: (JSONObject) -> Props,
content: @Composable (Props, NativeUIActions, NativeUIContext) -> Unit,
) {
check(Looper.myLooper() == Looper.getMainLooper()) { "Register Native UI on the main thread" }
require(name.isNotBlank() && !renderers.containsKey(name)) { "Native UI names must be nonempty and unique" }
renderers[name] = { json, actions, context ->
val props = decode(json)
val view: @Composable () -> Unit = { content(props, actions, context) }
view
}
}
}

data class NativeUIFrame(val x: Double, val y: Double, val width: Double, val height: Double)

/** Source geometry, not screen coordinates. Android currently supports element placement only. */
data class NativeUIContext(
val sourceID: String,
val sourceFrame: NativeUIFrame,
val sourceFrameInDp: NativeUIFrame,
val viewportWidth: Double,
val dpPerCSSPixel: Double,
val dark: Boolean,
val rtl: Boolean,
)

/** Completion acknowledges the DOM click dispatch, not the application's asynchronous work. */
class NativeUIActions internal constructor(names: Set<String>, private val dispatch: suspend (String) -> Unit) {
val availableNames: Set<String> = Collections.unmodifiableSet(names.toSet())
fun isAvailable(name: String): Boolean = name in availableNames
suspend fun activate(name: String) {
check(isAvailable(name)) { "Native UI action is unavailable" }
dispatch(name)
}
}
Loading
Loading