Tutorial · 15 min · intermediate

Material 3 & dynamic color

Theme your app with the device's wallpaper palette, put real M3 badges on tabs and actions, and get the hide-on-scroll bar from one field.

What you'll build — running on device.

DartNative’s adaptive-first promise runs both ways: the Liquid Glass tutorial showed iOS at its best; this one is Android’s turn. You’ll build three small screens: one that renders the raw material — the device’s wallpaper palette, read into Dart and laid bare; one whose tab and toolbar carry the Material library’s real badges; and one whose bottom bar hides itself on scroll — from a single generic field that, on iOS 26, produces the glass pill-minimize instead.

What you need

  • A project from Your first DartNative app
  • An Android 12+ device to see the real wallpaper palette — dynamic color is a Material You feature, so on iOS (and older Androids) the app honestly falls back to a “no device palette” story. The badges and the hide-on-scroll field, by contrast, lower to native iOS equivalents from the same Dart.

Step 1 — Read the wallpaper’s color scheme

The first screen, m3_dynamic_color_demo.dart. Material You is Android’s dynamic theming system: since Android 12, the OS derives five tonal palettes from the user’s wallpaper — a primary, secondary and tertiary hue plus two neutrals, each sampled at 13 brightness levels (“tones”) — and every well-behaved app themes itself from them. DynamicColor hands you both the raw palettes and the Material 3 color roles built from them, in three reads at the top of build:

final palette = DynamicColor.corePalette();
final dark = DynamicColor.colorScheme(brightness: Brightness.dark);
final light = DynamicColor.colorScheme(brightness: Brightness.light);

Two things to notice. First, the return types are nullable: where there is no device palette, DartNative never fakes one — you get null and fall back to your own seed color (in your own app that’s one line, dark?.primary ?? yourSeed). The screen keeps that honesty on the display side too: when palette is null it says so instead of rendering a simulated scheme. Second, this is one native read per launch (the palette only changes with the wallpaper, i.e. a fresh cold start), so reading it at build time is all the caching you need.

Step 2 — Lay the palette bare

The rest of the same screen is the teaching payoff: it shows what Material You actually hands your app. TonalPalette.tone(t) samples a palette at any M3 tone (0 = black … 100 = white; exact at the 13 device-sampled tones), so a strip is just a Row of tones:

static const _tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100];
ClipRRect(
  borderRadius: BorderRadius.circular(8),
  child: Row(
    children: [
      for (final t in _tones)
        Expanded(
          child: Container(
            height: 34,
            color: palette.tone(t),
          ),
        ),
    ],
  ),
),

The screen renders all five strips, then the M3 roles built from them in both brightnesses:

else ...[
  _strip('PRIMARY (accent1)', palette.primary),
  _strip('SECONDARY (accent2)', palette.secondary),
  _strip('TERTIARY (accent3)', palette.tertiary),
  _strip('NEUTRAL (neutral1)', palette.neutral),
  _strip('NEUTRAL VARIANT (neutral2)', palette.neutralVariant),
  if (dark != null) _roles('M3 ROLES — DARK', dark),
  if (light != null) _roles('M3 ROLES — LIGHT', light),
],

(primary … neutralVariant are Android’s accent1…neutral2.) Change the wallpaper and relaunch: the screen becomes a completely different painting.

Step 3 — Real badges on a tab and a bar action

The second screen, m3_badges_demo.dart. A badge is the little red count bubble on an icon. Wrap any tab icon in Badge and DartNative attaches the Material library’s own BadgeDrawable to the native bottom-bar item — not a Dart re-drawing:

BottomNavigationBarItem(
  label: 'Chats',
  // Badge on the icon → the native tab badge.
  icon: Badge(
    count: _chats == 0 ? null : _chats,
    child: const Icon(CupertinoIcons.chat_bubble_2),
  ),
),

(kBarBg, kTextPrimary and the other k* colors throughout these screens come from demo_ui.dart, the playground’s shared UI kit.) count: null removes the badge cleanly; counts over 99 render 99+ verbatim, because that capping is the library’s, not ours. The same widget around a BarButtonItem in AppBar.actions gives the toolbar action badge, updated in place with no re-attach flicker:

actions: [
  // Single badged action → the NATIVE action badge (BadgeDrawable
  // on Android; the custody capsule overlay on iOS 26).
  Badge(
    count: _inbox == 0 ? null : _inbox,
    child: BarButtonItem(
      title: 'Inbox',
      titleStyle: TextStyle(color: kTextPrimary),
      onPressed: () {},
    ),
  ),
],

Both badges are driven by plain Dart state (_chats, _inbox); the screen’s control rows just call setState to increment, cap and clear them. On iOS the identical code lowers to UITabBarItem.badgeValue and the capsule action badge.

Step 4 — The hide-on-scroll bar, in one field

The third screen, m3_hide_on_scroll_demo.dart. Why a field and not a scroll listener? Because “the bar gets out of the way while I read” is intent, and each platform has its own native answer to it. You declare the intent once:

bottomNavigationBar: BottomNavigationBar(
  // THE field: M3 hide-on-scroll here, Liquid Glass minimize on iOS 26.
  scrollBehavior: TabBarScrollBehavior.minimizeOnScrollDown,
  backgroundColor: kBarBg,
  labelFontStyle: TextStyle(color: kTextSecondary),
  selectedLabelFontStyle: TextStyle(color: kTextPrimary),
  currentIndex: _tab,
  onTap: (i) => setState(() => _tab = i),
  ...
),

On Android that one field gives you the real com.google.android.material hide-on-scroll: the M3 motion spec’s own timings (175 ms accelerate out, 225 ms decelerate back), the bar’s edge-to-edge surface clearing the gesture area, and the body auto-extended so hiding the bar reveals content. On iOS 26 the same field lowers the scaffold to a real UITabBarController and the floating glass pill minimizes into a capsule.

One practical consequence of that edge-to-edge extension: the list now runs behind the bar, so give it clearance. The screen computes it per platform — room for the extended M3 surface on Android, for the floating pill on iOS 26, a plain gap elsewhere — and feeds it to the list padding:

final bottomPad = isIOS26 ? 90.0 : (Platform.isAndroid ? 108.0 : 12.0);
ListView(
  padding: EdgeInsets.only(bottom: bottomPad, top: 8),

Why this is native

These aren’t seed-simulated approximations: DynamicColor reads the system’s real tonal palettes (android.R.color.system_accent1_* …) — the exact colors every Material You app on the device is themed with — and Badge attaches the Material library’s own BadgeDrawable, so metrics, colors and motion track the real library by definition. The hide-on-scroll bar is the real BottomNavigationView moving on the M3 spec’s own timings. And every screen is the same Dart that produces UIKit’s equivalents — capsule badges, the glass pill-minimize — on an iPhone.

The finished code

Three screens in the public repo — dn create ., dn run. They are byte-identical copies of the DartNative playground’s Material 3 demos (plus demo_ui.dart, the playground’s shared UI kit, also verbatim); the only code written for the tutorial is a thin three-row launcher in main.dart. When the playground screens improve, this tutorial updates by copying the files again.

Open the finished code →