Touch and motion
How a surface answers a finger, and the moving-glass model the slider and the nav pill are built from.
LiquidGlassTouch
classLiquidGlassFlex
classLiquidGlassFlexDeform
classLiquidGlassFlexAdvanced
enumLiquidGlassFlexOutline
classLiquidGlassMotionPill
classLiquidGlassLensMotion
classLiquidGlassLensMotionSpec
classLiquidGlassDraggable
How a glass surface answers a finger, bundled as one descriptor.
Touch response is not one effect. Apple's own description of Liquid Glass is that a control can "lift, flex, and energize" the moment you touch it — a deformation and a light, two separate things that fire on the same gesture. This is the group that holds them, so a surface's whole feel travels as a single value the way its whole look travels as a LiquidGlassStyle.
Today it carries flex. null means the surface does not respond at all — no gesture listener, no ticker, nothing added to the tree.
LiquidGlassLens(
touch: const LiquidGlassTouch(
flex: LiquidGlassFlex(stretch: 22, lean: 0.5),
),
child: const Center(child: Text('press me')),
)Constructors
const LiquidGlassTouch({this.flex})| Parameter | Type | Default | Description |
|---|---|---|---|
flex | LiquidGlassFlex? | — | Soft-body deformation under the finger: press and it compresses, drag and it elongates along the pull, pinches in the cross axis and leans after the finger, then springs back with a wobble. null leaves the surface rigid. |
const LiquidGlassTouch.flexing(LiquidGlassFlex this.flex)A response that is only a deformation — the common case, without the nesting.
Properties
| Name | Type | Description |
|---|---|---|
isEmpty | bool | Whether anything at all happens on touch. False for a group whose every member is unset, which callers treat exactly like a null group. |
Methods
| Signature | Description |
|---|---|
LiquidGlassTouch copyWith({LiquidGlassFlex? flex}) |
Declared in lib/src/widgets/utils/liquid_glass_touch.dart
Touch-driven soft-body deformation for a lens that does not move.
Press a lens and it compresses under your finger; drag and it elongates along the pull, pinches in the cross axis, and leans after your finger — then springs back with a wobble when you let go.
The model: four independent edges
Unlike a scale transform — which can only grow symmetrically around one anchor — this moves the lens's left, right, top and bottom edges independently. That is what makes it read as soft: the half of the shape nearest your finger deforms more than the far half.
The grab point drives that split. Grab near the right edge and pull right: the right edge shoots out while the left barely follows. Grab dead centre and the same pull elongates both sides evenly. grip controls how localized this is (0 = fully symmetric, 1 = fully local).
Volume is preserved, not gained
Whatever the along-axis edges gain, the cross-axis edges give back: (w + g)·(h - loss) = w·h. Elongate horizontally and it genuinely gets thinner. squeeze scales that give-back (1 = full preservation, 0 = none).
Hold and tap are separate responses
holdScale resizes the lens for as long as the finger stays down — the swell of a control being held. tapScale is a one-shot pop fired the moment a tap completes, so a quick click still reads as a click even though nothing was held long enough for the hold to arrive. Both are signed: positive swells, negative yields inward.
A press also deepens the optics (LiquidGlassFlexAdvanced.refractionBoost), which is what makes the surface read as glass being compressed rather than as a rubber button popping.
Only nine knobs
Everything you would reach for while dialling in a feel is a direct parameter. The five that are set once and forgotten — content follow, optical boost, and the three spring constants — live in LiquidGlassFlexAdvanced behind advanced, so they stay out of the way without being out of reach.
LiquidGlassLens(
touch: const LiquidGlassTouch(flex: LiquidGlassFlex()),
style: const LiquidGlassStyle(
shape: LiquidGlassShape.roundedRectangle(cornerRadius: 28),
),
child: const Center(child: Text('press me')),
)Constructors
const LiquidGlassFlex({ this.stretch = 13, this.squeeze = 0.70, this.lean = 0.50, this.grip = 0.70, this.compressInward = true, this.holdScale = 0.030, this.tapScale = 0.020, this.maxPull = 48, this.lockAxis, this.advanced = const LiquidGlassFlexAdvanced(), })| Parameter | Type | Default | Description |
|---|---|---|---|
stretch | double | 13 | Peak elongation along the pull axis, in logical pixels, reached as the drag saturates at maxPull. |
squeeze | double | 0.70 | How much of the along-axis gain is taken back from the cross axis. 1 preserves area exactly, 0 lets the lens simply grow. |
lean | double | 0.50 | How far the whole body slides after the finger, as a fraction of stretch. This is the "it is attached to my thumb" cue; 0 keeps the lens centred and only deforms it. |
grip | double | 0.70 | How localized the deformation is to the grab point, 0–1. 0 — every edge shares the deformation equally, wherever you touched. 1 — the edges nearest your finger take all of it. |
compressInward | bool | true | Whether driving the grabbed edge into the body compresses it. The deformation assumes the finger grips the surface and the far side lags. Pull an edge away from the middle and the body elongates. Push that same edge inward and the far side still lags, so with this on the body squashes — and squeeze then bulges the cross axis outward, since its give-back is signed both ways. false restores the original response, where the pull's magnitude drove the elongation and the shape grew whichever way it was pushed. That path is bit-identical to the pre-compression behaviour, not an approximation of it. How directional it is fades with how far off-centre the grab is and with grip: a grab on the exact middle has no near edge to push into, so it stretches symmetrically either way regardless of this flag. |
holdScale | double | 0.030 | How much the lens resizes while the finger stays down, before any drag — a signed fraction of its own size, weighted toward the grab point by grip. Positive grows it, the way a glass control swells under a press: 0.03 makes it 3% bigger on both axes. Negative compresses it, for a surface that yields inward instead. 0 leaves the size alone and lets LiquidGlassFlexAdvanced.refractionBoost carry the press on its own. This is the tap-and-hold response: it arrives on a spring and stays for as long as you hold. For the response to a quick click, see tapScale. A fraction rather than pixels so the same value reads identically on a 56pt button and a 320pt bar — a fixed pixel inset would be invisible on one and enormous on the other. |
tapScale | double | 0.020 | A one-shot size pop fired when a tap completes — the click feedback. Same signed-fraction units as holdScale; 0 disables it. A tap is a release with no meaningful drag (under kTouchSlop) inside kLongPressTimeout — Flutter's own definition, so this fires on exactly the gestures a GestureDetector.onTap would. Hold past that, or drag, and only holdScale applies. It exists because holdScale alone cannot express a click: a fast tap releases before the hold spring has arrived anywhere, so the lens barely moves. The pop is injected at release and decays over ~90 ms, and the edge springs turn that into a rebound. |
maxPull | double | 48 | Drag distance in logical pixels at which the pull saturates. The raw drag is passed through tanh(pull / maxPull), so small drags track the finger almost 1:1 while large ones stop dead at a predictable ceiling — the deformation never exceeds stretch. |
lockAxis | Axis? | — | Confines the drag to one axis: Axis.horizontal pins the top and bottom edges against a pull, Axis.vertical pins the left and right. null (the default) leaves all four free. For a shape whose proportions carry meaning — a bottom nav capsule, a segmented control, a slider track — the cross axis is not a free dimension. A bar that thickens under a sideways drag, or slides up off its own bottom margin, reads as broken rather than as soft. It covers everything the pull produces on that axis: elongation, lean, and the volume give-back — so a horizontally locked lens widens without thinning. It does not touch holdScale or tapScale. Those stay uniform on both axes, because a press is a swell rather than a stretch: flattening it onto one axis reads as the surface warping instead of yielding. So a locked bar still grows in both directions under a finger; it just will not change shape when dragged. Enforced on the spring targets, so a locked edge never loads a spring from the drag at all. |
advanced | LiquidGlassFlexAdvanced | const LiquidGlassFlexAdvanced() | The set-once knobs: content follow, optical boost, spring constants. Kept out of this constructor so the ones that matter stay visible. |
const LiquidGlassFlex.subtle()Barely-there deformation for large surfaces (cards, sheets, bars), where a big wobble would look wrong.
const LiquidGlassFlex.uniform()Symmetric, finger-following deformation that ignores the grab point (grip 0): touch anywhere and the lens deforms the same way, leaning hard after the drag rather than yielding where you pressed it.
const LiquidGlassFlex.pronounced()Loose, obviously-soft deformation for small controls (buttons, chips, app icons) where the jelly is the point.
Properties
| Name | Type | Description |
|---|---|---|
childFollow | double | See LiquidGlassFlexAdvanced.childFollow. |
refractionBoost | double | See LiquidGlassFlexAdvanced.refractionBoost. |
magnificationBoost | double | See LiquidGlassFlexAdvanced.magnificationBoost. |
stiffness | double | See LiquidGlassFlexAdvanced.stiffness. |
damping | double | See LiquidGlassFlexAdvanced.damping. |
releaseDamping | double | See LiquidGlassFlexAdvanced.releaseDamping. |
Methods
| Signature | Description |
|---|---|
LiquidGlassFlex copyWith({ double? stretch, double? squeeze, double? lean, … | Mirrors the constructor. To change one of the set-once knobs, go through the group: copyWith(advanced: e.advanced.copyWith(damping: 20)). |
Declared in lib/src/widgets/utils/liquid_glass_flex.dart
LiquidGlassFlexDeform#
One resolved frame of LiquidGlassFlex deformation.
The four edge values are how far each edge has moved outward from rest, in logical pixels; negative means the edge has moved inward.
Constructors
const LiquidGlassFlexDeform({ required this.left, required this.right, required this.top, required this.bottom, required this.childScaleX, required this.childScaleY, required this.childTranslateX, required this.childTranslateY, required this.pressAmount, })| Parameter | Type | Default | Description |
|---|---|---|---|
leftrequired | double | — | Outward travel of the left edge (grows the lens leftward). |
rightrequired | double | — | Outward travel of the right edge. |
toprequired | double | — | Outward travel of the top edge. |
bottomrequired | double | — | Outward travel of the bottom edge. |
childScaleXrequired | double | — | Horizontal component of the content's transform. Together with childTranslateX this is the glass's own map, blended toward identity by LiquidGlassFlex.childFollow. See childTransform. |
childScaleYrequired | double | — | Vertical component of the content's transform. See childTransform. |
childTranslateXrequired | double | — | Horizontal offset of the content's transform, in logical pixels, measured from the deformed box's top-left. See childTransform. |
childTranslateYrequired | double | — | Vertical offset of the content's transform. See childTransform. |
pressAmountrequired | double | — | Smoothed "finger is down" amount, 0–1. Drives LiquidGlassFlex.refractionBoost. |
Properties
| Name | Type | Description |
|---|---|---|
childTransform | Matrix4 | The content's transform, anchored at the deformed box's top-left. At LiquidGlassFlex.childFollow 1 this is literally the map the glass performs on itself, so the content cannot drift away from the surface — they are the same transform, not two tuned approximations. Derivation, per axis, in rest coordinates: the glass sends the rest span to , which is the linear map x' = sx·x - left with sx = (w + left + right) / w. Expressed from the deformed box's own origin (which sits at rest -left) that becomes x' = sx·x, and blending toward identity by f gives scale = 1 + f·(sx - 1), translate = (1 - f)·left. Column-major, so the last column carries the translation. Built literally rather than through translate/scale so the order of the two is unambiguous: scale first, then offset. |
isRest | bool | Whether there is nothing to draw differently — lets callers skip every wrapper widget entirely when the lens is at rest. |
widthDelta | double | Total width gained (left + right travel). |
heightDelta | double | Total height gained (top + bottom travel). |
originShift | Offset | How far the deformed lens's top-left sits from the rest top-left. Negative on each axis when the leading edges push outward. |
Methods
| Signature | Description |
|---|---|
Size sizeFrom(Size rest) | The deformed size for a lens whose rest size is rest. |
Offset scaleFrom(Size rest) | Deformed size ÷ rest size, as the shader's u_shapeScale. (1, 1) when undeformed. The shader evaluates the shape at REST size in a domain divided by this, so the deformation stretches the whole outline -- a circle becomes an ellipse instead of a stadium with flat runs. |
Offset clipScaleFrom(Size rest) | scaleFrom, but for the CLIPS rather than the shader. Identical in normal use. They part company only under LiquidGlassFlexOutline.shaderOnly, which stretches the shader's outline while pinning the clips circular -- the combination that eats the rim at a cap apex. |
Static members
| Signature | Description |
|---|---|
LiquidGlassFlexDeform none | The untouched, fully settled state. |
Declared in lib/src/widgets/utils/liquid_glass_flex.dart
The knobs of LiquidGlassFlex that are set once and then left alone — grouped so they stay out of the main constructor.
Reach for them when the feel is right but something else is off: the content is fighting the glass (childFollow), the optics are too shallow (refractionBoost), or the settle is too tight or too loose (the three spring constants).
Constructors
const LiquidGlassFlexAdvanced({ this.childFollow = 1, this.refractionBoost = 0.15, this.magnificationBoost = 0, this.stiffness = 320, this.damping = 24, this.releaseDamping = 17, })| Parameter | Type | Default | Description |
|---|---|---|---|
childFollow | double | 1 | How strongly the lens's content follows the deformation, 0–1. The child is always laid out at the lens's rest size and then scaled, so it stretches as pixels instead of re-flowing — text never re-wraps and rows never re-space. 0 leaves the content undisturbed (best for dense text), 1 makes it fully rubbery. |
refractionBoost | double | 0.15 | How much harder the glass BENDS while pressed, as a fraction. 0.15 means the refraction strength rises by up to 15% at full press. Only the bending. The separate magnificationBoost carries the zoom, because they are different effects and only one of them is refraction. |
magnificationBoost | double | 0 | How much the backdrop is ZOOMED while pressed, as a fraction of LiquidGlassRefraction.magnification. 0.15 magnifies what is behind the glass by up to 15% at full press. Off by default, and deliberately separate from refractionBoost. Magnification is not a refraction depth — it scales the content behind the lens about its centre, so a press reads as the background sliding rather than as glass bending. Apple keys lensing to a surface's SIZE, not to a finger being down, and its press cue is light instead. Turn this up only if you want that zoom on purpose. |
stiffness | double | 320 | Edge spring stiffness. |
damping | double | 24 | Edge spring damping while the finger is down. Higher → the edges track the drag more tightly. |
releaseDamping | double | 17 | Edge spring damping after release. Lower than damping on purpose: that is what produces the recoil wobble when you let go. |
Methods
| Signature | Description |
|---|---|
LiquidGlassFlexAdvanced copyWith({ double? childFollow, double? refractionB… |
Declared in lib/src/widgets/utils/liquid_glass_flex.dart
LiquidGlassFlexOutline#
How much of the stretched-outline behaviour is active. Debug only.
Exists so the fix can be judged against what it replaced on a running device -- a rim is one logical pixel wide, and the difference is not something a screenshot settles.
Values
| Value | Description |
|---|---|
legacy | Pre-fix. The shape keeps its authored pixel radius against the DEFORMED box, so a stretched circle grows flat runs and reads as a stadium. |
shaderOnly | The shader stretches the outline, but the clips stay circular. They then CROSS rather than nest: near a cap apex the clip sits inside the glass and shaves the rim off. This is the intermediate state, kept because it is the one that looks broken and is worth being able to point at. |
full | Shader and clips both stretch. The shipped behaviour. |
Declared in lib/src/widgets/utils/liquid_glass_flex.dart
The sliding thumb's whole thumb effect as one isolated, reusable component: the two-state morph (contracted ↔ expanded glass), the acceleration squash / stretch (LiquidGlassLensMotion), and the driven-lens rendering — so the same living glass pill can ride a slider track, a bottom nav bar, or anything else that moves it.
Division of labour
The HOST owns position and gesture: where the pill's centre is each frame (drags, glide springs, rubber bands — whatever its own model produces) and when the pill is grabbed. This widget owns everything the thumb itself did:
- Morph. While
active, the pill spring-grows fromrestSizetoactiveSize(0.4 s, ζ 0.6 — overshoot included); on deactivation it contracts on the softer spring (0.6 s, ζ 0.7). The optionalcover(e.g. the slider's white rest pill) fades out as the glass arrives, so the two read as one crossfade. - Squash/stretch. While
active,centeris sampled every frame into the acceleration model; the resulting deviation scales the pill oppositely on the two axes. Tracking starts on activation and resets instantly when the contract-back begins. - Rendering. The deformation is not a rebuilt capsule: the lens renders at its REST (morph) size and the size change rides the shader's
u_shapeScale+ matching clip scale viaLiquidGlassLens, so the end caps go elliptical instead of the shape being re-rounded at each new size — with the refraction band counter-scaled so it keeps its authored width at stretched caps.
Embedding
The widget fills whatever box the host gives it and positions the (overflowing) pill at center in that box's coordinates — place it as the child of a LiquidGlassView, or anywhere a LiquidGlassLens can render. The host just rebuilds with the new center; this widget's own ticker does the sampling.
Constructors
const LiquidGlassMotionPill({ super.key, required this.center, required this.active, required this.restSize, required this.activeSize, this.style, this.motion = const LiquidGlassLensMotionSpec(), this.expandStiffness = 247, this.expandDamping = 18.9, this.contractStiffness = 110, this.contractDamping = 14.7, this.cover, this.shadow, this.honorBackdropAlpha = true, this.onGlassVisibilityChanged, })| Parameter | Type | Default | Description |
|---|---|---|---|
key | Key? | — | |
centerrequired | Offset | — | The pill's centre in this widget's local coordinates. Update it every frame however the host moves — set directly from a drag, driven by a glide spring, anything. |
activerequired | bool | — | Whether the pill is "lifted": expanded to activeSize and tracking its own motion. Flip on grab, off when the pill should contract back to rest. |
restSizerequired | Size | — | Size of the contracted rest pill. |
activeSizerequired | Size | — | Size of the expanded (lifted) glass pill. |
style | LiquidGlassStyle? | — | Glass look; null keeps the tuned slider default. The default shape is a circular-cornered capsule tracking the morph height. |
motion | LiquidGlassLensMotionSpec | const LiquidGlassLensMotionSpec() | Tuning of the acceleration squash/stretch. |
expandStiffness | double | 247 | Expand spring, mapped as ω₀ = 2π / duration (0.4 s, ζ 0.6). |
expandDamping | double | 18.9 | |
contractStiffness | double | 110 | Contract spring (0.6 s, ζ 0.7). |
contractDamping | double | 14.7 | |
cover | Widget? | — | Widget drawn over the glass at rest and faded out as the morph expands — the slider passes its solid white pill here. It is its own layer above the lens, sized to the glass's VISIBLE extent (the deformed box plus the shader's edge-AA reach on the Impeller backdrop path) and clipped to the matching outline with the same stretch, so at rest it hides the glass completely and deforms as one body with it. Takes no pointers. It is clipped to the pill's own outline, so pass a plain fill: a rounded rectangle of its own would only cut back inside that outline at the caps, which is exactly the mismatch the clip removes. |
shadow | LiquidGlassShadow? | — | Contact shadow drawn around the pill — the soft dark band that hugs the rim and pools underneath. Normally left null: the shadow travels with the rest of the look, in style.appearance.shadow, and this is the override for a caller that has to state it apart from the style. null on both draws none. Either way it wraps the lens rather than living inside it, so the half that falls BELOW the pill survives instead of being clipped away; the pill also hands it the current outline stretch so the ring tracks an elliptical cap while the glass is squashed. That wrap is why the style's shadow is lifted out of the appearance before the lens sees it. See LiquidGlassShadow. It paints behind the glass, so an opaque cover at rest covers the shadow along with the glass beneath it. |
honorBackdropAlpha | bool | true | Whether the shader folds the captured backdrop's alpha into its coverage — required over an authored-transparent capture (a slider's track, a demo bar). Skia capture path only. |
onGlassVisibilityChanged | ValueChanged<bool>? | — | Fired when the glass leaves rest (true) and when the contraction lands back at rest and an opaque cover hides it again (false). The host owns active, but not this: the glass keeps rendering for the whole contract spring after active flips off. Lets the host drop work that only pays off while the glass shows — the slider runs its background capture exactly across this window. |
Declared in lib/src/widgets/components/liquid_glass_motion_pill.dart
The running simulation behind LiquidGlassLensMotionSpec. One instance per moving body; see the library doc for the calling contract.
Constructors
LiquidGlassLensMotion({this.spec = const LiquidGlassLensMotionSpec()})| Parameter | Type | Default | Description |
|---|---|---|---|
spec | LiquidGlassLensMotionSpec | const LiquidGlassLensMotionSpec() | Live tuning; takes effect from the next track. |
Properties
| Name | Type | Description |
|---|---|---|
isTracking | bool | Whether the effect is live (start without a stop yet). |
deviation | double | The current eased scale deviation, signed. 0 at rest. |
Methods
| Signature | Description |
|---|---|
void start() | Clears the history and samples from here on. Call when the effect goes live. |
void stop() | Stops sampling and drops the deformation in the same breath — an instant reset, masked by whatever animation runs over it. |
double track(Offset position, {required double now, required double dt}) | Samples position, prunes the window, and eases the deviation toward the averaged acceleration's answer. Call once per frame while tracking, with the caller's monotonic clock now (seconds) and the dt since the previous frame. Returns the new deviation. |
Declared in lib/src/widgets/utils/liquid_glass_lens_motion.dart
The acceleration→deformation physics of a moving glass body, extracted from the stretch slider's thumb into a component-agnostic class.
Feed it the mover's position once per frame — a slider thumb's centre, a nav-bar pill's centre, anything that travels — and read back a signed scale deviation:
scaleX = 1 + deviation scaleY = 1 − deviation
Accelerating into a drag stretches the body wide and flat; braking (or arriving from a glide) squashes it narrow and tall; constant speed leaves it undeformed — force, not speed, is what deforms it.
This class is physics only: the caller owns the Ticker, calls start when the effect goes live (the thumb lifts, the pill starts moving), track every frame with the current position, and stop the moment the effect ends (an instant reset, masked by whatever animation runs over it). How the deviation maps to pixels is each component's business.
Positions are 2-D on purpose: the two axes combine as avgX − avgY, since a vertical acceleration deforms the opposite way (Y-stretch is a negative deviation). A horizontal mover passes Offset(x, 0) and gets the collapsed one-axis behaviour.
Constructors
const LiquidGlassLensMotionSpec({ this.sampleWindow = 0.3, this.sensitivity = 0.00007, this.maxDeformation = 0.3, this.responseTime = 0.18, })| Parameter | Type | Default | Description |
|---|---|---|---|
sampleWindow | double | 0.3 | How many seconds of recent motion are averaged to read the force — bigger = calmer, smaller = twitchier. |
sensitivity | double | 0.00007 | The gain from acceleration (px/s²) to scale deviation — how strongly a given force deforms the body. |
maxDeformation | double | 0.3 | The hard cap on |deviation| — how far the two scales may stray from 1 (clamped for visual stability). |
responseTime | double | 0.18 | Seconds for the deviation to ease toward the acceleration's answer, instead of snapping to it every frame. 0 restores the frame-locked response; higher = slower, dreamier. |
Methods
| Signature | Description |
|---|---|
LiquidGlassLensMotionSpec copyWith({ double? sampleWindow, double? sensitiv… |
Declared in lib/src/widgets/utils/liquid_glass_lens_motion.dart
Wraps child so a pan gesture drags it, with the same smoothness as the built-in drag — and without baking drag into the lens itself.
The drag offset lives in an internal ValueNotifier, and only a Transform.translate rebuilds on each pan; child stays a stable subtree, so a wrapped LiquidGlassLens (or any glass) re-runs its shader/capture pipeline once, not every frame. The lens keeps sampling the backdrop correctly at its dragged position because the renderer resolves its screen rect through the full layer transform.
LiquidGlassDraggable(
child: LiquidGlassLens(
style: const LiquidGlassStyle(
shape: LiquidGlassShape.squircle(cornerRadius: 44),
),
child: const Center(child: Text('drag me')),
),
)Constructors
const LiquidGlassDraggable({ super.key, required this.child, this.enabled = true, this.initialOffset = Offset.zero, this.onChanged, })| Parameter | Type | Default | Description |
|---|---|---|---|
key | Key? | — | |
childrequired | Widget | — | The widget to make draggable (e.g. a LiquidGlassLens). |
enabled | bool | true | Whether dragging is active. When false the child is returned as-is (no gesture, no transform). |
initialOffset | Offset | Offset.zero | The starting offset from the child's layout position. |
onChanged | ValueChanged<Offset>? | — | Called with the new offset whenever the drag moves. |
Declared in lib/src/widgets/components/liquid_glass_draggable.dart
Comments
Comments are GitHub Discussions — reply from either place.