snapshotVisibleOffsets method

Map<TKey, ({double x, double y})> snapshotVisibleOffsets()

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;
}