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) {
final isVertical = direction == Axis.vertical;
// Calculate expansion rectangle based on thickness
final resolved = expansion.resolve(Directionality.of(context));
final resolvedExpansion = isVertical
? EdgeInsets.fromLTRB(
resolved.top,
resolved.left,
resolved.bottom,
resolved.right,
)
: resolved;
final rect = RelativeRect.lerp(
RelativeRect.fill,
RelativeRect.fromLTRB(
-resolvedExpansion.left,
-resolvedExpansion.top,
-resolvedExpansion.right,
-resolvedExpansion.bottom,
),
thickness,
);
final bool isStdPath =
quality == GlassQuality.standard || quality == GlassQuality.minimal;
// The indicator defaults to a perfect capsule: borderRadius=9999.0 is
// reduced by the shader's own `r = min(r, shortest)` clamp to
// min(half_width, half_height) at any indicator size — including during
// drag expansion where a finite value such as 32 would be smaller than
// the expanded half-height, leaving visible squarish corners.
//
// Callers that need visible corner geometry (e.g. GlassSegmentedControl)
// can pass a smaller borderRadius explicitly.
final shape = LiquidRoundedRectangle(borderRadius: borderRadius);
// 1. Background Indicator (Resting state)
// Fade out as the drag spring thickness increases toward 0.15.
final backgroundOpacity = (1.0 - (thickness / 0.15)).clamp(0.0, 1.0);
final backgroundIndicator = IgnorePointer(
child: Opacity(
opacity: backgroundOpacity,
child: DecoratedBox(
decoration: ShapeDecoration(
color: indicatorColor,
shape: shape,
shadows: shadows,
),
child: const SizedBox.expand(),
),
),
);
// 2. Glass Indicator (Active/Dragging state)
// We fade the glass in/out by setting `visibility` on the settings rather
// than wrapping the widget in `Opacity`.
final fade = thickness.clamp(0.0, 1.0);
final base =
settings != null ? _mergeWithBase(settings!) : baseIndicatorSettings;
// Stabilise the pinch UV shift against jelly spring micro-oscillation.
//
// The spring targets thickness=1.0 when active and overshoots, leaving
// `fade` oscillating in the 0.9–1.0 range while the pill settles.
// Using `fade` directly as the pinch multiplier amplifies this oscillation
// into the UV warp, making icons/labels jitter visibly through the lens.
//
// The quadratic ease-out formula 1−(1−f)² compresses the near-1.0
// oscillation range from ±0.10 → ±0.01 (≈10× reduction) while still
// mapping 0→0 and 1→1 cleanly. `visibility` is left on the raw `fade`
// so glass opacity still tracks the spring naturally — only the UV
// distortion is stabilised.
final stablePinchFade = 1.0 - (1.0 - fade) * (1.0 - fade);
final effectiveSettings = base
.copyWith(visibility: fade)
.copyWithPinch(stablePinchFade * pinchStrength);
final glassWidget = GlassEffect(
shape: shape,
settings: effectiveSettings,
quality: quality,
interactionIntensity: thickness,
backgroundKey: backgroundKey,
clipExpansion:
isVertical ? _jellyClipExpansionVertical : _jellyClipExpansion,
// rimThickness translation: Premium uses thickness as 3D glass depth
// (Impeller SDF — no visible border drawn). Standard uses rimThickness
// as a literal pixel-width border in the GLSL shader, so the same raw
// value (default 30) would produce a ~2.8 px ring (clamped 8.0 × 0.35).
//
// For graceful fallback parity — the primary use case is Premium quality
// with Standard as an automatic device fallback — we proportionally map
// the depth intent to an equivalent rim weight:
// anchor : thickness=30 (default) → 0.5 pre-norm → 0.175 px actual
// floor : 0.35 → minimum hairline even at very low thickness (5–20),
// where Premium still reads via optical depth but Standard
// would otherwise have a sub-pixel invisible rim.
// cap : 1.5 → prevents extreme thickness values producing thick rings.
rimThickness: isStdPath
? ((settings?.effectiveThickness ?? 30.0) * (0.5 / 30.0))
.clamp(0.35, 1.5)
: (settings?.effectiveThickness ?? 0.8).clamp(0.8, 8.0),
// iOS 26 Standard glass matching GlassSwitch/Slider pattern.
// settings.ambientRim (default 0) overrides the hardcoded floor — the
// omnidirectional ring that the directional key/kick highlights can't
// provide. Same field boosts the premium Fresnel rim, so one knob
// brightens the ring on either quality path.
ambientRim: (effectiveSettings.ambientRim > 0)
? effectiveSettings.ambientRim
: (isStdPath ? 0.08 : 0.1),
baseAlphaMultiplier:
isStdPath ? 0.08 : 0.2, // Match GlassSlider (near clear center)
edgeAlphaMultiplier:
isStdPath ? 0.15 : 0.4, // Match GlassSlider/Switch (subtle rim)
child: const GlassGlow(
// Whitelisted: Structural no-glow default.
glowColor: Color(0x00000000),
child: SizedBox.expand(),
),
);
// Drop shadow for the moving glass jelly. GlassEffect itself never paints
// LiquidGlassSettings.effectiveShadow (only container widgets like
// AdaptiveGlass do), so without this the jelly renders shadowless no
// matter what the caller sets. Gated on an EXPLICIT shadow/shadowElevation
// in the caller's indicator settings — the constructor default (1.0) must
// not grow a shadow under every existing indicator. Alpha tracks [fade] so
// the shadow materialises with the glass and vanishes at rest.
// Shadows only apply in light mode — same rule as every other
// effectiveShadow consumer (AdaptiveGlass, AdaptiveLiquidGlassLayer,
// the tab-bar shadow overlay).
final jellyShadowIsDark =
GlassTheme.brightnessOf(context) == Brightness.dark;
final explicitJellyShadows = (!jellyShadowIsDark &&
settings != null &&
(settings!.shadow != null ||
settings!.shadowElevation != _settingsDefaults.shadowElevation))
? effectiveSettings.effectiveShadow
: const <BoxShadow>[];
final shadowedGlass = explicitJellyShadows.isEmpty
? glassWidget
: CustomPaint(
painter: _OuterShadowPainter(
borderRadius: borderRadius,
shadows: explicitJellyShadows,
opacity: fade,
),
child: glassWidget,
);
// Mount early (0.01) so geometry is built before the indicator is visible.
// We MUST NOT wrap this in a RepaintBoundary because the jelly Transform
// below will apply a sub-pixel shift/scale. If we pre-rasterise the glass
// with a RepaintBoundary, the pre-computed AA will misalign with the pixel
// grid during the transform, causing stair-stepping on the edges.
final interactiveIndicator =
thickness > 0.01 ? shadowedGlass : const SizedBox.expand();
// Standard: background pill included inside Transform so the solid pill
// carries the jelly squish visually. The glass lens alone is too
// translucent on Standard (baseAlpha 0.08) to show the squish.
// Premium: glass lens only inside Transform — the Impeller SDF lens has
// full 3D optical contrast and carries the squish without the solid pill.
final glassChild = Stack(
clipBehavior: Clip.none,
children: [
if (paintBackground && isStdPath && backgroundOpacity > 0)
backgroundIndicator,
if (paintGlass && fade > 0.05) interactiveIndicator,
],
);
final indicatorBody = Stack(
clipBehavior: Clip.none,
children: [
// Rest-blur: an Apple-style backdrop frost behind the RESTING pill.
// Sigma is scaled by backgroundOpacity so it is full when settled and
// fades toward zero as the pill morphs into the glass lens (motion stays
// crisp). Sits at the bottom of the stack so the (translucent) pill +
// icons read on top of the frosted backdrop.
//
// The ClipRRect + BackdropFilter stay MOUNTED for the whole gesture — we
// gate only on [innerBlur] > 0, never on backgroundOpacity. Adding or
// removing a clip layer over an iOS PlatformView mid-gesture makes the
// engine reconstruct the platform-view clip-view chain, which cancels
// the in-flight touch and freezes an interactive bar overlaid on it.
// Fading the sigma (rather than unmounting the layer) keeps the look
// without the churn; a small sigma floor keeps it a real backdrop layer
// so the engine can't drop it on its own either.
if (paintBackground && innerBlur > 0)
Positioned.fromRelativeRect(
rect: rect!,
child: ClipRRect(
borderRadius: GlassDefaults.safeBorderRadius(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: (innerBlur * backgroundOpacity)
.clamp(0.001, double.infinity),
sigmaY: (innerBlur * backgroundOpacity)
.clamp(0.001, double.infinity),
),
child: const SizedBox.expand(),
),
),
),
// Premium: background pill is rigid (outside Transform). The Impeller
// glass lens has enough 3D contrast to carry the jelly on its own.
if (paintBackground && !isStdPath && backgroundOpacity > 0)
Positioned.fromRelativeRect(
rect: rect!,
child: backgroundIndicator,
),
// Jelly-physics Transform.
// Standard: wraps both the solid background pill + glass lens so the
// pill itself flexes (the only element with enough visual weight).
// maxDistortion is capped at 0.35 — enough to feel organic, below the
// threshold where 2D ClipPath corners start looking boxy.
// Premium: wraps only the glass lens. The 3D SDF shader absorbs the
// full 0.8 distortion naturally via optical pinch.
Positioned.fromRelativeRect(
rect: rect!,
child: Transform(
alignment: Alignment.center,
transform: DraggableIndicatorPhysics.buildJellyTransform(
velocity: direction == Axis.horizontal
? Offset(velocity, 0)
: Offset(0, velocity),
maxDistortion: isStdPath ? 0.35 : 0.8,
velocityScale: 10,
),
child: glassChild,
),
),
],
);
Widget positioning;
if (exactWidth != null && exactOffset != null) {
// Exact pixel positioning for scrollable mode with variable-width tabs
assert(
!isVertical,
'exactWidth/exactOffset positioning is horizontal-only',
);
positioning = Positioned(
left: exactOffset,
top: 0,
bottom: 0,
width: exactWidth,
child: indicatorBody,
);
} else {
// Fractional positioning for fixed-width tabs
positioning = Positioned.fill(
child: FractionallySizedBox(
widthFactor: direction == Axis.horizontal ? 1 / itemCount : null,
heightFactor: direction == Axis.vertical ? 1 / itemCount : null,
alignment: alignment,
child: indicatorBody,
),
);
}
return Positioned.fill(
child: Padding(
padding: padding,
child: Stack(
clipBehavior: Clip.none,
children: [positioning],
),
),
);
}