animateFromOffsets method

void animateFromOffsets(
  1. Map<TKey, ({double x, double y})> priorOffsets,
  2. Map<TKey, ({double x, double y})> currentOffsets, {
  3. required Duration duration,
  4. required Curve curve,
  5. required bool structuralAnimationsDisabled,
  6. double maxSlideDistance = double.infinity,
})

Installs FLIP slides for every node whose offset changed between priorOffsets and currentOffsets. See TreeController.animateSlideFromOffsets for the full contract.

structuralAnimationsDisabled = controller's animationDuration == Duration.zero — caller passes it in so the engine never reaches back into the controller for it. The engine ALSO short-circuits when duration is zero. Both gates exist in the original code and are preserved bit-for-bit.

Implementation

void animateFromOffsets(
  Map<TKey, ({double y, double x})> priorOffsets,
  Map<TKey, ({double y, double x})> currentOffsets, {
  required Duration duration,
  required Curve curve,
  required bool structuralAnimationsDisabled,
  double maxSlideDistance = double.infinity,
}) {
  if (structuralAnimationsDisabled || duration == Duration.zero) {
    // No-animation mode: drop any in-flight slide and return.
    if (hasActive) {
      _clearAllSlidesInternal();
      _ticker?.stop();
    }
    return;
  }

  // If the ticker isn't currently running, reset our mirror of its
  // elapsed value to 0 BEFORE the install loop reads it. The ticker's
  // internal elapsed restarts from 0 on the next [Ticker.start] call,
  // and we want new slides installed in this batch to capture
  // `slideStartElapsed = 0` so the first post-install tick computes
  // progress as `vsync_delta / duration` (not negative).
  if (_ticker == null || !_ticker!.isActive) {
    _lastTickElapsed = Duration.zero;
  }

  int installed = 0;
  final touched = <int>{};
  for (final entry in currentOffsets.entries) {
    final key = entry.key;
    final current = entry.value;
    final prior = priorOffsets[key];
    if (prior == null) continue;
    final rawDeltaY = prior.y - current.y;
    final rawDeltaX = prior.x - current.x;
    final existing = _slideAt(key);

    // Distance gate: applied to the COMPOSED Y delta. When existing is
    // null, composedY == rawDeltaY (subset of the same check). On
    // exceed: drop any in-flight entry, install nothing, row paints at
    // new structural position. The visual jump is bounded by
    // |existing.currentDelta| which was itself ≤ maxSlideDistance.
    final composedY = (existing?.currentDelta ?? 0.0) + rawDeltaY;
    if (composedY.abs() > maxSlideDistance) {
      if (existing != null) {
        _clearSlide(key);
        // Removing a touched entry mid-iteration is fine — touched is
        // populated only on install/compose.
      }
      continue;
    }

    if (existing == null) {
      if (rawDeltaY == 0.0 && rawDeltaX == 0.0) continue;
      final slide = SlideAnimation<TKey>(
        startDelta: rawDeltaY,
        startDeltaX: rawDeltaX,
        curve: curve,
      );
      slide.slideStartElapsed = _lastTickElapsed;
      slide.slideDuration = duration;
      _setSlide(key, slide);
      if (rawDeltaX != 0.0) _xActiveCount++;
      final nid = _nids[key];
      if (nid != null) touched.add(nid);
      installed++;
    } else {
      // Composition: preserve currently rendered visual position as the
      // new starting delta so the slide continues seamlessly.
      final composedX = existing.currentDeltaX + rawDeltaX;
      if (composedY == 0.0 && composedX == 0.0) {
        _clearSlide(key); // handles _xActiveCount decrement internally
        continue;
      }
      // No-op composition: this batch reports the row's painted
      // position is the same in both baseline and current snapshots
      // (rawDeltaY == 0 && rawDeltaX == 0), so the slide's existing
      // trajectory is still valid — animating from `currentDelta`
      // toward 0 reaches the same structural target either way.
      // Skipping the reset here avoids the failure mode the user
      // reported under rapid tapping of `Reparent ALL` / `Move N`:
      //
      //   * Tap N installs slide for row R. `currentDelta` = X.
      //   * Tap N+1 includes R in its batch but doesn't shift R
      //     structurally (some other rows are reordered around R but
      //     R's own position is unchanged). `rawDeltaY` = 0.
      //   * Pre-fix composition: `startDelta = currentDelta`,
      //     `progress = 0`, `slideStartElapsed = now`. The clock
      //     restarts; the slide is animated again from `currentDelta`
      //     to 0 over a fresh `slideDuration`.
      //   * Per rapid tap, `currentDelta` shrinks (it had been
      //     ticking) and the clock is reset again. Per-tick motion
      //     becomes sub-pixel within a few iterations, so the user
      //     observes "the slide isn't playing" — the row ends up at
      //     its correct structural target only when tapping stops
      //     and the slide can finally run for one full duration
      //     uninterrupted.
      //
      // Treat this entry as "un-touched" by this batch: it stays in
      // `_activeSlideNids`, so the un-touched re-baseline branch
      // below is the only authority over its clock — and that branch
      // honors `preserveProgressOnRebatch`, so a slide that already
      // had the flag set (via consume's step 8 / `_syncPreserveProgressFlags`
      // / re-promotion-on-scroll) will continue ticking on its
      // original install clock. `installed` is not incremented here
      // because no new install/composition happened.
      if (rawDeltaY == 0.0 && rawDeltaX == 0.0) {
        continue;
      }
      // Update X-active count based on transition between had-X and
      // has-X states. existing.startDeltaX reflects the entry's
      // current "has X work" status (lerp doesn't cross zero).
      final hadX = existing.startDeltaX != 0.0;
      final newHasX = composedX != 0.0;
      if (hadX && !newHasX) {
        _xActiveCount--;
      } else if (!hadX && newHasX) {
        _xActiveCount++;
      }
      existing.startDelta = composedY;
      existing.currentDelta = composedY;
      existing.startDeltaX = composedX;
      existing.currentDeltaX = composedX;
      existing.slideStartElapsed = _lastTickElapsed;
      // Adapt the slide's effective duration so per-frame motion is
      // visually perceptible. Under rapid cascaded `moveNode(animate:
      // true)` (e.g. the example app's `Reparent ALL` button tapped
      // quickly), each tap re-composes a row's slide with a new
      // `composedY = currentDelta + rawDeltaY`. When the existing
      // slide's `currentDelta` and the batch's `rawDeltaY` partially
      // cancel — common under random reparenting — `composedY` can
      // shrink relative to the original `rawDeltaY`. With the user-
      // set `slideDuration` applied unchanged, the per-frame motion
      // (`composedY / ticks_per_duration`) becomes sub-pixel and the
      // user perceives "the row didn't animate", even though the
      // engine has an active slide and the row eventually settles at
      // its correct structural position.
      //
      // Clamp the duration so per-tick motion is at least ~1 px.
      // This means small composedY → faster settle (the row "snaps"
      // quickly to its target with a brief but visible animation);
      // large composedY → user-set duration unchanged (smooth slide
      // over the full duration). No visual jump: `composedY` is still
      // the slide's start delta. Only the time over which it's
      // animated is shortened.
      //
      // The 16667 µs / px ratio assumes 60Hz; on higher-refresh
      // displays this slightly over-shortens (per-tick is bigger
      // than 1 px on a 120Hz device). Acceptable — it errs on the
      // side of more-visible motion.
      existing.slideDuration = _adaptDurationToVisibleMotion(
        duration,
        composedY: composedY,
        composedX: composedX,
      );
      // Composition creates a fresh slide semantically; reset the
      // preserve flag. Render layer re-marks via syncPreserveProgressFlags
      // for slides that are still ghosts after the batch.
      existing.preserveProgressOnRebatch = false;
      existing.progress = 0.0;
      existing.curve = curve;
      final nid = _nids[key];
      if (nid != null) touched.add(nid);
      installed++;
    }
  }

  // Re-baseline every active slide that this call did NOT touch — without
  // this, an un-touched slide's progress would snap to ~0 and lerp
  // currentDelta back to its ORIGINAL startDelta (visible jump).
  //
  // Slides marked [SlideAnimation.preserveProgressOnRebatch] (set by
  // the render layer for active edge-ghost and exit-phantom slides) are
  // skipped — their progress continues uninterrupted across batches so
  // that concurrent mutations (e.g. autoscroll commits) don't reset
  // ghost slides that should be settling smoothly.
  if (_activeSlideNids.length != touched.length) {
    for (final nid in _activeSlideNids) {
      if (touched.contains(nid)) continue;
      final entry = _slideByNid[nid]!;
      if (entry.currentDelta == 0.0 && entry.currentDeltaX == 0.0) {
        // Already settled — let the next tick mark complete and clear.
        continue;
      }
      if (entry.preserveProgressOnRebatch) continue;
      entry.startDelta = entry.currentDelta;
      entry.startDeltaX = entry.currentDeltaX;
      entry.slideStartElapsed = _lastTickElapsed;
      entry.progress = 0.0;
      // Keep the un-touched entry's existing curve and slideDuration.
    }
  }

  if (!hasActive) {
    _ticker?.stop();
    return;
  }
  if (installed == 0) return;

  // Ticker runs continuously while any slide is active; per-slide
  // progress derives from `(elapsed - slide.slideStartElapsed)
  // / slide.slideDuration`. Starting an already-active ticker is a
  // no-op. Per the class docstring: [Ticker.start] does NOT fire
  // callbacks synchronously, so this is safe inside
  // [RenderObject.performLayout]. `_lastTickElapsed` was already reset
  // above for fresh-ticker batches.
  final ticker = _ticker ??= _vsync.createTicker(_onSlideTick);
  if (!ticker.isActive) ticker.start();
}