compute static method

LiquidMorphState compute({
  1. required double rawValue,
  2. required double finalDx,
  3. required double finalDy,
  4. double horizontalOffset = 0.0,
  5. double verticalOffset = 0.0,
  6. double scaleDelta = 1.0,
  7. bool? adaptiveDamping,
  8. bool isClosing = false,
})

Computes the full LiquidMorphState for a single animation frame.

Parameters

  • rawValue — the raw, unclamped value from AnimationController.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 to 1.0 for backward compatibility — all existing callers without geometry information are unaffected.
  • adaptiveDamping — explicit opt-in/opt-out override for adaptive mode. When null (default), adaptive mode is inferred from scaleDelta.
  • isClosing — whether the morph animation is closing back to origin. Defaults to false. When true, 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,
  );
}