@rdlabo/capacitor-native-ui 0.0.0-stage → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +23 -0
  3. package/README.md +53 -2
  4. package/RdlaboCapacitorNativeUi.podspec +17 -0
  5. package/android/build.gradle +69 -0
  6. package/android/proguard-rules.pro +0 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
  9. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
  10. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
  11. package/dist/_virtual/_rolldown/runtime.js +13 -0
  12. package/dist/core.d.ts +4 -0
  13. package/dist/core.js +3 -0
  14. package/dist/native/activation.d.ts +14 -0
  15. package/dist/native/activation.d.ts.map +1 -0
  16. package/dist/native/activation.js +305 -0
  17. package/dist/native/activation.js.map +1 -0
  18. package/dist/native/components/index.d.ts +19 -0
  19. package/dist/native/components/index.d.ts.map +1 -0
  20. package/dist/native/components/index.js +38 -0
  21. package/dist/native/components/index.js.map +1 -0
  22. package/dist/native/components/ion-back-button.d.ts +10 -0
  23. package/dist/native/components/ion-back-button.d.ts.map +1 -0
  24. package/dist/native/components/ion-back-button.js +21 -0
  25. package/dist/native/components/ion-back-button.js.map +1 -0
  26. package/dist/native/components/ion-button.d.ts +10 -0
  27. package/dist/native/components/ion-button.d.ts.map +1 -0
  28. package/dist/native/components/ion-button.js +25 -0
  29. package/dist/native/components/ion-button.js.map +1 -0
  30. package/dist/native/components/ion-buttons.d.ts +11 -0
  31. package/dist/native/components/ion-buttons.d.ts.map +1 -0
  32. package/dist/native/components/ion-buttons.js +30 -0
  33. package/dist/native/components/ion-buttons.js.map +1 -0
  34. package/dist/native/components/ion-fab.d.ts +10 -0
  35. package/dist/native/components/ion-fab.d.ts.map +1 -0
  36. package/dist/native/components/ion-fab.js +132 -0
  37. package/dist/native/components/ion-fab.js.map +1 -0
  38. package/dist/native/components/ion-menu-button.d.ts +13 -0
  39. package/dist/native/components/ion-menu-button.d.ts.map +1 -0
  40. package/dist/native/components/ion-menu-button.js +44 -0
  41. package/dist/native/components/ion-menu-button.js.map +1 -0
  42. package/dist/native/components/ion-segment.d.ts +11 -0
  43. package/dist/native/components/ion-segment.d.ts.map +1 -0
  44. package/dist/native/components/ion-segment.js +30 -0
  45. package/dist/native/components/ion-segment.js.map +1 -0
  46. package/dist/native/components/ion-tab-bar.d.ts +11 -0
  47. package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
  48. package/dist/native/components/ion-tab-bar.js +50 -0
  49. package/dist/native/components/ion-tab-bar.js.map +1 -0
  50. package/dist/native/components/searchable-tabs.d.ts +20 -0
  51. package/dist/native/components/searchable-tabs.d.ts.map +1 -0
  52. package/dist/native/components/searchable-tabs.js +413 -0
  53. package/dist/native/components/searchable-tabs.js.map +1 -0
  54. package/dist/native/definitions.d.ts +255 -0
  55. package/dist/native/definitions.d.ts.map +1 -0
  56. package/dist/native/definitions.js +0 -0
  57. package/dist/native/extensions.d.ts +31 -0
  58. package/dist/native/extensions.d.ts.map +1 -0
  59. package/dist/native/extensions.js +122 -0
  60. package/dist/native/extensions.js.map +1 -0
  61. package/dist/native/foldable/fold-state.d.ts +5 -0
  62. package/dist/native/foldable/fold-state.d.ts.map +1 -0
  63. package/dist/native/foldable/fold-state.js +15 -0
  64. package/dist/native/foldable/fold-state.js.map +1 -0
  65. package/dist/native/foldable/types.d.ts +12 -0
  66. package/dist/native/foldable/types.d.ts.map +1 -0
  67. package/dist/native/foldable/types.js +0 -0
  68. package/dist/native/html.d.ts +9 -0
  69. package/dist/native/html.d.ts.map +1 -0
  70. package/dist/native/html.js +188 -0
  71. package/dist/native/html.js.map +1 -0
  72. package/dist/native/index.d.ts +10 -0
  73. package/dist/native/index.d.ts.map +1 -0
  74. package/dist/native/index.js +28 -0
  75. package/dist/native/index.js.map +1 -0
  76. package/dist/native/legacy-layout.d.ts +5 -0
  77. package/dist/native/legacy-layout.d.ts.map +1 -0
  78. package/dist/native/legacy-layout.js +27 -0
  79. package/dist/native/legacy-layout.js.map +1 -0
  80. package/dist/native/overlays/content.d.ts +4 -0
  81. package/dist/native/overlays/content.d.ts.map +1 -0
  82. package/dist/native/overlays/content.js +51 -0
  83. package/dist/native/overlays/content.js.map +1 -0
  84. package/dist/native/overlays/controller.d.ts +8 -0
  85. package/dist/native/overlays/controller.d.ts.map +1 -0
  86. package/dist/native/overlays/controller.js +285 -0
  87. package/dist/native/overlays/controller.js.map +1 -0
  88. package/dist/native/overlays/dialog-animation.d.ts +9 -0
  89. package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
  90. package/dist/native/overlays/dialog-animation.js +53 -0
  91. package/dist/native/overlays/dialog-animation.js.map +1 -0
  92. package/dist/native/overlays/dismiss-animation.d.ts +4 -0
  93. package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
  94. package/dist/native/overlays/dismiss-animation.js +18 -0
  95. package/dist/native/overlays/dismiss-animation.js.map +1 -0
  96. package/dist/native/overlays/focus.d.ts +5 -0
  97. package/dist/native/overlays/focus.d.ts.map +1 -0
  98. package/dist/native/overlays/focus.js +59 -0
  99. package/dist/native/overlays/focus.js.map +1 -0
  100. package/dist/native/overlays/popover.d.ts +12 -0
  101. package/dist/native/overlays/popover.d.ts.map +1 -0
  102. package/dist/native/overlays/popover.js +110 -0
  103. package/dist/native/overlays/popover.js.map +1 -0
  104. package/dist/native/overlays/styles.d.ts +11 -0
  105. package/dist/native/overlays/styles.d.ts.map +1 -0
  106. package/dist/native/overlays/styles.js +167 -0
  107. package/dist/native/overlays/styles.js.map +1 -0
  108. package/dist/native/prehide.d.ts +8 -0
  109. package/dist/native/prehide.d.ts.map +1 -0
  110. package/dist/native/prehide.js +238 -0
  111. package/dist/native/prehide.js.map +1 -0
  112. package/dist/native/runtime.d.ts +7 -0
  113. package/dist/native/runtime.d.ts.map +1 -0
  114. package/dist/native/runtime.js +788 -0
  115. package/dist/native/runtime.js.map +1 -0
  116. package/dist/native/shared/bridge.d.ts +6 -0
  117. package/dist/native/shared/bridge.d.ts.map +1 -0
  118. package/dist/native/shared/bridge.js +18 -0
  119. package/dist/native/shared/bridge.js.map +1 -0
  120. package/dist/native/shared/candidate.d.ts +25 -0
  121. package/dist/native/shared/candidate.d.ts.map +1 -0
  122. package/dist/native/shared/candidate.js +106 -0
  123. package/dist/native/shared/candidate.js.map +1 -0
  124. package/dist/native/shared/clipping.d.ts +5 -0
  125. package/dist/native/shared/clipping.d.ts.map +1 -0
  126. package/dist/native/shared/clipping.js +26 -0
  127. package/dist/native/shared/clipping.js.map +1 -0
  128. package/dist/native/shared/crossfade.d.ts +12 -0
  129. package/dist/native/shared/crossfade.d.ts.map +1 -0
  130. package/dist/native/shared/crossfade.js +67 -0
  131. package/dist/native/shared/crossfade.js.map +1 -0
  132. package/dist/native/shared/diagnostics.d.ts +4 -0
  133. package/dist/native/shared/diagnostics.d.ts.map +1 -0
  134. package/dist/native/shared/diagnostics.js +18 -0
  135. package/dist/native/shared/diagnostics.js.map +1 -0
  136. package/dist/native/shared/dom.d.ts +46 -0
  137. package/dist/native/shared/dom.d.ts.map +1 -0
  138. package/dist/native/shared/dom.js +180 -0
  139. package/dist/native/shared/dom.js.map +1 -0
  140. package/dist/native/shared/events.d.ts +15 -0
  141. package/dist/native/shared/events.d.ts.map +1 -0
  142. package/dist/native/shared/events.js +17 -0
  143. package/dist/native/shared/events.js.map +1 -0
  144. package/dist/native/shared/icons.d.ts +8 -0
  145. package/dist/native/shared/icons.d.ts.map +1 -0
  146. package/dist/native/shared/icons.js +61 -0
  147. package/dist/native/shared/icons.js.map +1 -0
  148. package/dist/native/shared/modal.d.ts +10 -0
  149. package/dist/native/shared/modal.d.ts.map +1 -0
  150. package/dist/native/shared/modal.js +160 -0
  151. package/dist/native/shared/modal.js.map +1 -0
  152. package/dist/native/shared/projection-id.d.ts +4 -0
  153. package/dist/native/shared/projection-id.d.ts.map +1 -0
  154. package/dist/native/shared/projection-id.js +7 -0
  155. package/dist/native/shared/projection-id.js.map +1 -0
  156. package/dist/native/shared/toolbar-options.d.ts +9 -0
  157. package/dist/native/shared/toolbar-options.d.ts.map +1 -0
  158. package/dist/native/shared/toolbar-options.js +36 -0
  159. package/dist/native/shared/toolbar-options.js.map +1 -0
  160. package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
  161. package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
  162. package/dist/native/shared/vertical-bars-geometry.js +85 -0
  163. package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
  164. package/dist/native/startup.d.ts +8 -0
  165. package/dist/native/startup.d.ts.map +1 -0
  166. package/dist/native/startup.js +86 -0
  167. package/dist/native/startup.js.map +1 -0
  168. package/dist/native/vertical-bars-web-search.d.ts +10 -0
  169. package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
  170. package/dist/native/vertical-bars-web-search.js +90 -0
  171. package/dist/native/vertical-bars-web-search.js.map +1 -0
  172. package/dist/native/vertical-bars-web.d.ts +5 -0
  173. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  174. package/dist/native/vertical-bars-web.js +421 -0
  175. package/dist/native/vertical-bars-web.js.map +1 -0
  176. package/dist/native-integration/index.d.ts +25 -0
  177. package/dist/native-integration/index.d.ts.map +1 -0
  178. package/dist/native-integration/index.js +33 -0
  179. package/dist/native-integration/index.js.map +1 -0
  180. package/dist/vertical-bars.d.ts +6 -0
  181. package/dist/vertical-bars.js +5 -0
  182. package/docs/configuration.md +187 -0
  183. package/docs/framework-independent.md +82 -0
  184. package/docs/migration.md +206 -0
  185. package/docs/native-mini-player.md +47 -0
  186. package/docs/native-ui-extensions.md +365 -0
  187. package/docs/native-ui.md +409 -0
  188. package/docs/setup.md +205 -0
  189. package/docs/vertical-bars.md +379 -0
  190. package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
  191. package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
  192. package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
  193. package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
  194. package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
  195. package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
  196. package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
  197. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
  198. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
  199. package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
  200. package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
  201. package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
  202. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
  203. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
  204. package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
  205. package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
  206. package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
  207. package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
  208. package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
  209. package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
  210. package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
  211. package/package.json +89 -4
  212. package/src/core.ts +15 -0
  213. package/src/native/activation.ts +394 -0
  214. package/src/native/components/index.ts +45 -0
  215. package/src/native/components/ion-back-button.ts +22 -0
  216. package/src/native/components/ion-button.ts +25 -0
  217. package/src/native/components/ion-buttons.ts +37 -0
  218. package/src/native/components/ion-fab.ts +151 -0
  219. package/src/native/components/ion-menu-button.ts +37 -0
  220. package/src/native/components/ion-segment.ts +27 -0
  221. package/src/native/components/ion-tab-bar.ts +50 -0
  222. package/src/native/components/searchable-tabs.ts +499 -0
  223. package/src/native/definitions.ts +324 -0
  224. package/src/native/extensions.ts +176 -0
  225. package/src/native/foldable/fold-state.ts +10 -0
  226. package/src/native/foldable/types.ts +4 -0
  227. package/src/native/html.ts +193 -0
  228. package/src/native/index.ts +41 -0
  229. package/src/native/legacy-layout.ts +21 -0
  230. package/src/native/overlays/content.ts +51 -0
  231. package/src/native/overlays/controller.ts +349 -0
  232. package/src/native/overlays/dialog-animation.ts +53 -0
  233. package/src/native/overlays/dismiss-animation.ts +15 -0
  234. package/src/native/overlays/focus.ts +64 -0
  235. package/src/native/overlays/popover.ts +120 -0
  236. package/src/native/overlays/styles.ts +181 -0
  237. package/src/native/prehide.ts +285 -0
  238. package/src/native/runtime.ts +988 -0
  239. package/src/native/shared/bridge.ts +18 -0
  240. package/src/native/shared/candidate.ts +162 -0
  241. package/src/native/shared/clipping.ts +24 -0
  242. package/src/native/shared/crossfade.ts +67 -0
  243. package/src/native/shared/diagnostics.ts +13 -0
  244. package/src/native/shared/dom.ts +304 -0
  245. package/src/native/shared/events.ts +14 -0
  246. package/src/native/shared/icons.ts +57 -0
  247. package/src/native/shared/modal.ts +157 -0
  248. package/src/native/shared/projection-id.ts +2 -0
  249. package/src/native/shared/toolbar-options.ts +53 -0
  250. package/src/native/shared/vertical-bars-geometry.ts +80 -0
  251. package/src/native/startup.ts +84 -0
  252. package/src/native/vertical-bars-web-search.ts +86 -0
  253. package/src/native/vertical-bars-web.ts +498 -0
  254. package/src/native-integration/index.ts +49 -0
  255. package/src/vertical-bars.ts +11 -0
@@ -0,0 +1,365 @@
1
+ ---
2
+ title: App-local Native UI extensions (experimental)
3
+ ---
4
+
5
+ # App-local Native UI extensions (experimental)
6
+
7
+ > **Experimental:** The extension API, native registration interfaces and behavior may change in future releases. This applies to both SwiftUI and Kotlin/Compose extensions, including `element` and `tab-accessory` placement.
8
+
9
+ > This guide targets the upcoming package split. Until the first native package release, use the [candidate archive installation](../README.md#try-the-unreleased-packages) in place of registry install commands.
10
+
11
+ An extension projects your existing Web UI into a SwiftUI View on iOS or Jetpack Compose content on Android. Keep both definitions in your application: no npm package, separate library, or Capacitor plugin is required. The Web element remains the source of truth and the fallback UI.
12
+
13
+ ## Before you start
14
+
15
+ Start with an existing Capacitor 8 app and install the plugin if you have not already:
16
+
17
+ ```sh
18
+ npm install @rdlabo/capacitor-native-ui
19
+ ```
20
+
21
+ Choose the native platform you want to support:
22
+
23
+ - **Android:** use your Capacitor Android project and an API 24+ device or emulator. Follow step 1, then the [Kotlin/Compose registration](#2b-register-compose-content-on-android), and steps 3–4. No iOS project, Xcode, Ionic, or theme package is required.
24
+ - **iOS:** use your Capacitor iOS project, Xcode 26+, and an iOS 26+ device or simulator. Follow step 1, the [SwiftUI registration](#2a-register-a-swiftui-view-on-ios), and steps 3–4. To also project standard controls, follow [Ionic setup](setup.md) or [HTML setup](framework-independent.md).
25
+ - **Both:** share the DOM reader from step 1 and register native content for the same name on each platform.
26
+
27
+ The TypeScript examples below use the Ionic entry. Without Ionic, import both `defineNativeUI` and `enableNativeUI` from `@rdlabo/capacitor-native-ui/core`.
28
+
29
+ ## 1. Define a DOM reader
30
+
31
+ ```ts
32
+ // src/native-ui/counter.ts
33
+ import { defineNativeUI } from '@rdlabo/capacitor-native-ui';
34
+
35
+ export const counter = defineNativeUI({
36
+ name: 'app.counter',
37
+ selector: '[data-counter]',
38
+ read(element) {
39
+ return {
40
+ props: { count: Number(element.getAttribute('data-count') ?? 0) },
41
+ actions: { increment: element.querySelector<HTMLButtonElement>('button') },
42
+ };
43
+ },
44
+ });
45
+ ```
46
+
47
+ For example, use this Web source. Its reserved space also accommodates native content and an action error:
48
+
49
+ ```html
50
+ <div data-counter data-count="0" style="width: 280px; min-height: 128px">
51
+ <p data-count-label>Count: 0</p>
52
+ <button type="button">Increment</button>
53
+ </div>
54
+ ```
55
+
56
+ Keep your framework's click handler updating the count and `data-count`. In plain HTML, this is enough:
57
+
58
+ ```ts
59
+ const source = document.querySelector<HTMLElement>('[data-counter]')!;
60
+ source.querySelector('button')!.addEventListener('click', () => {
61
+ const count = Number(source.dataset.count ?? 0) + 1;
62
+ source.dataset.count = String(count);
63
+ source.querySelector('[data-count-label]')!.textContent = `Count: ${count}`;
64
+ });
65
+ ```
66
+ The shell observes DOM changes and sends the new props to the native content. State rendered into DOM attributes or text is observed automatically. For properties or external state read by the reader that do not mutate the DOM, call `shell.markForCheck()` after changing them.
67
+
68
+ The reader is synchronous and read-only. Return `null` when the source should stay on the Web. Props are a JSON object (finite numbers, strings, booleans, null, arrays and objects), limited to 64K characters. Action names map to the source itself or its descendants; missing, hidden and disabled targets cannot be activated. Up to 30 actions are supported per source.
69
+
70
+ ## 2a. Register a SwiftUI View on iOS
71
+
72
+ Run `npx cap sync ios` after installing the plugin, then add a Swift file to the application's Xcode target. Import `NativeUIPlugin`, the module supplied by `@rdlabo/capacitor-native-ui`. Keep your existing SPM or CocoaPods integration.
73
+
74
+ ```swift
75
+ import SwiftUI
76
+ import NativeUIPlugin
77
+
78
+ struct CounterProps: Decodable {
79
+ let count: Int
80
+ }
81
+
82
+ struct CounterView: View {
83
+ let props: CounterProps
84
+ let actions: NativeUIActions
85
+ @State private var error: String?
86
+
87
+ var body: some View {
88
+ HStack {
89
+ Text("Count: \(props.count)")
90
+ Button("Increment") {
91
+ Task { @MainActor in
92
+ do {
93
+ try await actions.activate("increment")
94
+ error = nil
95
+ } catch {
96
+ self.error = "Action unavailable"
97
+ }
98
+ }
99
+ }
100
+ .disabled(!actions.isAvailable("increment"))
101
+ if let error { Text(error) }
102
+ }
103
+ }
104
+ }
105
+
106
+ @MainActor func registerAppNativeUI() throws {
107
+ try NativeUI.register("app.counter") {
108
+ (props: CounterProps, actions: NativeUIActions) in
109
+ CounterView(props: props, actions: actions)
110
+ }
111
+ }
112
+ ```
113
+
114
+ Call `registerAppNativeUI()` once from `application(_:didFinishLaunchingWithOptions:)`, before JavaScript enables the shell. Handle a registration error there; duplicate or empty names are rejected. Swift source is compiled into the app, so adding or changing a View requires an iOS rebuild. Registration is synchronous setup, not a native bridge command.
115
+
116
+ ## 2b. Register Compose content on Android
117
+
118
+ Android supports **element extensions only**. Standard Ionic and HTML controls stay on the Web, including explicitly marked buttons. Native Vertical Bars and `tab-accessory` are iOS features; Android can combine a Web side rail with element extensions. The same DOM reader, props, action targets, and `markForCheck()` work on both platforms.
119
+
120
+ Run `npx cap sync android` after installing the plugin. Use Kotlin in your Android app. In the root `build.gradle`, add the Kotlin and Compose compiler plugins to `buildscript.dependencies`, using the same Kotlin version as the app (the plugin defaults to `2.2.20`). Define `ext.kotlin_version = '2.2.20'` in the root `buildscript` if it is not already defined:
121
+
122
+ ```groovy
123
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
124
+ classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version"
125
+ ```
126
+
127
+ In `app/build.gradle`, apply `kotlin-android` and `org.jetbrains.kotlin.plugin.compose`, and enable `android { buildFeatures { compose true } }`. The Native UI dependency exposes Compose UI and Foundation; add Material dependencies yourself if your extension uses them.
128
+
129
+ Register once from your `Application.onCreate()`, on the main thread, before JavaScript enables the shell. Use an `Application` subclass configured with `android:name=".App"` on `<application>` in `AndroidManifest.xml`; avoid registering on each Activity recreation.
130
+
131
+ ```kotlin
132
+ import android.app.Application
133
+ import androidx.compose.foundation.background
134
+ import androidx.compose.foundation.clickable
135
+ import androidx.compose.foundation.layout.*
136
+ import androidx.compose.foundation.text.BasicText
137
+ import androidx.compose.runtime.*
138
+ import androidx.compose.ui.Alignment
139
+ import androidx.compose.ui.Modifier
140
+ import androidx.compose.ui.draw.alpha
141
+ import androidx.compose.ui.graphics.Color
142
+ import androidx.compose.ui.semantics.*
143
+ import androidx.compose.ui.text.TextStyle
144
+ import androidx.compose.ui.unit.dp
145
+ import dev.rdlabo.capacitor.nativeui.NativeUI
146
+ import kotlinx.coroutines.CancellationException
147
+ import kotlinx.coroutines.launch
148
+
149
+ class App : Application() {
150
+ override fun onCreate() {
151
+ super.onCreate()
152
+ NativeUI.register(
153
+ name = "app.counter",
154
+ decode = { json -> json.getInt("count") },
155
+ ) { count, actions, context ->
156
+ val scope = rememberCoroutineScope()
157
+ var error by remember { mutableStateOf<String?>(null) }
158
+ val enabled = actions.isAvailable("increment")
159
+ val foreground = if (context.dark) Color.White else Color.Black
160
+ val background = if (context.dark) Color(0xFF202020) else Color.White
161
+ val textStyle = TextStyle(color = foreground)
162
+ Column(Modifier.fillMaxSize().background(background).padding(8.dp)) {
163
+ BasicText("Count: $count", style = textStyle)
164
+ Box(
165
+ Modifier.fillMaxWidth().heightIn(min = 48.dp)
166
+ .alpha(if (enabled) 1f else 0.5f)
167
+ .clickable(enabled = enabled, role = Role.Button, onClickLabel = "Increment count") {
168
+ scope.launch {
169
+ try {
170
+ actions.activate("increment")
171
+ error = null
172
+ } catch (cancelled: CancellationException) {
173
+ throw cancelled
174
+ } catch (unavailable: IllegalStateException) {
175
+ error = "Action unavailable. Try again."
176
+ }
177
+ }
178
+ },
179
+ contentAlignment = Alignment.Center,
180
+ ) { BasicText("Increment", style = textStyle) }
181
+ BasicText(
182
+ error ?: "",
183
+ modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
184
+ style = textStyle,
185
+ )
186
+ }
187
+ }
188
+ }
189
+ }
190
+ ```
191
+
192
+ The shell sizes and positions the Compose host from the source DOM rectangle. `context.sourceFrame` contains CSS pixels; `sourceFrameInDp` contains Compose dp, with `dpPerCSSPixel` as the conversion factor. `sourceID` and `viewportWidth` describe the current source and viewport. `rtl` is also applied through `LocalLayoutDirection`; use `dark` to select your app's Compose theme. Do not apply the source's `x` and `y` again inside your content.
193
+
194
+ The `decode` callback runs before accepting the projection. If it throws, that source stays on the Web. Empty or duplicate registration names are rejected. Compose content owns its styling and accessibility. Rebuild the Android app after changing Kotlin code.
195
+
196
+ ## 3. Enable with local definitions
197
+
198
+ ```ts
199
+ import { enableNativeUI } from '@rdlabo/capacitor-native-ui';
200
+ import { counter } from './native-ui/counter';
201
+
202
+ const shell = await enableNativeUI({ ionic: { auto: false }, extensions: [counter] });
203
+ ```
204
+
205
+ This example enables your extensions without automatically projecting Ionic controls. Explicit `data-native-ui` sources remain opted in. To also project standard Ionic controls on supported iOS, omit `ionic: { auto: false }`. Android standard controls remain Web-rendered either way.
206
+
207
+ Replace your existing startup call with the one above; do not start a second shell. Connect it to your framework using the [app-root lifecycle examples](setup.md#place-startup-in-your-frameworks-app-root), putting these options in the shared startup call.
208
+
209
+ Keep extension objects stable across repeated enable calls. Changing extensions while running requires `await shell.destroy()` first. Extension names must be unique within an activation. Do not register overlapping selectors for the same source. An extension owns its source subtree, so built-in controls inside it are not projected separately.
210
+
211
+ ## 4. Build and try the extension
212
+
213
+ Build your Web assets, then sync and rebuild the platform you configured:
214
+
215
+ - **Android:** run `npx cap sync android`, then build and launch the app from Android Studio. The counter should render with Compose.
216
+ - **iOS:** run `npx cap sync ios`, then build and launch the app from Xcode. On iOS 26+, the counter should render with SwiftUI.
217
+
218
+ Tap Increment: the original Web handler should update `data-count`, and the native count should follow. In a browser, the original Web counter should still work.
219
+
220
+ For source-specific fallback reasons, temporarily enable [`debug: true`](configuration.md#understand-why-a-source-stays-on-the-web). If the source stays on the Web, confirm that registration runs before JavaScript enables the shell and that the names and props agree on both sides. On Android, check that your `Application` subclass is declared in the manifest; on iOS, check that the Swift file belongs to the app target.
221
+
222
+ ## Content and placement are independent
223
+
224
+ | `placement` | Behavior |
225
+ | --- | --- |
226
+ | Omitted or `'element'` | Hosts native content in the source element's Web frame on iOS 26+ or Android API 24+. |
227
+ | `'tab-accessory'` | Attaches the View to a projected tab bar's system SwiftUI tab accessory on iOS 26.1+. |
228
+
229
+ For `tab-accessory`, place the source inside its owning `ion-tabs`. The shell resolves that tabs element's `ion-tab-bar`; applications do not pass native controller references. Currently only the **Vertical Bars SwiftUI tab host** supports this placement. Ordinary UIKit tab bars, missing native tabs, iOS 26.0, or multiple competing accessories keep the source on the Web. The shell does not approximate a system accessory with a floating overlay. Keep an equivalent Web layout (for example, a fixed footer); the source retains its layout space while hidden.
230
+
231
+ `enableVerticalControlArea({ extensions: [...] })` also accepts extensions. In this standalone mode only accessories belonging to native Vertical Bars are eligible; ordinary element projections require `enableNativeUI`.
232
+
233
+ ## Read source geometry and presentation state
234
+
235
+ The shell measures the source DOM element automatically. With the default `element` placement, it positions and sizes the SwiftUI host to match that rectangle. You do not need to send coordinates in `props` or position the native view yourself.
236
+
237
+ Use the three-argument registration closure to receive that information:
238
+
239
+ ```swift
240
+ try NativeUI.register("app.counter") {
241
+ (props: CounterProps, actions: NativeUIActions, context: NativeUIContext) in
242
+ CounterView(props: props, actions: actions)
243
+ .padding(.horizontal, context.sourceFrame.width < 240 ? 4 : 12)
244
+ }
245
+ ```
246
+
247
+ The existing `(props, actions)` closure remains supported. Both forms use the same registration names and duplicate-name checks.
248
+
249
+ | Context property | Meaning |
250
+ | --- | --- |
251
+ | `sourceID` | Opaque source identity for its current lifetime; not the HTML `id`. |
252
+ | `sourceFrame` | DOM `getBoundingClientRect()` in CSS pixels relative to the Web viewport, including `x`, `y`, `width`, and `height`. |
253
+ | `sourceFrameInWebView` | The source rectangle converted to UIKit points in the WebView's local coordinate system; not screen coordinates. |
254
+ | `viewportWidth` | CSS viewport width used for that snapshot's coordinate conversion. |
255
+ | `pointsPerCSSPixel` | UIKit points per CSS pixel; not device pixel density. |
256
+ | `placement` | `.element` or `.tabAccessory`. |
257
+ | `colorScheme` | `.light` or `.dark`, also applied to the SwiftUI environment. |
258
+ | `layoutDirection` | `.leftToRight` or `.rightToLeft`, also applied to the SwiftUI environment. |
259
+
260
+ The closure receives a fresh context when the shell applies an updated snapshot, including source movement, resizing, and appearance changes. Use the current context rather than caching its geometry in `@State`. Updates cross the Web/native bridge; they are not a synchronous DOM measurement from Swift.
261
+
262
+ For `tabAccessory`, `sourceFrame` still describes the original DOM element. The system assigns the accessory's actual location and size. Use SwiftUI's `GeometryReader` inside your View for the actual local space in either placement:
263
+
264
+ ```swift
265
+ GeometryReader { geometry in
266
+ CounterView(props: props, actions: actions)
267
+ .frame(width: geometry.size.width, height: geometry.size.height)
268
+ }
269
+ ```
270
+
271
+ Do not apply `sourceFrame.x` or `y` as another SwiftUI offset: the shell already positions the host for `element`. Your View lays out its contents within that host.
272
+
273
+ ## Use the shared projection capabilities
274
+
275
+ | Capability | How an extension uses it |
276
+ | --- | --- |
277
+ | Position and size | Automatic host placement plus `NativeUIContext`; actual local bounds through `GeometryReader`. |
278
+ | Dark mode and RTL | `context` or SwiftUI `@Environment(\.colorScheme)` and `@Environment(\.layoutDirection)`. |
279
+ | Reduce Motion and accessibility | SwiftUI environment values such as `@Environment(\.accessibilityReduceMotion)` and native accessibility modifiers. Give custom controls accessible names. |
280
+ | DOM-backed application state | Return JSON `props` from `read(element)`; DOM changes trigger synchronization. For changes without a DOM mutation, call `shell.markForCheck()`. |
281
+ | Enabled/visible action targets | `actions.availableNames` or `actions.isAvailable("increment")`; unavailable DOM targets are omitted. |
282
+ | Invoke the original Web action | `try await actions.activate("increment")`; completion and failure follow the contract below. |
283
+ | Temporary Web fallback and teardown | Managed automatically for unsupported content, overlays, navigation suspension, and source removal. |
284
+ | Application-controlled suspension | Use the shell handle's `suspend()`, the returned token's `resume()`, and `destroy()` from JavaScript. |
285
+
286
+ For example, disable a native button when its original action is unavailable:
287
+
288
+ ```swift
289
+ Button("Increment") {
290
+ Task { @MainActor in
291
+ do { try await actions.activate("increment") }
292
+ catch { /* Present an application-appropriate error. */ }
293
+ }
294
+ }
295
+ .disabled(!actions.isAvailable("increment"))
296
+ ```
297
+
298
+ Availability is a snapshot, not a reservation: a target can disappear before activation, so handle errors too. Custom text, selected state, fonts, colors, and badges belong in your extension's `props` and SwiftUI View; the shell does not infer a custom component's visual structure from CSS. Read the desired element's computed styles and attributes in your reader. Use the shared image and color conversion APIs below instead of reimplementing those transformations.
299
+
300
+ ## Schedule a source check
301
+
302
+ DOM attributes, text, children, observed element sizes, scrolling, and supported lifecycle events trigger automatic checks. The shell does not poll JavaScript variables or arbitrary DOM properties. After changing state that your reader uses without a DOM mutation, request a check:
303
+
304
+ ```ts
305
+ source.playbackPosition = newPosition; // An application-defined property read by your extension.
306
+ shell.markForCheck();
307
+ ```
308
+
309
+ `markForCheck(): void` schedules a re-read and geometry measurement; it does not update the application's DOM or force a native redraw. Repeated calls are batched, and unchanged snapshots are not sent. A call during a bridge update schedules another check afterwards. Suspended projection stays suspended until resumed. Calls after `destroy()` or in an environment without a projection runtime do nothing.
310
+
311
+ Call after your framework has committed any relevant DOM changes. This API does not trigger Angular change detection, React rendering, or Vue rendering, and it is not a promise that waits for native completion. It also works for standard controls when their properties change without an observable mutation.
312
+
313
+ ## Reuse native artwork and color conversion
314
+
315
+ A reader's second argument exposes the standard SVG capture and rasterization path. It accepts a local `svg` or a resolved `ion-icon` belonging to the source and returns an **opaque image reference**, or `null` for unsupported artwork. Return the reference in `props`; do not try to read image bytes synchronously:
316
+
317
+ ```ts
318
+ const badge = defineNativeUI({
319
+ name: 'app.badge',
320
+ selector: '[data-badge]',
321
+ read(element, context) {
322
+ return {
323
+ props: {
324
+ icon: context.image(element.querySelector<HTMLElement>('ion-icon')),
325
+ color: getComputedStyle(element).color,
326
+ },
327
+ };
328
+ },
329
+ });
330
+ ```
331
+
332
+ Before sending the snapshot, the shell resolves references into `{ data, width, height, template }`: base64 PNG data, CSS dimensions, and rendering mode. Nested references and copies are supported; the `$nativeUIImage` key is reserved for references returned by the current read context. Use `{ template: true }` as the second argument to `image()` when SwiftUI should tint the icon; the default preserves source colors. The same local SVG restrictions, RTL capture, display-scale rasterization, and image cache used by standard controls apply. External resources and unsupported SVG content return `null`; unresolved `ion-icon` content should retain your Web fallback until loaded. Rasterization failure or props exceeding the size limit keeps that extension on the Web.
333
+
334
+ On iOS, decode the resulting value with `NativeUIImage` and use the shared converters:
335
+
336
+ ```swift
337
+ struct BadgeProps: Decodable {
338
+ let icon: NativeUIImage?
339
+ let color: String
340
+ }
341
+
342
+ struct BadgeView: View {
343
+ let props: BadgeProps
344
+ var body: some View {
345
+ if let icon = props.icon, let image = NativeUIRendering.image(icon) {
346
+ Image(uiImage: image)
347
+ .foregroundStyle(Color(uiColor: NativeUIRendering.color(props.color)))
348
+ }
349
+ }
350
+ }
351
+ ```
352
+
353
+ `NativeUIRendering.image` returns `nil` for invalid image data. `NativeUIRendering.color` accepts computed `rgb()`/`rgba()` values; unsupported formats use `.label`, or the `fallback:` color you pass. Both are shared with the standard native renderers. They do not evaluate CSS selectors, variables, named colors, or arbitrary stylesheets.
354
+
355
+ ## Lifecycle and action completion
356
+
357
+ The shell owns discovery, geometry, state synchronization, native controller containment, overlay retirement, suspension and teardown. Extensions do not implement attach/detach hooks. Web sources are hidden only after native accepts the projection. An unregistered renderer, a failing props decoder, unsupported placement or failed reader leaves that instance on the Web. Existing shell overlay policy applies; extensions return to the Web while Web overlays cover the page.
358
+
359
+ Element placement requires the source to fit fully inside the viewport and its ancestors' overflow clipping bounds, including Ionic's shadow scroll containers. Partially clipped sources stay on the Web and become eligible again when scrolled fully into view. Tab accessories use the system host's placement instead.
360
+
361
+ `try await actions.activate(name)` completes after the original Web element receives its click. It does **not** wait for application work launched by the click handler (network requests, navigation or modal presentation). Disabled, removed or stale actions reject; teardown, native task cancellation and bridge timeouts settle pending calls. The bridge acknowledgement is a Capacitor Promise method.
362
+
363
+ The [demo reader](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/demo/src/native-ui/counter.ts) and [demo SwiftUI View](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/demo/ios/App/App/NativeUICounter.swift) show both placements using app-local files. Open the Native UI demo page to compare native projection with its Web fallback.
364
+
365
+ For a complete application feature, see the [SwiftUI mini-player example](native-mini-player.md): artwork, track metadata, playback controls and swipe-to-open, all defined in the demo application.