compute static method
Computes the full LiquidMorphState for a single animation frame.
Parameters
rawValue— the raw, unclamped value fromAnimationController.unbounded. Legitimately exceeds[0, 1]during spring overshoot phases.finalDx— target horizontal displacement from the trigger center to the menu center, in logical pixels.finalDy— target vertical displacement from the trigger center to the menu center, in logical pixels.horizontalOffset— screen-edge clamping correction for horizontal overflow, accumulated by the menu positioning logic.verticalOffset— screen-edge clamping correction for vertical overflow.scaleDelta— geometric scale ratio of the morph (target area / source area, square-rooted). Use computeScaleDelta to derive this from sizes. Values > 1.5 engage adaptive damping. Defaults to1.0for backward compatibility — all existing callers without geometry information are unaffected.adaptiveDamping— explicit opt-in/opt-out override for adaptive mode. Whennull(default), adaptive mode is inferred fromscaleDelta.isClosing— whether the morph animation is closing back to origin. Defaults tofalse. Whentrue, avoids reverse-direction J-curve launch.
Implementation
static LiquidMorphState compute({
required double rawValue,
required double finalDx,
required double finalDy,
double horizontalOffset = 0.0,
double verticalOffset = 0.0,
double scaleDelta = 1.0,
bool? adaptiveDamping,
bool isClosing = false,
}) {
final clampedValue = rawValue.clamp(0.0, 1.0);
// Inject the close undershoot so Blob B bounces past the anchor on close.
// The open-side overshoot (rawValue > 1) is intentionally excluded because
// it causes an unwanted size wobble during the initial expansion.
final closeUndershoot = rawValue < 0.0 ? rawValue : 0.0;
// ── Adaptive mode detection ───────────────────────────────────────────────
// Engages when explicitly requested or when the scale ratio indicates a
// large-scale morph (button → sheet, button → tall menu).
final bool adaptive = adaptiveDamping ?? (scaleDelta > 1.5);
// ── Effective J-curve amplitude ───────────────────────────────────────────
// Small morphs (adaptive = false): full 2.5 amplitude for maximum teardrop.
// Large morphs (adaptive = true): damped inversely with vertical travel so
// physical overshoot stays within ~10–14 px.
final double amplitude = adaptive
? computeAdaptiveBackOutAmplitude(finalDy: finalDy)
: _backOutAmplitude;
// ── J-Curve Position ──────────────────────────────────────────────────────
// On open: The back-out curve overshoots past 1.0 before settling at 1.0,
// creating the "string pull" teardrop neck at maximum separation.
// On close: The surface returns home toward 0.0 following the spring's
// natural deceleration profile without any reverse-direction launch, plus
// the closing undershoot bounce past 0.0.
final double pathT;
if (isClosing) {
pathT = clampedValue + closeUndershoot;
} else {
pathT =
_BackOutCurve(amplitude).transform(clampedValue) + closeUndershoot;
}
// ── Size ─────────────────────────────────────────────────────────────────
// On close: smoothly tracks the spring contraction.
// On open:
// Small morphs: linearToEaseOut — starts fast, decelerates to the target.
// Large morphs (scaleDelta > 3): smootherstep — keeps early peel controlled
// through detach/travel, then blossoms into destination bounds.
final double rawSizeT;
if (isClosing) {
// Ease-in contraction: the droplet contracts rapidly early in the return flight,
// forming a compact liquid bead rather than shrinking linearly with position.
rawSizeT = math.pow(clampedValue, 1.4).toDouble();
} else if (adaptive && scaleDelta > 3.0) {
rawSizeT = _smootherStep(clampedValue);
} else {
rawSizeT = Curves.linearToEaseOut.transform(clampedValue);
}
final sizeT = rawSizeT + closeUndershoot;
// ── Closing Momentum Push (Blob A displacement) ───────────────────────────
// When the spring overshoots past 0 (rawValue < 0), Blob A is displaced
// proportionally to mirror the closing momentum.
//
// For large-scale morphs the raw displacement would be proportional to the
// huge travel distance, producing a jarring multi-pixel jolt on the trigger.
// The pushFactor caps the effective travel to [_maxPushTravelPx] so the
// bounce remains a subtle iOS-native nudge.
final double pushFactor;
if (adaptive) {
final travelDx = (finalDx + horizontalOffset).abs();
final travelDy = (finalDy + verticalOffset).abs();
final travelMag = math.sqrt(travelDx * travelDx + travelDy * travelDy);
pushFactor = travelMag > 0.0
? (_maxPushTravelPx / travelMag).clamp(0.0, 1.0)
: 1.0;
} else {
pushFactor = 1.0;
}
final pushDx = rawValue < 0.0
? (finalDx + horizontalOffset) * rawValue * pushFactor
: 0.0;
final pushDy = rawValue < 0.0
? (finalDy + verticalOffset) * rawValue * pushFactor
: 0.0;
// ── Blob B Displacement ───────────────────────────────────────────────────
final currentDx = finalDx * pathT;
final currentDy = finalDy * pathT;
// ── Anchor Scale ─────────────────────────────────────────────────────────
// On open: Shrinks the ghost trigger (Blob A) to 0 over the first 40% of
// the animation so the droplet cleanly detaches.
// On close: Blob A forms early (0.85 -> 0.45) so the trigger shape is fully
// present and receptive at the destination, allowing the incoming droplet
// to form an authentic SDF metaball bridge as it approaches.
final double anchorScale;
if (isClosing) {
anchorScale = (1.0 - (clampedValue - 0.45) / 0.40).clamp(0.0, 1.0);
} else {
anchorScale =
(1.0 - (clampedValue / _anchorEaseDuration)).clamp(0.0, 1.0);
}
// ── Metaball Blend ────────────────────────────────────────────────────────
final blendAttenuation = computeBlendAttenuation(scaleDelta);
final double blend;
if (isClosing) {
// Proximity re-merge: as the droplet re-enters the trigger's proximity
// zone (clampedValue < _closeProximityThreshold), ramp the SDF bridge up
// from 0 → _maxBlend using an easeOut curve.
//
// easeOut (fast-at-start) is intentional: the bridge snaps onto screen
// early as Blob A grows back, holds near peak for most of the window,
// then the droplet simply "lands" into the trigger shape. This matches
// the native iOS surface-tension spike that fires as the two shapes first
// touch, not as they fully absorb.
//
// Using easeIn (slow-at-start) caused the bridge to be a sub-perceptual
// flash that maxed out only in the last few frames before handoff.
//
// NOTE: this value is non-zero only when a LiquidGlassBlendGroup is
// present (GlassQuality.premium + Impeller). On standard / minimal
// quality the blend field is computed but the SDF layer never reads it.
final proximityT =
(1.0 - clampedValue / _closeProximityThreshold).clamp(0.0, 1.0);
final eased = Curves.easeOut.transform(proximityT);
blend = (eased * _maxBlend * blendAttenuation).clamp(0.0, _maxBlend);
} else {
// Open: separation between pathT (position) and sizeT (size) represents
// how far Blob B has pulled away from its anchor. Blend naturally scales
// with this, attenuated for large-scale morphs.
final separation = (pathT - sizeT).abs();
blend = (separation * _blendMultiplier * blendAttenuation)
.clamp(0.0, _maxBlend);
}
// ── Container Scale Pulse ─────────────────────────────────────────────────
// Subtle squeeze/swell during spring overshoot phases.
final containerScale = rawValue > 1.0
? 1.0 + (rawValue - 1.0) * 0.10 // open overshoot (negligible)
: rawValue < 0.0
? 1.0 + rawValue * 0.55 // close undershoot → visible squeeze
: 1.0;
// ── Phase ─────────────────────────────────────────────────────────────────
final phase = _derivePhase(rawValue, clampedValue);
return LiquidMorphState(
pathT: pathT,
sizeT: sizeT,
currentDx: currentDx,
currentDy: currentDy,
pushDx: pushDx,
pushDy: pushDy,
anchorScale: anchorScale,
blend: blend,
containerScale: containerScale,
phase: phase,
);
}