build method
Describes the part of the user interface represented by this widget.
The framework calls this method when this widget is inserted into the tree in a given BuildContext and when the dependencies of this widget change (e.g., an InheritedWidget referenced by this widget changes). This method can potentially be called in every frame and should not have any side effects beyond building a widget.
The framework replaces the subtree below this widget with the widget returned by this method, either by updating the existing subtree or by removing the subtree and inflating a new subtree, depending on whether the widget returned by this method can update the root of the existing subtree, as determined by calling Widget.canUpdate.
Typically implementations return a newly created constellation of widgets that are configured with information from this widget's constructor and from the given BuildContext.
The given BuildContext contains information about the location in the tree at which this widget is being built. For example, the context provides the set of inherited widgets for this location in the tree. A given widget might be built with multiple different BuildContext arguments over time if the widget is moved around the tree or if the widget is inserted into the tree in multiple places at once.
The implementation of this method must only depend on:
- the fields of the widget, which themselves must not change over time, and
- any ambient state obtained from the
contextusing BuildContext.dependOnInheritedWidgetOfExactType.
If a widget's build method is to depend on anything else, use a StatefulWidget instead.
See also:
- StatelessWidget, which contains the discussion on performance considerations.
Implementation
@override
Widget build(BuildContext context) {
// 1. Resolve Settings
// In grouped mode, the explicit `settings` field is a const placeholder;
// we must inherit the real settings from the ancestor layer.
final inherited =
context.dependOnInheritedWidgetOfExactType<InheritedLiquidGlass>();
// A running materialize transition dissolves this surface through the
// settings' visibility channel. Applying it here covers the Frosted and
// Standard tiers and the widget-level decorations (shadow, backer); the
// Premium tiers pass the raw `settings` field into a [LiquidGlassLayer],
// which resolves the same scope itself — the two never overlap, so the
// transform is never applied twice.
// When the caller explicitly overrides the body rendering mode (e.g.
// GlassBodyMode.clear for tintColor flooding) or provides an opaque
// glassColor, their settings MUST take precedence over the inherited
// ancestor's settings, regardless of useOwnLayer.
//
// The inherited path exists for the batch-blur optimisation: buttons
// inside a shared glass container blend into the parent surface and
// don't apply an independent glass layer. That optimisation is
// correct for default/adaptive surfaces, but MUST be bypassed when
// the caller has intentionally requested a different body mode —
// otherwise GlassBodyMode.clear tintColor requests are silently
// discarded and the capsule stays neutral grey.
final bool hasExplicitBodyMode =
settings.bodyMode != GlassBodyMode.adaptive;
final bool hasExplicitTint = settings.glassColor.a > 0;
final bool useExplicitSettings =
useOwnLayer || hasExplicitBodyMode || hasExplicitTint;
final baseSettings = GlassMaterializeScope.resolveSettings(
context,
(!useExplicitSettings && inherited != null)
? inherited.settings
: settings,
);
// The content channel fades and blurs the child on the tiers that render
// it as plain paint. Premium/grouped children flow through [LiquidGlass],
// which applies the same wrap internally.
final content = GlassMaterializeScope.wrapContent(context, child);
// ---- FULLY DEMATERIALIZED FAST-PATH --------------------------------------
// A surface faded to nothing renders nothing, and has to say so before any
// tier selection runs. `effectiveBlur` is blur × visibility, so fading a
// surface out drives it to zero and would route it into the frosted
// fallback below — a renderer with no notion of visibility, which leaves a
// solid tinted disc exactly where the glass was supposed to have gone.
//
// Zero opacity rather than the bare child: the premium path wraps content
// in `Opacity(settings.visibility)`, so at zero the icon goes with the
// glass. Returning the child unwrapped made it pop back into view at the
// exact moment the surface finished disappearing.
// --------------------------------------------------------------------------
if (baseSettings.visibility <= 0.0) {
return Opacity(opacity: 0.0, child: content);
}
// ---- MINIMAL FAST-PATH ---------------------------------------------------
// GlassQuality.minimal bypasses all custom shaders. Renders via
// _FrostedFallback: ClipPath(ShapeBorderClipper) + BackdropFilter + tint.
// ClipPath correctly clips ALL shape types (oval, superellipse, rect).
// Zero fragment shader cost on any device.
//
// platformViewBackdrop ALSO routes here: over a PlatformView only a live
// BackdropFilter samples the composited map. The premium/standard shaders
// read a captured backdrop that EXCLUDES the platform view (see
// canUsePremiumShader below), so they render inert there — the frost is the
// one tier that actually blurs over a PlatformView. This finally delivers
// the "live BackdropFilter path" the canUsePremiumShader comment promises.
// --------------------------------------------------------------------------
if (quality == GlassQuality.minimal || platformViewBackdrop) {
return _wrapWithDecorations(
context,
baseSettings,
_FrostedFallback(
shape: shape,
settings: baseSettings,
clipBehavior: clipBehavior,
glowIntensity: glowIntensity,
isAccessibilityFallback: false,
isInteractive: isInteractive,
platformViewBackdrop: platformViewBackdrop,
child: content,
),
);
}
// ---- NESTED GLASS VIBRANCY FAST-PATH (avoidsRefraction: true) ------------
// Triggered when an ancestor GlassContainer (or any widget that sets
// InheritedLiquidGlass.avoidsRefraction = true) is in the tree. A second
// BackdropFilter inside the parent glass surface causes Impeller GPU tile-
// memory thrash, missing surfaces, and visible compositor artifacts.
//
// The correct iOS 26 behaviour for nested glass is a UIVibrancyEffect-style
// translucent tinted fill: no refraction, no blur, but rim and specular
// highlights are preserved. _VibrancyFill delivers exactly this.
//
// Isolated in a RepaintBoundary so that interaction animations (such as
// GlassGlow, touch scale, or saturation pulses on nested buttons) do NOT
// trigger repaints of the ancestor GlassContainer layer or sibling glass cards.
// Exterior drop shadow is suppressed: nested vibrancy controls sit flush on
// the host glass surface without casting exterior drop shadows onto it.
if (!useOwnLayer && (inherited?.avoidsRefraction ?? false)) {
return _wrapWithBacker(
baseSettings,
RepaintBoundary(
child: _VibrancyFill(
shape: shape,
settings: baseSettings,
child: child,
),
),
);
}
// ---- IP1: ACCESSIBILITY FAST-PATH ----------------------------------------
// iOS 26 glass degrades to a solid frosted panel when "Reduce Transparency"
// is enabled. We honour the equivalent Flutter signal (highContrast, which
// is the closest available platform proxy for isReduceTransparencyEnabled).
//
// When triggered, the entire glass shader pipeline is bypassed. The fallback
// is a ClipRRect + BackdropFilter(blur) + semi-opaque tinted container —
// still visually layered, but with no refraction, no specular, and no
// chromatic aberration. Zero GPU shader cost.
//
// GlassAccessibilityScope must be in the widget tree for this to activate;
// without it, defaults.reduceTransparency = false and we proceed normally.
// --------------------------------------------------------------------------
final accessibilityData = GlassAccessibilityData.of(context);
if (accessibilityData.reduceTransparency) {
return _wrapWithDecorations(
context,
baseSettings,
_FrostedFallback(
shape: shape,
settings: baseSettings,
clipBehavior: clipBehavior,
glowIntensity: glowIntensity,
isAccessibilityFallback: true,
isInteractive: isInteractive,
child: content,
),
);
}
// If we are on Skia/Web, we CANNOT use LiquidGlass.withOwnLayer or withOwnLayer
// because those will fall back to FakeGlass (solid color) inside the renderer.
// We MUST use our LightweightLiquidGlass to get actual glass effects.
// platformViewBackdrop forces the live BackdropFilter path even at premium:
// the premium shader's toImageSync backdrop can't capture a PlatformView, so
// over one it must use BackdropFilter (live) instead. The local/cheap checks
// are evaluated before the platform shader-support query (_canUseImpeller).
final bool canUsePremiumShader = !kIsWeb &&
!platformViewBackdrop &&
quality == GlassQuality.premium &&
_canUseImpeller;
if (!canUsePremiumShader) {
// 1. Detect Grouped Elevation
// When a parent provides the blur (Batch-Blur Optimization), we lose the
// "double-darkening" effect of nested blurs. We compensate with the
// densityFactor parameter (0.0-1.0) which triggers synthetic density physics
// in the shader to make elevated widgets "pop" against the background.
final bool shouldElevate =
allowElevation && (inherited?.isBlurProvidedByAncestor ?? false);
// Calculate density factor for shader (0.0 = normal, 1.0 = elevated)
final double densityFactor = shouldElevate ? 1.0 : 0.0;
// Normalise settings for the 2D lightweight shader to prevent it from looking
// overpowering when the user has tuned their settings for the 3D premium shader.
//
// BYPASS: When quality is explicitly GlassQuality.standard, the settings
// are already calibrated for the Standard renderer — skip normalization.
// Normalization only makes sense when adapting Premium-tuned settings to
// Standard; if the caller already knows they're on Standard, their values
// must be passed through unchanged so tuning sliders take full effect.
final bool skipNormalization = quality == GlassQuality.standard;
final standardSettings = _withoutNativeMaterial(baseSettings);
final LiquidGlassSettings normalizedSettings;
if (skipNormalization) {
normalizedSettings = standardSettings.copyWith(
glassColor: standardSettings.glassColor.withValues(
alpha: (standardSettings.effectiveGlassColor.a *
standardSettings.standardOpacityMultiplier)
.clamp(0.0, 1.0),
),
);
} else {
// Frosting normalization: adapts Premium settings for the 2D shader.
// Thickness scaled down (2D inner shadows look much thicker than 3D bevels).
// Light intensity scaled down (2D gradients look brighter than 3D speculars).
normalizedSettings = standardSettings.copyWith(
thickness: (standardSettings.effectiveThickness * 0.4)
.clamp(0.0, double.infinity),
lightIntensity:
(standardSettings.effectiveLightIntensity * 0.6).clamp(0.0, 10.0),
glassColor: standardSettings.glassColor.withValues(
alpha: (standardSettings.effectiveGlassColor.a *
standardSettings.standardOpacityMultiplier)
.clamp(0.0, 1.0),
),
);
}
// Apply subtle elevation boost to settings (preserves saturation!)
final color = normalizedSettings.effectiveGlassColor;
final effectiveSettings = shouldElevate
? LiquidGlassSettings(
glassColor:
color, // Removed flat +0.2 alpha boost for predictability
refractiveIndex: normalizedSettings.refractiveIndex,
thickness: normalizedSettings.effectiveThickness,
lightAngle: normalizedSettings.lightAngle,
lightIntensity: (normalizedSettings.effectiveLightIntensity * 1.2)
.clamp(0.0, 10.0),
chromaticAberration: normalizedSettings.chromaticAberration,
blur: normalizedSettings.effectiveBlur,
visibility: normalizedSettings.visibility,
saturation: normalizedSettings.effectiveSaturation,
ambientStrength:
(normalizedSettings.effectiveAmbientStrength * 0.4)
.clamp(0.0, 1.0),
glowIntensity: normalizedSettings.glowIntensity,
// Preserve whiten through the elevation rebuild; otherwise the
// whitening would silently drop to 0 for grouped/elevated
// surfaces such as bars.
whitenStrength: normalizedSettings.whitenStrength,
whitenGated: normalizedSettings.whitenGated,
bodyMode: normalizedSettings.bodyMode,
)
: normalizedSettings;
// If this is a container (allowElevation=false), we are providing a blur
// for all our children to use. We update the InheritedLiquidGlass tree.
if (!allowElevation) {
final Widget container = LightweightLiquidGlass(
shape: shape,
settings: effectiveSettings,
densityFactor: 0.0, // Containers are never elevated
glowIntensity: 0.0, // Containers don't glow
child: InheritedLiquidGlass(
settings: effectiveSettings,
quality: quality,
isBlurProvidedByAncestor: true,
child: content,
),
);
return _wrapWithDecorations(
context,
baseSettings,
_fadeLightweight(baseSettings, container),
);
}
// Elevated widgets use PATH B (no backgroundKey). They composite via
// SrcOver against the container's output.
final Widget lightweightWidget = LightweightLiquidGlass(
shape: shape,
settings: effectiveSettings,
densityFactor: densityFactor, // 0.0 or 1.0 based on elevation
glowIntensity:
glowIntensity * 0.35, // Normalise additive glow to match Impeller
child: content,
);
return _wrapWithDecorations(
context,
baseSettings,
_fadeLightweight(baseSettings, lightweightWidget),
);
}
// Impeller + Premium Path: Use the renderer's native path.
// Wrap in PremiumGlassTracker so GlassPerformanceMonitor can correlate
// slow raster frames with active premium surfaces.
//
// Force useOwnLayer when inside a GlassIsolationScope (e.g. GlassScaffold
// bottom bar). This gives bars their own compositing layer so body glass
// cards don't composite over bar buttons.
//
// NOTE: isInteractive is NOT included here. It only controls
// RepaintBoundary wrapping (lines below). Including it would force
// every GlassButton into its own compositing layer, breaking grouped
// rendering inside bars (e.g. BottomBarExtraBtn must blend with the
// tab pill, not render as a separate glass surface). Buttons that need
// independent refraction should set useOwnLayer: true explicitly.
//
// De-isolate children of the own-layer so nested glass (e.g. tab
// items inside a bottom bar) groups with this layer rather than
// creating additional own-layers (which would cause double-glass).
final effectiveUseOwnLayer =
useOwnLayer || GlassIsolationScope.isIsolated(context);
if (effectiveUseOwnLayer) {
// Resolve shadows for the GPU cutout method
final isDark = GlassTheme.brightnessOf(context) == Brightness.dark;
// Fallback to the CSS-style shadow for platforms with known saveLayer Impeller bugs
final useFallbackShadow =
kIsWeb || defaultTargetPlatform == TargetPlatform.windows;
final shadows =
(isDark || _FrostedFallback._isFlatEdge(shape) || useFallbackShadow)
? const <BoxShadow>[]
: baseSettings.effectiveShadow;
Widget premium = LiquidGlass.withOwnLayer(
shape: shape,
settings: settings,
shadows: shadows,
clipBehavior: clipBehavior,
clipExpansion: clipExpansion,
// De-isolate children so nested glass groups with this own-layer
// rather than creating its own (which causes double-glass).
// Carry the parent's defaultQuality through so quality hints
// (e.g. premium for bars) are preserved even when de-isolated.
child: GlassIsolationScope(
isolated: false,
defaultQuality: GlassIsolationScope.defaultQualityOf(context),
child: child,
),
);
final premiumTracker = _wrapWithBacker(
baseSettings,
PremiumGlassTracker(
child: premium,
),
);
// If we bypassed the GPU cutout shadow, apply the standard CSS-style shadow instead
if (useFallbackShadow &&
baseSettings.effectiveShadow.isNotEmpty &&
!isDark &&
!_FrostedFallback._isFlatEdge(shape)) {
return _wrapWithLightModeShadow(context, baseSettings, premiumTracker);
}
return premiumTracker;
} else {
// Grouped elements (e.g. inside GlassTabBar.bottom) rely on the ancestor's
// LiquidGlassLayer to provide the RepaintBoundary and BackdropGroup.
// IMPORTANT: Do NOT wrap grouped elements with the shadow Stack — it
// inserts a widget between the grouped glass and its ancestor blend
// group, breaking metaball morphing (the blend SDF pass requires
// grouped render objects to be direct descendants of the shared layer).
return PremiumGlassTracker(
child: LiquidGlass.grouped(
shape: shape,
clipBehavior: clipBehavior,
child: child,
),
);
}
}