Tutorial · 18 min · intermediate
Liquid Glass, the real one
The iOS 26 material as a widget — interactive glass with Dart children, the fluid merge, regular vs clear, under a clear large-title bar.
What you'll build — running on device.
Liquid Glass is iOS 26’s defining material — and the clearest case for native rendering. It refracts what’s behind it, flexes under touch, and fuses with neighboring glass; none of that can be faked on a Flutter canvas. In DartNative it’s just a widget, because your widgets already are native views. You’ll build two screens: one that puts the material through its signature moves under a frosted bar, and a pushed screen with the fully clear large-title bar.
What you need
- A project from Your first DartNative app
- An iOS 26 device to see the live material (everywhere else the same
code renders a graceful translucent fallback —
isIOS26lets you annotate when you care)
Step 1 — A glass capsule with your widgets inside
The atom of this screen is a small builder that wraps an icon in glass:
Widget _glassCircle(IconData icon) => GlassEffectContainer(
borderRadius: BorderRadius.circular(32),
interactive: true,
tint: const Color(0x22FFFFFF),
brightness: playgroundPalette.brightness,
child: SizedBox(
width: 64,
height: 64,
child: Center(
child: Icon(icon, size: 26, color: kTextPrimary),
),
),
);
GlassEffectContainer is a real UIGlassEffect view — and its child
is an ordinary Dart subtree rendered inside the glass, not behind it.
The tint is a translucent wash over the material (here a whisper of
white), and brightness tells the glass which appearance to render —
here it follows the demo’s active palette (dark by default), so the
capsule asks for the dark variant to match its dark stage.
playgroundPalette and the k* colors come from the shared UI kit in
lib/screens/home/demo_ui.dart.
Step 2 — The fluid merge
Wrap two capsules in a GlassEffectGroup and animate the gap between
them:
_stage(
height: 170,
child: GlassEffectGroup(
spacing: 40,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_glassCircle(CupertinoIcons.camera_fill),
AnimatedContainer(
duration: const Duration(milliseconds: 550),
curve: Curves.easeInOut,
width: _together ? 10 : 72,
),
_glassCircle(CupertinoIcons.plus),
],
),
),
),
Tap “Bring together” and the capsules glide toward each other — and the
system fuses them into one blob the moment they come within spacing of
touching. The AnimatedContainer only animates the gap; the
melt-together is entirely the OS’s, computed by the compositor from the
real views. It works across your own Dart layout — the capsules are
ordinary children of an ordinary Row.
About _stage: glass is invisible over flat black — it needs colorful
content behind it to refract, so the demo places every capsule on a
vivid gradient “stage” with off-center highlights. The lensing is the
whole show; in a real app, glass earns its keep over photos, maps, and
colorful content.
Step 3 — Interactive vs static
interactive: true adds the system’s gel response. The demo makes the
difference unmistakable with an A/B pair — same glass, same content,
only interactive differs:
_stage(
height: 120,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_glassPill('Interactive', interactive: true),
const SizedBox(width: 20),
_glassPill('Static', interactive: false),
],
),
),
Press and hold: the interactive pill brightens and flexes under your finger; the static one stays inert. The pill builder gives the interactive one a warm tint so the press response reads clearly:
tint: interactive ? const Color(0x44FFD60A) : const Color(0x1AFFFFFF),
The labels inside are Dart Text widgets — and the same touch that
drives the gel response still reaches your Dart handlers. One touch,
both worlds; there’s no platform-view boundary to fall off.
Step 4 — Regular vs clear
The material comes in two system styles, one parameter apart:
_stage(
height: 120,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_stylePill('Regular', GlassStyle.regular),
const SizedBox(width: 20),
_stylePill('Clear', GlassStyle.clear),
],
),
),
GlassStyle.regular is the standard frosted glass; GlassStyle.clear
is the more transparent variant Apple uses for clear-glass buttons over
media. Inside the builder it’s just style: style on the same
GlassEffectContainer.
Step 5 — A frosted bar is one alpha away
The bar surface is the other half of the iOS 26 look, and it’s a single
knob: the alpha of backgroundColor. An opaque color is a solid bar; a
translucent color routes to the native frosted treatment — system blur
plus your tint. The demo’s bar colors come from the UI kit’s palette,
whose dark variant sets barBgGlass to Color(0xD91C1C1E):
/// iOS 26 glass variant of [barBg]: alpha < 1 opts the AppBar into the real
/// Liquid Glass material — the bar keeps its tint while content scrolling
/// behind it stays faintly visible through the frost. Raise the alpha for a
/// more opaque bar.
final Color barBgGlass;
extendBodyBehindAppBar: isIOS26,
appBar: AppBar(
title: Text('Glass & Fluid Merge', /* ... */),
backgroundColor: isIOS26 ? kBarBgGlass : kBarBg,
),
extendBodyBehindAppBar matters: the frost is a live material reacting
to your content scrolling beneath it, so the body has to run under the
bar for it to have anything to act on. Pre-26 (and on Android) the same
AppBar takes the solid color instead.
Step 6 — The clear large-title bar
The third surface is no backgroundColor at all. The second screen,
LargeTitleDemo, is built around it — a light-themed chats list under
the fully clear bar (the launcher pushes it with Navigator.push and a
PageRoute, and the glass rides the transition):
Scaffold(
// Body extends under the bar so the scroll-edge ramp has content to
// act on (a body that starts below the bar clips at its own edge).
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text('Chats', /* fontSize 17 */),
subtitle: _showSubtitle
? Text('${_chats.length} chats', /* ... */)
: null,
actions: [
BarButtonItem(
fontIcon: CupertinoIcons.info_circle,
titleStyle: const TextStyle(color: Color(0xFF111111)),
onPressed: () => setState(() => _showSubtitle = !_showSubtitle),
),
],
largeTitle: const Text('Chats', /* fontSize 34, bold */),
// NO backgroundColor → the fully CLEAR iOS 26 bar: glass lives on
// the controls, the surface stays see-through, and the system
// scroll-edge ramp (not a frost sheet) keeps the region legible.
),
body: ListView(
padding: EdgeInsets.only(
top: padTop + (isIOS26 ? 108 : 56),
bottom: 40,
),
// ...
),
)
Scroll and the large title slides under the bar and fades while the
small title fades in — the native collapse, tracking your finger,
driven by an ordinary Dart ListView. Rows passing beneath stay
visible through the system’s progressive blur ramp. Because the body
extends behind the bar, the list pads its top by the status inset plus
the bar band — isIOS26 ? 108 : 56 covers the large-title band on
iOS 26 and the composed-header fallback everywhere else.
Two garnishes worth stealing: the ⓘ BarButtonItem toggles a live bar
subtitle fed by plain Dart state, and since this screen is
deliberately light (the edge ramp needs a light surface to read), its
initState pins dark status icons with
SystemChrome.setSystemUIOverlayStyle — the Navigator restores the
previous style on pop.
Why this is native
Flutter plugins can host glass only as a platform-view island: the island’s content must itself be native — your widgets can’t live inside it — and the sandwich leaks (the plugins document modal bleed and glass halos escaping during transitions). Here there is no island. The capsules are ordinary parents in one native hierarchy: your widgets render inside the glass, one tap drives the gel response and your Dart handler together, and the merge spans your own layout. The same goes for the bar: the audited plugins show the native large-title collapse only over native list content — here the Dart list is a native scroll view, so the collapse and the edge ramp just work. No seams to hide.
The finished code
In the public repo the two screens —
lib/screens/liquid_glass/glass_merge_demo.dart (the material) and
lib/screens/liquid_glass/large_title_demo.dart (the clear bar) — are
byte-identical copies of the playground’s Liquid Glass demos, with the
playground’s shared UI kit (lib/screens/home/demo_ui.dart) carried
verbatim alongside; lib/main.dart adds only a thin two-row launcher.
When the playground screens improve, this tutorial inherits it by
copying the files again. dn create ., dn run.