Liquid Glass Easy

Engines and the view

Impeller reads the backdrop. Skia needs a capture. Same widgets.

Refraction needs the pixels behind the glass. Impeller can hand a shader the live backdrop; Skia cannot. That single difference is the whole story, and LiquidGlassView is the answer to it.

SetupWhat the lens does
Impeller — iOS/Android defaultRefracts the live backdrop: whatever your app painted behind it. No view, no background widget, no setup.
Skia inside a LiquidGlassViewRefracts the view’s captured backgroundWidget, wherever the lens sits inside the view’s child.
Skia with no viewDegrades to a frosted surface — blur, tint, rim — and logs a one-time debug notice.

LiquidGlassView#

The view is the capture pipeline: it rasterizes backgroundWidget into a texture and hands that texture to every lens inside child. Its full parameter list is generated from the source on the API page; these are the ones you will actually reach for.

ParameterDefaultWhat it controls
backgroundWidgetThe layer being captured. Lenses refract this, not the child.
childnullYour UI. Anything here can hold a lens, at any depth.
pixelRatio1.0Resolution of the capture. Below 1 is cheaper and softer; the glass hides a surprising amount of it.
realTimeCapturetrueRe-capture every frame. Turn it off for a background that does not move.
useSynctrueKeeps the capture in step with the frame being built. Off is cheaper and can lag by a frame.
refreshRatedeviceRefreshRateCaps how often the capture runs, independent of the display.
useImpellerBackdropnullOverrides engine auto-detection. Leave it null unless you are testing the other path on purpose.
controllernullA LiquidGlassViewController, for captureOnce() — one snapshot on your cue — and for driving adaptivity by hand.
adaptiveSamplingnullBoth engines. Turns on the tiny background-luminance capture LiquidGlassAdaptivity reads. Null means no sampling at all, so adaptive surfaces inside fall back to a guess. One capture serves every adaptive surface in the view, and it is idle-gated: a still screen samples nothing.
batchtrueImpeller only. Puts the lenses in child on one shared read of the backdrop, and those in backgroundWidget on another. Inert on the capture path, which already reads once per view. See Batch.
regionCapturefalseCaptures each lens’s own rect instead of the whole background — a win when a small lens sits on a large background, a loss when many lenses are spread across it. Skia useSync path only.

Capture once, or every frame#

realTimeCapture: true rasterizes the background every frame. That is right for a scrolling feed, a video, an animating gradient — anything genuinely moving. It is pure waste for a background that never changes.

When the background is static, take one snapshot and refract it forever. Moving a lens over a fixed image costs nothing extra: the shader re-runs, but nothing re-rasterizes.

Dart
final controller = LiquidGlassViewController();

LiquidGlassView(
  controller: controller,
  backgroundWidget: const MyBackground(),
  realTimeCapture: false,
  child: const MyGlassUI(),
);

// Re-snapshot by hand after the background actually changes:
await controller.captureOnce();

This page's demo runs a background that never stops sliding. Set the capture to frozen and the glass goes on refracting bands that have already moved on, while the ones around it carry on — the offset between them is how old the snapshot has become, and capture once closes it.

When Impeller still wants a view#

On Impeller a lens needs nothing: it reads the live backdrop wherever it sits. So the view looks like a Skia formality — and it is, for refraction. It is not for everything else the view provides, and two of those are worth reaching for on Impeller too.

  • `adaptiveSampling` — the one that is not optional. LiquidGlassAdaptivity judges the background by reading its pixels, and reading pixels means a capture. Impeller has none: it never takes one. So a LiquidGlassView with adaptiveSampling set is how adaptive glass gets something to judge on either engine — without it, every adaptive surface inside falls back to guessing from the app theme. It is one tiny capture (pixel ratio 0.05, 8 per second) shared by every adaptive surface in the view, and it stops entirely while the screen is still.
  • `batch` — Impeller-only by nature. The view batches its lenses by default, which is a pure Impeller saving: it is the capture path that already reads once per view, while on Impeller each lens copies the backdrop for itself unless a batch says otherwise.
  • One tree, both engines. A view costs nothing on Impeller beyond the widget itself, so writing the page inside one means the same code runs on a phone and on the web without a branch.

Compile the shaders before the first frame#

This one is not about the capture, and it is not about the engine either — it applies to both. The glass programs are compiled once per run of the app, by whichever lens mounts first, and a lens whose program has not arrived yet paints its frosted fallback until it does. LiquidGlassShaders.ensureLoaded() in main() moves that compile ahead of the first frame.

Dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Every program at once: the lens's, and the blender's.
  await LiquidGlassShaders.ensureLoaded();
  runApp(const MyApp());
}
  • General: useSync: true, pixelRatio: 0.81.0.
  • Performance-first: useSync: false, pixelRatio: 0.50.7.
  • Static background: realTimeCapture: false beats every other tuning knob combined.

Comments

Comments are GitHub Discussions — reply from either place.