Liquid Glass Easy

Combining lenses

Three ways several lenses can share one read of the backdrop, or fuse into one surface.

class

LiquidGlassGroup#

Extends StatelessWidgetGuide: Group →

Draws every LiquidGlassLens beneath it as one sheet of glass.

The members keep their own layout, their own shape and their own child; what they give up is their individual glass pass. In its place the group paints a single surface covering all of them — one backdrop read and one material for the whole set, instead of one of each per lens.

smoothness decides whether they also fuse, and defaults to none: the group still draws them as one surface with one read, but each member keeps its own hard outline, and the shader skips the smooth-union entirely rather than running it and finding nothing to blend. Give it a radius and members that come within about half of it flow together through a metaball bridge.

That makes this widget LiquidGlassBlender with a different default — smoothness: 0 there says the same thing.

Adaptivity is per member. Each lens judges the background behind itself and paints its own verdict into the shared sheet; where two of them fuse, their colours cross over inside the bridge, on the same falloff that shapes it. A member that is not adaptive takes the group's colour. Put adaptivity on the group's style as well and it supplies the fallback colour and the content palette for the subtree.

Two to maxLensCount members. Place the group inside a LiquidGlassView for the Skia / web capture path; on Impeller it reads the live backdrop.

Deprecated

LiquidGlassBlender does this and nothing is lost in the move: this widget is that one with a different default, and smoothness: 0 says what a group says. Every other parameter has the same name and the same meaning.

Passing a radius carries over unchanged — LiquidGlassGroup(smoothness: 40) becomes LiquidGlassBlender(smoothness: 40).

Dart
LiquidGlassGroup(
  style: LiquidGlassStyle(adaptivity: myPalette),
  child: Column(children: [ ...lenses... ]),
)
Dart
// before
LiquidGlassGroup(style: myStyle, child: child)
// after
LiquidGlassBlender(smoothness: 0, style: myStyle, child: child)

Constructors

const LiquidGlassGroup({ super.key, required this.child, this.style = const LiquidGlassStyle(), this.smoothness, this.useImpellerBackdrop, this.useEngineBlur = true, this.debugClipBounds = false, })
ParameterTypeDefaultDescription
keyKey?
childrequiredWidgetA subtree holding minLensCount to maxLensCount LiquidGlassLens descendants.
styleLiquidGlassStyleconst LiquidGlassStyle()The shared material: shape, appearance, refraction, and the group-level adaptivity that members without one of their own fall back to.
smoothnessdouble?Radius, in logical pixels, over which nearby members flow together — or `null` (the default) to switch the fusing off. With a radius, two members bridge once the gap between them drops to roughly smoothness / 2, and their tints cross over across that same bridge. Further apart than that and the radius costs a little maths per fragment for a blend that never happens. With null the group is a plain union: nearest member wins each fragment outright, no smooth-union, no per-member influence weights, no blended gradient. That is the default because sharing one surface and one backdrop read is what the group is FOR — fusing is the extra, and members laid out apart (a row of buttons, a column of pills) should not pay for a blend that never happens. Ask for a radius when you want the members to touch. Prefer null over a tiny radius. A near-zero radius gets the outline right but leaves the influence weights as a 0/1 indicator, so two OVERLAPPING members weigh the same and their colours average with a hard step at each outline. null resolves that tie by distance instead.
useImpellerBackdropbool?Overrides renderer detection. Null inherits from LiquidGlassView, then falls back to Flutter's shader-filter capability.
useEngineBlurbooltrueOn Impeller, blur the backdrop with the engine's Gaussian before the refraction shader instead of blurring in-shader. Cheaper and cleaner; false falls back to the in-shader blur. No effect on the Skia path.
debugClipBoundsboolfalseDebug: outline the backdrop clip region in magenta. Costs performance — diagnostic only.

Static members

SignatureDescription
int minLensCountFewest members the shared surface is meant for.
int maxLensCountMost members one group can hold.

Declared in lib/src/widgets/lens/liquid_glass_group.dart

class

LiquidGlassBatch#

Extends StatefulWidgetGuide: Batch →

Draws every LiquidGlassLens beneath it from one read of the backdrop.

A lens on Impeller works by reading back what is already painted behind it and running the glass shader over that copy. The read is the expensive part, and it is per lens: twenty glass cards in a list mean twenty readbacks a frame (forty with blur, which adds a second pass of its own). A batch tags all of its lenses with one shared backdrop key, so the engine takes the copy once and every member samples it.

Nothing else about the members changes: each keeps its own shape, its own style, its own child, its own adaptivity, its own touch response. They are still separate sheets of glass — the batch only makes them share the read.

Batch or blender?

LiquidGlassBlender draws two to eight lenses with one shader: one silhouette, one material, one read — optionally flowing into each other through a metaball bridge, and with smoothness: 0 not even that, just the one pass for the set (what the deprecated LiquidGlassGroup was). Everything it can draw has to fit in that shader, which is where its eight-member ceiling comes from.

LiquidGlassBatch shares nothing but the read. Members stay separate passes, keep their own looks, and there is no limit on how many there can be. Reach for the blender when the lenses should read as one piece of glass, and for the batch when there are simply a lot of them.

The two nest. A blender inside a batch is a member like any other: it is already one pass, and in a batch that pass shares the batch's read instead of taking its own. So a page of glass cards with one fused pair among them is still a single read.

It works on the components too

Every component that draws its glass through a LiquidGlassLens — the button, the FAB, the app bar, the sheet, the dialog, the tab bar's capsule, the draggable — joins a batch by being inside it. There is nothing to pass: the lens finds the batch from its own context, however deep in a component's tree it sits. So does the scroll edge, whose blur is a backdrop pass of its own: inside a batch it takes the batch's copy instead of a read of its own.

A component that stacks glass on glass is an overlap like any other, and the rule below applies to it: the tab bar's moving pill refracts the capsule under it, so a tab bar inside a batch loses that — the pill reads the page instead. Keep one out of the batch, or wrap it in LiquidGlassBatch.exclude, which puts that subtree back on reads of its own while the rest of the batch stays shared.

The switch and the slider do this for themselves: each thumb refracts the track it rides, which a shared copy taken before the control painted would not hold, so both keep their thumb on a read of its own whatever batch they sit in. The rest of the control — the track, the fill — is plain paint and costs a batch nothing either way.

The one rule: members must not overlap

Every member reads the same copy of the backdrop, taken before any of them painted — so a member cannot see another member's glass. Where two of them overlap, the one on top refracts what was behind the one below instead of the glass itself, and the stack reads as a single lens. Lay members out side by side (lists, grids, rows of controls, a keyboard) and this never comes up. Two lenses that must overlap belong in different batches, or in no batch at all.

A scroll edge is the member most likely to overlap: it is pinned over the content that scrolls under it. Batched, it blurs the copy taken before the cards painted, so a card passing through the band loses its glass there — at the band's sigma the difference is small, and it is nothing where the band has faded out. If it shows, keep the edge out with LiquidGlassBatch.exclude and it reads the cards as before.

The view and the scaffold batch on their own

LiquidGlassView and LiquidGlassScaffold do this without being asked (batch: true, the default): one batch over the background — the scaffold's body — and another over what floats above it. Two, because the chrome refracts the body and could not see it from the body's copy. A glass pill tab bar keeps its moving pill and its magnifier out of both. The scaffold's chrome batch can reach further than its own tree: a showLiquidGlassSheet or showLiquidGlassDialog opened from inside the scaffold with batch: true joins it, so the sheet or the dialog shares the tab bar's read — by default each takes a read of its own. Pass batch: false to the scaffold to get every lens back on a read of its own.

Where it applies

The shared key is an Impeller mechanism, so the batch is what changes the cost there. On the Skia / web capture path a LiquidGlassView already captures its background once for every lens inside it, so the batch is inert rather than wrong — the same tree runs on both backends.

Dart
LiquidGlassBatch(
  child: ListView.builder(
    itemCount: 40,
    itemBuilder: (context, i) => LiquidGlassLens(
      style: cardStyle,
      child: Card(i),
    ),
  ),
)
Dart
LiquidGlassBatch(
  child: Wrap(
    spacing: 12,
    children: <Widget>[
      for (final String label in labels)
        LiquidGlassButton(label: label, onPressed: () {}),
    ],
  ),
)
Dart
LiquidGlassBatch(
  child: Column(
    children: <Widget>[
      Expanded(child: cards),
      LiquidGlassBatch.exclude(child: LiquidGlassTabBar(...)),
    ],
  ),
)

Constructors

const LiquidGlassBatch({ super.key, required this.child, this.enabled = true, })
ParameterTypeDefaultDescription
keyKey?
childrequiredWidgetThe subtree whose LiquidGlassLens descendants share the read.
enabledbooltrueWhether the members are actually batched. false leaves every lens on its own backdrop read — the pre-batch behaviour — which is also what a disabled batch nested inside an enabled one restores for its own subtree.
const LiquidGlassBatch.exclude({ super.key, required this.child, })

Keeps child out of the enclosing batch.

ParameterTypeDefaultDescription
keyKey?
childrequiredWidgetThe subtree whose LiquidGlassLens descendants share the read.

Static members

SignatureDescription
int? backdropIdOf(BuildContext context)The shared backdrop key for the nearest enclosing batch, or null when there is none (or the nearest one is disabled).

Declared in lib/src/widgets/lens/liquid_glass_batch.dart

class

LiquidGlassBlender#

Extends StatefulWidgetGuide: Blend →

Draws two to eight descendant LiquidGlassLens widgets with one shader — one backdrop read and one material for all of them — and, above smoothness: 0, blends their silhouettes into one liquid surface.

The sharing is the base and the bridge is the option. At smoothness: 0 the members keep their own hard outlines on that single pass, which is exactly what the deprecated LiquidGlassGroup did: LiquidGlassGroup(...) is LiquidGlassBlender(smoothness: 0, ...), every other parameter by the same name.

The upper limit is eight shapes (maxLensCount) — the metaball field compares every member per fragment, so the cap keeps the shader cost bounded. It is raised two at a time, by adding a mat4 to the shader.

The descendant lenses keep their normal layout and child content, but their individual glass passes are replaced by one smooth metaball union. The group's style controls the shared material; each lens's shape and layout control its contribution to the merged silhouette.

Place the group inside a LiquidGlassView.child for Skia/Web capture support. On Impeller it samples the live backdrop directly.

Dart
LiquidGlassBlender(
  smoothness: 48,
  child: Stack(
    children: [
      Positioned(
        left: 40,
        top: 80,
        child: SizedBox(
          width: 120,
          height: 80,
          child: LiquidGlassLens(),
        ),
      ),
      Positioned(
        left: 130,
        top: 100,
        child: SizedBox(
          width: 100,
          height: 100,
          child: LiquidGlassLens(),
        ),
      ),
    ],
  ),
)

Constructors

const LiquidGlassBlender({ super.key, required this.child, this.style = const LiquidGlassStyle(), this.smoothness = 48, this.useImpellerBackdrop, this.useEngineBlur = true, this.debugClipBounds = false, })
ParameterTypeDefaultDescription
keyKey?
childrequiredWidgetAny widget tree containing two to six LiquidGlassLens descendants.
styleLiquidGlassStyleconst LiquidGlassStyle()The shared material used after the lens silhouettes are merged.
smoothnessdouble?48Radius, in logical pixels, over which nearby lens outlines flow together. `0` turns the metaball off. The members are then unioned hard — nearest one wins each fragment outright — and the shader runs none of the smooth-union machinery: no smin, no per-member influence weights, no blended gradient. They still share one surface, one backdrop read and one material; they just stop flowing into each other. That is the whole of what LiquidGlassGroup does. Zero is a switch, not a limit of the radius. A near-zero radius — 0.001 — is a different thing and a worse one: it degenerates the distance correctly, but its weights collapse to a 0/1 indicator, so two OVERLAPPING members weigh equally and their colours average with a hard step at each outline. Zero takes the branch instead, and has no such tie. null is accepted and means exactly what 0 means. It was the only way to say it before, so passing it still works; new code should say 0.
useImpellerBackdropbool?Overrides renderer detection. When null, inherits LiquidGlassView and otherwise uses Flutter's shader-filter capability.
useEngineBlurbooltrueOn the Impeller (live-backdrop) path, blur the backdrop with the engine's native Gaussian before the refraction shader — via ImageFilter.compose(outer: shader, inner: blur) — instead of the shader's own multi-tap blur. The shader still masks to the merged silhouette, so out-of-shape blurred pixels are discarded (no halo). This is cheaper and higher quality; set false to fall back to the in-shader blur (e.g. to A/B, or if a device rejects a composed shader filter). No effect on the Skia capture path, which always blurs in-shader.
debugClipBoundsboolfalseDebug: draw a magenta outline around the backdrop clip region (the blob union inflated by the rim/blur/refraction/bridge margin). Works on both backends — the Impeller engine-blur clip and the Skia draw rect. Diagnostic only — it costs performance. It adds an extra stroked drawRect every frame, and on Impeller that draw lands on the parent canvas after the backdrop pass, which can break paint batching. Keep it false in production; turn it on only while tuning to see where the costly pass runs.

Static members

SignatureDescription
int minLensCount
int maxLensCountMaximum number of LiquidGlassLens members that can be blended into one surface — eight; the cap keeps the per-fragment metaball cost bounded. Raised two at a time, by adding a mat4 to the shader.

Declared in lib/src/widgets/lens/liquid_glass_blender.dart

Comments

Comments are GitHub Discussions — reply from either place.