snapshotVisibleOffsets method
A per-node snapshot of the painted position (in scroll-space) for every visible node. Painted y = structural y + that node's own slide delta.
Used as the "before" baseline for FLIP slide animation. Calling this again post-mutation produces the "after" baseline; the per-node difference is the new slide's startDelta.
Coordinate space: scroll-space, matching SliverTreeParentData.layoutOffset.
Slide deltas are paint-only: a node's delta shifts only that node's painted position and never contributes to the structural accumulator used for subsequent rows.
O(N_visible). Walks TreeController.visibleNodes independently of
_nodeOffsetsByNid, so the result is correct even under the bulk-only
fast path (where the nid-indexed array is not fresh for every node).
Implementation
Map<TKey, ({double y, double x})> snapshotVisibleOffsets() {
assert(
geometry != null,
"snapshotVisibleOffsets called before first layout",
);
// Hoist per-axis activity checks. The common case is no slides at
// all (idle) or Y-only slides (same-depth reorders). Skip the
// per-row delta reads in those cases.
final hasSlides = controller.hasActiveSlides;
final hasXSlides = hasSlides && controller.hasActiveXSlides;
final result = <TKey, ({double y, double x})>{};
double structural = 0.0;
final visible = controller.visibleNodes;
final orderNids = controller.orderNidsView;
final edgeExits = _phantomEdgeExits;
// Build the viewport snapshot lazily — only needed if there are
// active edge ghosts. Plan §7.2: ghost rows paint at the LIVE
// viewport edge, so snapshot must derive their painted Y from the
// current viewport, not a frozen capture.
final _ViewportSnapshot? viewportForGhosts =
edgeExits != null ? _currentViewportSnapshot() : null;
for (int i = 0; i < visible.length; i++) {
final nid = orderNids[i];
final key = visible[i];
final slideY = hasSlides ? controller.getSlideDeltaNid(nid) : 0.0;
final slideX = hasXSlides ? controller.getSlideDeltaXNid(nid) : 0.0;
final indent = controller.getIndent(key);
// Edge-ghost rows paint at `_edgeGhostBaseY + slideDelta`, not at
// `structural + slideDelta`. Override to keep snapshot consistent
// with what the edge-ghost paint pass actually paints — required
// for composition correctness when a ghost row gets re-mutated
// mid-slide.
if (edgeExits != null) {
final entry = edgeExits[key];
if (entry != null) {
result[key] = (
y: _edgeGhostBaseY(entry, viewportForGhosts!) + slideY,
x: indent + slideX,
);
structural += controller.getCurrentExtentNid(nid);
continue;
}
}
result[key] = (y: structural + slideY, x: indent + slideX);
structural += controller.getCurrentExtentNid(nid);
}
// Augment with exit-ghost rows (visible→hidden reparents whose slide
// is still in flight). Ghosts aren't in visibleNodes — they're
// rendered in a separate pass anchored to a visible parent — but if
// a ghost gets re-moved before its slide settles, the next staging
// call MUST capture its current painted position. Otherwise the
// baseline misses the ghost entirely and the new slide installs
// from a wrong starting point, producing a visible snap.
//
// Painted position of a ghost = anchor's painted position + ghost's
// own slideDelta. anchor's painted position is already in `result`
// (anchor is in visibleNodes by definition of the exit-phantom path).
final ghosts = _phantomExitGhosts;
if (ghosts != null) {
// Reuse the same hoist as the visible loop. Settled-but-unpruned
// ghosts have slide=0 either way, so skipping the read is safe.
for (final entry in ghosts.entries) {
final ghostKey = entry.key;
// Skip if the key is already in the visible loop's result —
// a ghost from a prior cycle whose key has been re-promoted to
// visible is being handled via the standard path now. The
// consume's lazy-prune will drop the stale ghost entry; until
// then, prefer the structural entry over the ghost-derived one.
if (result.containsKey(ghostKey)) continue;
final anchorKey = entry.value;
final anchorPos = result[anchorKey];
if (anchorPos == null) continue; // anchor itself disappeared
final ghostNid = controller.nidOf(ghostKey);
if (ghostNid < 0) continue;
final ghostSlideY =
hasSlides ? controller.getSlideDeltaNid(ghostNid) : 0.0;
final ghostSlideX =
hasXSlides ? controller.getSlideDeltaXNid(ghostNid) : 0.0;
result[ghostKey] = (
y: anchorPos.y + ghostSlideY,
x: anchorPos.x + ghostSlideX,
);
}
}
return result;
}