From d934b3714f320641cf11ec94691afa8e12ceef0c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 9 Nov 2026 18:38:10 +0900 Subject: [PATCH] docs: graduate dynamic colors to supported API --- README.md | 37 ++++----- docs/dynamic-colors.md | 166 +++++++++++++++++++++++++++++++++++++++++ src/dynamic-colors.ts | 16 ++-- 3 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 docs/dynamic-colors.md diff --git a/README.md b/README.md index 3283c1e..dbd50c5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Blocking Web overlays can keep prepared images of native buttons, segments, FABs | Add a side rail to Ionic while keeping your theme | [Vertical Bars step by step](https://docs.rdlabo.dev/projects/capacitor-native-ui/docs/ionic-vertical-bars) | | Add a side rail without Ionic | [HTML rail setup](https://docs.rdlabo.dev/projects/capacitor-native-ui/docs/framework-independent) | | Add native controls to an existing iOS 27 theme app | [Add Native UI to your theme](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/add-native-ui) | +| Read Android Material You colors | [Android dynamic colors](docs/dynamic-colors.md) | | Render custom SwiftUI or Android Compose content | [App-local extensions (experimental)](https://docs.rdlabo.dev/projects/capacitor-native-ui/docs/native-ui-extensions) | ## Install from npm @@ -126,29 +127,17 @@ Capacitor apps continue to use the `RdlaboCapacitorNativeUi` product and `import CocoaPods continues to compile the adapter and shared sources into the existing plugin pod. This change does not publish a standalone Core pod or implement a Cordova adapter. -## Android dynamic colors (experimental) - -> **Experimental:** The dynamic color API, returned color roles, and change -> notification behavior may change in future releases. - -The `@rdlabo/capacitor-native-ui` root entrypoint exports -`getDynamicColorScheme()` and `addDynamicColorListener(listener)`. These work -independently of Native UI projection. Import them from the package root; the -experimental `/dynamic-colors` subpath has been removed. Android 12+ returns `{ available: true, -systemDark, light, dark }`, with supported Material `ColorScheme` roles as opaque `#RRGGBB` colors for -each scheme, including accent/on-accent/container pairs, surface containers, -outline, inverse, scrim, and fixed roles. The original six compatibility fields -remain required in TypeScript; additional roles are optional so consumers can -accept partial schemes. The OS -palette is used without changing the Activity theme or requesting wallpaper -permissions. Other platforms return `{ available: false, systemDark }`. - -The listener returns a removable handle and invalidates cached colors on Android -resume/configuration changes. Fetch again when notified. For Ionic conversion and -`system` / `light` / `dark` mode control, pass the getter and listener to +## Android dynamic colors + +Read Android 12+ Material You colors with `getDynamicColorScheme()` and observe +resume/configuration notifications with `addDynamicColorListener()`. Import both +from `@rdlabo/capacitor-native-ui`. Dynamic colors are a supported, opt-in API, +independent of Native UI projection and experimental app-local extensions. + +The plugin returns light and dark schemes without changing the Activity theme or +requesting wallpaper permissions. Other platforms report `available: false`. +The plugin does not apply CSS; Ionic applications can use `enableDynamicColors` from `@rdlabo/ionic-theme-md3/dynamic-colors`. -Ionic themes do not require these extra roles: ionic-theme-md3 derives its default -component colors from Ionic palettes alone. Material You optionally supplies more -precise `--md-sys-color-*` overrides, preserving the Material foreground/background -pairs. No additional native calls are needed for the expanded role set. +See [Android dynamic colors](docs/dynamic-colors.md) for installation, API contracts, +change notifications, cleanup, and Ionic integration. diff --git a/docs/dynamic-colors.md b/docs/dynamic-colors.md new file mode 100644 index 0000000..6713ad4 --- /dev/null +++ b/docs/dynamic-colors.md @@ -0,0 +1,166 @@ +--- +title: Android dynamic colors +--- + +# Android dynamic colors + +Dynamic colors are a supported, opt-in API. Read the device's Material You palette +without enabling Native UI projection, changing the Activity theme, or requesting +wallpaper/storage permissions. Ionic is not required to read colors. App-local +SwiftUI/Compose extensions remain experimental; their status is separate. + +## Install and import + +Install the plugin as a direct application dependency, sync, then rebuild the +Android app. Updating JavaScript alone cannot add a native method to an installed APK. + +```sh +npm install @rdlabo/capacitor-native-ui +npx cap sync android +npx cap run android +``` + +Use the package root for functions and types: + +```ts +import { + getDynamicColorScheme, + addDynamicColorListener, + materialColorRoles, + type DynamicColorScheme, + type MaterialColorScheme, + type MaterialColorRole, +} from '@rdlabo/capacitor-native-ui'; +``` + +The native getter and root exports are included in 2.1.3. Older installations such +as 2.1.1 need an update and native rebuild. This guide describes the current source +API; removing the experimental designation takes effect in the release containing +this change. Do not use the former `/dynamic-colors` plugin subpath. + +## Read a scheme + +```ts +const result = await getDynamicColorScheme(); +if (result.available) { + const colors = result.systemDark ? result.dark : result.light; + console.log(colors.primary, colors.onPrimary); +} +``` + +`getDynamicColorScheme(): Promise` reads both appearances in +one native call. It returns a snapshot, does not cache it, and does not apply CSS. + +| Platform | Result | +| ---------------------- | ---------------------------------------------------------------- | +| Android 12+ (API 31+) | `{ available: true, systemDark, light, dark }` | +| Android 11 and earlier | `{ available: false, systemDark }` | +| iOS / Web | `{ available: false, systemDark }`, without a native bridge read | + +`systemDark` is the Android configuration's night-mode flag. On iOS/Web it comes +from `prefers-color-scheme: dark`, or is false when `matchMedia` is unavailable. +When `available` is false, there are no `light` or `dark` properties. Keep your +application's existing palette. Native read failures reject the promise; they are +not converted to `available: false`. + +### Color contract + +Each `MaterialColorScheme` contains opaque six-digit `#RRGGBB` strings. Hexadecimal +letter casing is not significant. `primary`, `secondary`, `tertiary`, `error`, +`surface`, and `onSurface` are required; other roles are optional in the public +type so consumers can accept partial schemes. The current Android implementation +returns all 46 names in `materialColorRoles` for each appearance: + +- Accent and foreground roles, including primary/secondary/tertiary containers. +- Error and error-container pairs. +- Surface, surface-container levels, and surface foregrounds. +- Outline, outline variant, scrim, inverse and fixed roles. + +Use `MaterialColorRole` for a role name and `materialColorRoles` to enumerate the +supported names. Preserve background/foreground pairs such as `primary` and +`onPrimary` when rendering them. The values come from AndroidX Material 3's dynamic +light/dark schemes; the plugin does not regenerate a palette from one seed color +or expose raw tonal palettes. Values depend on the device's system palette. + +## Observe changes and clean up + +`addDynamicColorListener(listener: () => void)` returns a promise for a +`PluginListenerHandle` with `remove(): Promise`. + +Android notifies on Activity resume and configuration changes. A notification +means **read again**, not that the colors necessarily changed. It contains no +scheme and is not a continuous wallpaper-change subscription. On iOS/Web the +function returns a removable no-op handle; observe browser appearance separately +when implementing your own controller. + +Register before the initial read so a change during startup is not missed: + +```ts +const refresh = async () => { + const result = await getDynamicColorScheme(); + // Apply or store result here, retaining your fallback when unavailable. + console.log(result); +}; +const handle = await addDynamicColorListener(() => { + void refresh().catch(console.error); +}); +try { + await refresh(); +} catch (error) { + await handle.remove(); + throw error; +} + +// During application teardown: +await handle.remove(); +``` + +For a custom controller, coalesce concurrent reads and retain the last working +palette if a refresh fails. The Ionic controller below handles those details. + +## Apply to Ionic + +The plugin acquires colors; the theme owns conversion and CSS lifecycle. No call +to `enableNativeUI()` or other projection setup is needed. + +```ts +import { getDynamicColorScheme, addDynamicColorListener } from '@rdlabo/capacitor-native-ui'; +import { enableDynamicColors } from '@rdlabo/ionic-theme-md3/dynamic-colors'; + +const theme = await enableDynamicColors({ + getScheme: getDynamicColorScheme, + listen: addDynamicColorListener, + mode: 'system', + onError: console.error, +}); + +// On a user appearance preference change: +theme.setMode('dark'); // 'light' and 'system' are also supported. + +// During application teardown: +await theme.destroy(); +``` + +Install the MD3 theme and import Ionic's `dark.class.css` in the global stylesheet. +Do not combine it with `dark.system.css` or `dark.always.css`. Initialize one +controller per browser document, after the DOM is available; do not run it during +server rendering. See the [theme setup and mapping guide](https://github.com/webview-native-ui/ionic-theme-md3/blob/main/docs/dynamic-colors.md) +for the complete stylesheet setup, fallback rules, errors and appearance control. + +Ionic palettes remain sufficient without this feature. Material roles enrich +optional tokens while retaining explicit Ionic color choices, default white +surfaces and the separate light/medium/dark palettes. Success and warning keep +their Ionic values. + +## Troubleshooting + +- **Native method not implemented:** update the plugin dependency, run Capacitor + sync, rebuild and reinstall the Android app. Check the native binary as well as + the JavaScript version. +- **No colors on iOS/Web/older Android:** `available: false` is expected; keep the + existing palette. It is not a failed installation. +- **Wallpaper changes do not appear immediately:** read again after returning to + the app, or request an explicit refresh. Notifications are lifecycle/configuration + invalidations, not a guarantee of an immediate wallpaper event. +- **Colors are read but the UI is unchanged:** this API does not write CSS. Connect + a theme controller or apply the values in your own renderer. diff --git a/src/dynamic-colors.ts b/src/dynamic-colors.ts index fffc826..b29d248 100644 --- a/src/dynamic-colors.ts +++ b/src/dynamic-colors.ts @@ -4,7 +4,6 @@ import type { PluginListenerHandle } from '@capacitor/core'; /** * Optional Material roles enrich the theme; Ionic palettes remain sufficient on their own. - * @experimental The role set may change in future releases. */ export const materialColorRoles = [ 'primary', @@ -54,12 +53,11 @@ export const materialColorRoles = [ 'onTertiaryFixed', 'onTertiaryFixedVariant', ] as const; -/** @experimental Material You role names may change in future releases. */ +/** Material role names exposed by the native color scheme. */ export type MaterialColorRole = (typeof materialColorRoles)[number]; /** - * Six compatibility inputs; every additional Material role is optional. - * @experimental The dynamic color payload may change in future releases. + * Opaque #RRGGBB colors. Six required inputs; every additional Material role is optional. */ export interface MaterialColorScheme extends Partial> { primary: string; @@ -70,12 +68,12 @@ export interface MaterialColorScheme extends Partial; addListener(name: 'dynamicColorSchemeChange', listener: () => void): Promise; @@ -83,7 +81,6 @@ export interface DynamicColorPlugin { /** * Independent of Native UI projection. No wallpaper or storage permission needed. - * @experimental The API and returned color roles may change in future releases. */ export const getDynamicColorScheme = (): Promise => { if (Capacitor.getPlatform() !== 'android') { @@ -93,8 +90,9 @@ export const getDynamicColorScheme = (): Promise => { }; /** - * Invalidates the cached schemes after native resume or configuration changes. - * @experimental The listener API and notification behavior may change in future releases. + * Notifies consumers after Android resume or configuration changes. + * The callback carries no palette; call getDynamicColorScheme() again. + * Returns a removable no-op handle on other platforms. */ export const addDynamicColorListener = async (listener: () => void): Promise => { if (Capacitor.getPlatform() !== 'android') return { remove: async () => {} };