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:
DynamicColorreads the system’s real tonal palettes (android.R.color.system_accent1_*…) — the exact colors every Material You app on the device is themed with — andBadgeattaches the Material library’s ownBadgeDrawable, so metrics, colors and motion track the real library by definition. The hide-on-scroll bar is the realBottomNavigationViewmoving 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.