Skip to content

mobile: Draw system alerts and action sheets in the app's theme colors - #742

Open
jt-wang wants to merge 3 commits into
longbridge:mainfrom
jt-wang:android-dialog-theme
Open

jt-wang wants to merge 3 commits into
longbridge:mainfrom
jt-wang:android-dialog-theme

Conversation

@jt-wang

@jt-wang jt-wang commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Description

An app that sets a theme in nsh.json gets it everywhere except its confirm dialogs. Android draws AlertDialog.show in the wallpaper's colors (Material You; seen on Android 15): a lilac card with blue buttons. iOS 17 draws the buttons in system blue. In an app drawn in black and white, the confirm dialog is the one screen that looks like another app's. Linux's menus already take the app's design tokens.

The core already sends those tokens to every platform (Command::Tokens); Android and iOS passed over them. Now they use them, and an app without a theme of its own keeps the system's colors on both: when the tokens are the default theme's (nsh_base::tokens_of(None)), nothing changes.

Android colors the whole dialog:

Part Token
The dialog popover
Title, sheet options popover-foreground
Message muted-foreground
Buttons primary
A destructive action destructive

The dialog keeps the system's shape, corners, insets, type and ripple; only the colors change, light or dark as the appearance shown. Material's dialog guidance lists color among what an app may customize. encode.rs sends the five tokens' light and dark values as command 27, Mounter keeps them, and SystemUi.dialog tints the background drawable and colors the texts and buttons.

iOS sets the tintColor of the alert or action sheet to primary. The HIG's Color page names "choosing your brand color as the app accent color", and tintColor is a public UIView property, so this leaves the controller's private view hierarchy alone. UIKit then draws the regular and cancel actions in the tint; destructive actions stay system red. iOS 26 alerts don't use the tint, so they look as before there.

Independent of #741 (TabBar tint): both touch Mounter.java and the iOS mount.rs, and they merge cleanly in either order.

Screenshots

Android 15 emulator, an app whose theme sets popover #FCFCFA, primary #26251E and destructive #CF2D56 (dark: #1F1E1A, #EDECE6, #F0627E):

Before After, light After, dark

iOS 17.0 simulator, same theme:

Before After, light After, dark

iOS 26.3 simulator, after (same as before):

Public API

None.

Breaking Changes

None. An app without a theme in nsh.json looks as before:

How to Test

  1. Give an app a theme in nsh.json and a button that calls AlertDialog.show with a cancel and a destructive action.
  2. nsh run App.tsx --target android, tap the button, in light and in dark mode. The dialog is popover, the cancel button primary, the destructive one destructive.
  3. nsh run App.tsx --target ios on an iOS 17 simulator, same steps. The cancel button is primary, the destructive one system red.
  4. Remove the theme and repeat: both platforms show the system's colors, as before.

I checked these by reading the pixels of the screenshots:

  • Android, before: buttons #495D92 on a #F1F0F7 dialog (the wallpaper's). After: every color matches the theme, in light and dark. Without a theme: #495D92 on #F1F0F7, as before.
  • iOS 17, before: Cancel #007AFF. After: Cancel #26251E (light) and #EDECE6 (dark), Delete #FF3B30 / #FF453A; in the action sheet Copy and Cancel #26251E / #EDECE6, Report system red. Without a theme: #007AFF, as before.
  • iOS 26 alert: Cancel #000000 and Delete #FF383C, before and after.

A test that answers the dialog with app.onDialog(() => "Delete") still passes on the Android emulator.

Ran: cargo test --workspace (passes), cargo xtask android --release --dev --abi arm64-v8a, cargo xtask clippy-android (clean), clippy for the Catalyst target (clean), rustfmt +nightly --check on the changed Rust files.

Checklist

  • I have read the CONTRIBUTING document and followed the guidelines.
  • Reviewed the changes in this PR and confirmed AI generated code (if any) is accurate.
  • Passed cargo test --workspace, cargo +nightly fmt --all and clippy (including the Catalyst and Android runs for platform code).
  • Changes to what apps use keep what released versions shipped and come with a case in tests/compat/ (cargo xtask compat): no API change, so no case.
  • Ran the related examples/gallery demos on iOS and Android (if the change is visible or platform-specific).

The core already sends the app's design tokens to the platform (Command::Tokens),
which Linux's menus take their colors from. Android passed over them, so its
alerts and action sheets drew in the wallpaper's colors (Material You), whatever
the app's palette.

SystemUi now fills the system dialog's shape with `popover`, writes the title
and options in `popover-foreground`, the message in `muted-foreground`, the
buttons in `primary` and a destructive action in `destructive`, light or dark
as the appearance shown. An app without a theme of its own (its tokens the
default theme's) keeps the system's colors, as on iOS.
UIKit draws an alert's regular and cancel actions in its tint, which iOS 17
leaves the system blue in an app of any palette. With a theme of its own, the
app's `primary` is now the alert's tint (light or dark as shown); destructive
actions stay red, and an app without a theme keeps the system's. iOS 26's
alerts do not use the tint and look as before.

@huacnlee huacnlee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static review; not device-tested. Reviewed by ChatGPT on behalf of @huacnlee.

Comment thread crates/nsh-platform-ios/src/system.rs Outdated
static APPEARANCE: Cell<UIUserInterfaceStyle> = const { Cell::new(UIUserInterfaceStyle::Unspecified) };
/// The tint of alerts and action sheets: the app theme's `primary`
/// (`None`: the system's, for an app without a theme of its own).
static ALERT_TINT: Cell<Option<nsh_core::element::AdaptiveColor>> = const { Cell::new(None) };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep alert tint scoped to its surface. ALERT_TINT is shared by every iOS mounter on the UI thread, so after surface B updates its theme, a dialog from surface A uses B’s color because tokens are only resent when they change. Store the tint on Mounter, pass it into show_dialog, and add a two-surface regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 99f223c. The thread-local is gone: each iOS Mounter keeps alert_tint from its own Command::Tokens and passes it to show_dialog. Android already kept dialogColors on each Mounter.

Mounter::new needs a MainThreadMarker, which libtest's threads don't have, so the test covers the value each Mounter now holds: a_surfaces_alert_tint_comes_from_its_own_tokens. On the iOS 17.0 simulator the themed alert still draws Cancel #26251E and Delete #FF3B30, and the dark action sheet #EDECE6 and #FF453A.

The tint of alerts and action sheets lived in a thread-local shared by
every surface, and the core sends a surface's tokens only when they
change: after one surface's tokens arrived, another surface's dialogs
took its color. Each Mounter now keeps the tint of its own tokens and
passes it to show_dialog.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants