animateFromOffsets method
void
animateFromOffsets(})
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();
}