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
37 changes: 13 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
166 changes: 166 additions & 0 deletions docs/dynamic-colors.md
Original file line number Diff line number Diff line change
@@ -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<DynamicColorScheme>` 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<void>`.

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.
16 changes: 7 additions & 9 deletions src/dynamic-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<Record<MaterialColorRole, string>> {
primary: string;
Expand All @@ -70,20 +68,19 @@ export interface MaterialColorScheme extends Partial<Record<MaterialColorRole, s
onSurface: string;
}

/** @experimental Dynamic color acquisition and its payload may change in future releases. */
/** Availability and system appearance, with both schemes when supported. */
export type DynamicColorScheme =
| { available: false; systemDark: boolean }
| { available: true; systemDark: boolean; light: MaterialColorScheme; dark: MaterialColorScheme };

/** @experimental Native dynamic color methods and events may change in future releases. */
/** Native bridge contract used by the public getter and listener. */
export interface DynamicColorPlugin {
getDynamicColorScheme(): Promise<DynamicColorScheme>;
addListener(name: 'dynamicColorSchemeChange', listener: () => void): Promise<PluginListenerHandle>;
}

/**
* 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<DynamicColorScheme> => {
if (Capacitor.getPlatform() !== 'android') {
Expand All @@ -93,8 +90,9 @@ export const getDynamicColorScheme = (): Promise<DynamicColorScheme> => {
};

/**
* 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<PluginListenerHandle> => {
if (Capacitor.getPlatform() !== 'android') return { remove: async () => {} };
Expand Down
Loading