animateFromOffsets method
Installs FLIP slides for every node whose offset changed between
priorOffsets and currentOffsets. See
TreeController.animateSlideFromOffsets for the full contract.
structuralAnimationsDisabled = the calling family's spec is
zeroed (reorderSlide for this public path; the drop-settle
channel passes its own family's flag), passed in so the engine never
reaches back into the controller for it. An explicit zero duration
is treated the same way. DISABLED-MODE SPLIT: either gate means this
call CREATES no motion, so nothing fresh installs, but existing
entries are NOT destroyed: entries
whose bases this batch moved are re-based on their OWN
duration/curve so they continue seamlessly. Stopping existing
motion is an explicit transition event (purgeActive), never a
side effect of another family's install call.
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) {
// Disabled-mode split: refuse fresh installs; re-base survivors.
// O(1) on the dominant no-actives path (`.disabled` configs).
if (!hasActive) {
return;
}
if (_ticker == null || !_ticker!.isActive) {
_lastTickElapsed = Duration.zero;
}
// Iterate ACTIVES (a handful), not the maps (every visible row).
// Copy: _clearSlide mutates the set mid-iteration.
final touched = <int>{};
for (final nid in _activeSlideNids.toList()) {
final entry = _slideByNid[nid]!;
final key = _nids.keyOfUnchecked(nid);
final current = currentOffsets[key];
final prior = priorOffsets[key];
if (current == null || prior == null) {
continue; // Absent from this batch: re-baselined below.
}
final rawDeltaY = prior.y - current.y;
final rawDeltaX = prior.x - current.x;
final composedY = entry.currentDelta + rawDeltaY;
final composedX = entry.currentDeltaX + rawDeltaX;
if (composedY.abs() > maxSlideDistance) {
_clearSlide(key);
continue;
}
if (composedY == 0.0 && composedX == 0.0) {
_clearSlide(key); // Painted where it belongs: no slide left.
continue;
}
if (rawDeltaY == 0.0 && rawDeltaX == 0.0) {
// No-op for this entry: the un-touched branch below is the
// only authority over its clock (mirrors the enabled path's
// no-op composition rule).
continue;
}
final hadX = entry.startDeltaX != 0.0;
final newHasX = composedX != 0.0;
if (hadX && !newHasX) {
_xActiveCount--;
} else if (!hadX && newHasX) {
_xActiveCount++;
}
entry.startDelta = composedY;
entry.currentDelta = composedY;
entry.startDeltaX = composedX;
entry.currentDeltaX = composedX;
entry.slideStartElapsed = _lastTickElapsed;
entry.progress = 0.0;
// KEEP entry.slideDuration and entry.curve: the staged timing
// belongs to the fresh installs this branch refuses.
entry.preserveProgressOnRebatch = false;
entry.installStamp++;
touched.add(nid);
}
_rebaselineUntouched(touched);
// "hasActive implies ticker active" holds at every stop site, and
// this branch only mutates pre-existing actives, so ensure-start keeps
// the invariant locally enforced WITHOUT threading the enabled
// tail's `installed == 0` early-return (which always fires here).
final ticker = _ticker ??= _vsync.createTicker(_onSlideTick);
if (!ticker.isActive) ticker.start();
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 within 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 or 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 as identical in the baseline and current snapshots
// (rawDeltaY == 0 && rawDeltaX == 0), so the existing trajectory
// is still valid. Animating from `currentDelta` toward 0 reaches
// the same structural target either way.
//
// Resetting the clock here instead would STARVE the slide under a
// rapid burst of batches that keep including this row without
// moving it. Each reset restarts the animation from a
// `currentDelta` that has already ticked down, so per-frame motion
// shrinks toward sub-pixel and the row reads as frozen until the
// burst stops and one full duration finally runs uninterrupted.
//
// Treat the entry as UN-TOUCHED by this batch: it stays in
// `_activeSlideNids`, so the re-baseline branch below is the sole
// authority over its clock. That branch honors
// `preserveProgressOnRebatch`, so a slide already carrying the
// flag keeps ticking on its original install clock. `installed`
// is not incremented, because nothing was installed or composed.
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 stays
// perceptible. Under rapid cascaded `moveNode(animate: true)`,
// each batch re-composes the row with a new
// `composedY = currentDelta + rawDeltaY`. When the existing
// `currentDelta` and the batch's `rawDeltaY` partially cancel,
// which is common under random reparenting, `composedY` shrinks
// relative to the original `rawDeltaY`. Applying the caller's
// `slideDuration` unchanged then spreads that small delta over the
// full time, so per-frame motion drops below a pixel and the row
// reads as not animating, even though the slide is active and
// does settle correctly.
//
// Clamping keeps per-tick motion at [_minPxPerTick] or better: a
// small composedY settles faster, as a brief but visible move,
// while a large composedY keeps the caller's duration. Neither
// case jumps, since `composedY` is still the start delta; only
// the time it animates over changes.
//
// The microseconds-per-pixel ratio assumes 60Hz, so a
// higher-refresh display over-shortens slightly. That errs toward
// more visible motion, which is the safe direction.
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;
// Mark the in-place retarget so a completion cleanup collected
// BEFORE this composition (same tick) cannot mistake the fresh
// slide for the entry that completed.
existing.installStamp++;
final nid = _nids[key];
if (nid != null) touched.add(nid);
installed++;
}
}
_rebaselineUntouched(touched);
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();
}