performLayout method
Do the work of computing the layout for this render object.
Do not call this function directly: call layout instead. This function is called by layout when there is actually work to be done by this render object during layout. The layout constraints provided by your parent are available via the constraints getter.
If sizedByParent is true, then this function should not actually change the dimensions of this render object. Instead, that work should be done by performResize. If sizedByParent is false, then this function should both change the dimensions of this render object and instruct its children to layout.
In implementing this function, you must call layout on each of your children, passing true for parentUsesSize if your layout information is dependent on your child's layout information. Passing true for parentUsesSize ensures that this render object will undergo layout if the child undergoes layout. Otherwise, the child can change its layout information without informing this render object.
Some special RenderObject subclasses (such as the one used by OverlayPortal.overlayChildLayoutBuilder) call applyPaintTransform in their performLayout implementation. To ensure such RenderObjects get the up-to-date paint transform, RenderObject subclasses should typically update the paint transform (as reported by applyPaintTransform) in this method instead of paint.
Implementation
@override
void performLayout() {
debugPerformLayoutCount++;
debugLastParentDataRefreshIterationCount = 0;
debugLastParentDataCumulativeBuilds = 0;
debugStickyOffsetAuthorityMismatchCount = 0;
final constraints = this.constraints;
// This sliver's layout and paint code assume a vertical-forward axis.
// Child constraints, offset math, sticky pinning and hit-testing all use
// plain (x = indent, y = layoutOffset) coordinates with no axis mapping.
// Running in any other axis/growth/reverse configuration silently renders
// incorrectly, so fail loudly in debug builds.
final bool axisOk =
constraints.axis == Axis.vertical &&
constraints.axisDirection == AxisDirection.down &&
constraints.growthDirection == GrowthDirection.forward;
if (!axisOk) {
// Release-safe degraded behavior: report zero geometry rather than
// miscompute against vertical-down assumptions. Mirrors the empty-tree
// pattern just below: didStartLayout before setting geometry,
// didFinishLayout after.
//
// The fallback runs BEFORE the assert so that even in debug builds
// (where the assert throws and the framework catches it) downstream
// layout/semantics/paint see valid zero geometry instead of a null
// SliverGeometry. Without this ordering, the framework cascades to
// multiple downstream null-check errors trying to walk an unlaid sliver.
childManager?.didStartLayout();
geometry = SliverGeometry.zero;
childManager?.didFinishLayout();
}
assert(
axisOk,
"SliverTree currently supports only vertical, forward-growing axes "
"(Axis.vertical, AxisDirection.down, GrowthDirection.forward). Got "
"axis=${constraints.axis}, axisDirection=${constraints.axisDirection}, "
"growthDirection=${constraints.growthDirection}.",
);
if (!axisOk) return;
childManager?.didStartLayout();
final visibleNodes = controller.visibleNodes;
if (visibleNodes.isEmpty) {
// An animated mutation may have staged a FLIP baseline and then
// emptied the tree in the same frame. Discard it here, under
// first-wins staging a stranded baseline would block every later
// beginSlideBaseline, and when the tree repopulates the stale
// pre-empty offsets would be consumed as the FLIP "before" of an
// unrelated mutation (wrong one-frame deltas). Ghost/phantom
// state is likewise keyed against rows that no longer exist,
// mirror the controller-swap reset. Scroll bookkeeping restarts
// fresh with the repopulated tree.
_composer.reset();
_phantomExitGhosts = null;
_phantomClipAnchors = null;
_lastObservedScrollOffset = double.nan;
_structureChanged = true;
_lastVisibleNodeCount = 0;
// Nothing was admitted, so no bound was honored either; leaving a
// stale one would let a later tick read "already admitted" for a
// window this layout never opened.
_admittedSlideBound = 0.0;
geometry = SliverGeometry.zero;
childManager?.didFinishLayout();
return;
}
// Step 0a: Prune freed/settled entries from `_phantomExitGhosts`
// every layout. Runs unconditionally (not just when slides are idle)
// so dead entries don't accumulate during active slide cycles. Paint
// passes below can iterate these maps read-only because of this prune.
_pruneSettledPhantomExitGhosts();
// Step 0b: Same for the composer ghost registry. `pruneSettled`
// handles both freed-key (nid < 0) and settled (both deltas == 0)
// entries. When all FLIP slides have settled, we additionally drop
// everything via `clearAll` so the map shrinks back to empty.
//
// Both branches are FLIP-only. A held make-room preview keeps the
// composed `hasActiveSlides` true for a whole drag, so gating on it
// sent every layout down the prune branch, where the composed
// criterion then retained any ghost the preview had shifted.
if (_composer.hasGhosts) {
if (!controller.hasActiveFlipSlides) {
_composer.ghosts.clearAll();
} else {
_composer.ghosts.pruneSettled();
}
}
_ensureLayoutCapacity();
// Detect structure changes
if (controller.structureGeneration != _lastStructureGeneration) {
_structureChanged = true;
_sticky.dirty = true;
_lastStructureGeneration = controller.structureGeneration;
}
if (visibleNodes.length != _lastVisibleNodeCount) {
_structureChanged = true;
_sticky.dirty = true;
}
final scrollOffset = constraints.scrollOffset;
final remainingPaintExtent = constraints.remainingPaintExtent;
final remainingCacheExtent = constraints.remainingCacheExtent;
final crossAxisExtent = constraints.crossAxisExtent;
// Cache region bounds: per sliver protocol, remainingCacheExtent starts
// at scrollOffset + cacheOrigin (cacheOrigin is typically <= 0).
final cacheOrigin = constraints.cacheOrigin;
final cacheStart = scrollOffset + cacheOrigin;
final cacheEnd = cacheStart + remainingCacheExtent;
// Slide-pipeline ordering:
//
// 1. Build the current viewport snapshot.
// 2. If scroll changed since last layout and edge ghosts exist,
// normalize the ghost map for the current viewport, handle
// re-promotions, direction flips, and stays-same. When a
// pending mutation baseline exists, normalization runs WITHOUT
// installing standalone slides so the upcoming consume owns
// the single animation batch for this layout (avoids two
// independent `animateSlideFromOffsets` calls in the same
// frame). When no pending baseline exists, normalization
// installs slides directly.
// 3. Consume the pending mutation baseline. After step 2 the
// ghost map already reflects the current viewport, so consume
// composes against fresh state instead of the stale frozen
// `edgeY` the previous implementation carried.
// 4. Update `_lastObservedScrollOffset` to the new scroll.
//
// `snapshotVisibleOffsets()` walks `visibleNodes` with
// `getCurrentExtent`, which is independent of Pass 1's per-nid
// offset array, so all three steps are safe before Pass 1.
final currentViewport = _currentViewportSnapshot();
final currentScroll = currentViewport.scrollOffset;
final scrollChanged =
!_lastObservedScrollOffset.isNaN &&
currentScroll != _lastObservedScrollOffset;
if (scrollChanged && _composer.hasGhosts) {
_composer.ghosts.normalizeForViewport(
viewport: currentViewport,
installStandaloneSlides: !_composer.isBaselineStaged,
);
}
_consumeSlideBaselineIfAny(currentViewport: currentViewport);
_lastObservedScrollOffset = currentScroll;
// FLIP-slide overreach (Option A): during a slide, a row's painted y
// can differ from its structural y by up to `slideOverreach` px in
// either direction. Widen the effective cache region by that amount
// so rows whose painted y lies in the viewport, but whose structural
// y is outside the normal cache region, still get built. Without
// this, a swap of two large subtrees leaves a visible gap at the slot
// where a sliding row should appear (no child created for it), and
// the gap does NOT resolve on scroll because the build decision still
// only considers structural offsets. Overreach shrinks to 0 as the
// slide progresses (see [TreeController.composedSlideAbsDeltaBound]),
// so the transient overbuild contracts with the animation.
//
// Future optimization (Option B): replace this blanket clamp with a
// per-entry precise union. For each active slide, compute the
// structural index range whose painted y (structural + currentDelta)
// intersects the cache region, then union those ranges with the
// normal cache-region index range. This eliminates the transient
// overbuild for the common case of a few small slides, at the cost
// of a per-entry scan every frame. Worth doing only when the
// overbuild measurably hurts, large-subtree swaps are rare and
// short-lived, so the blanket clamp is usually fine.
// Summed bound, not max: a row can carry a FLIP delta AND a held
// make-room offset at once (drag started mid-FLIP), and the composed
// displacement reaches the sum. Identical to the max whenever only
// one engine is active.
final slideOverreach = controller.composedSlideAbsDeltaBound;
_admittedSlideBound = slideOverreach;
final effectiveCacheStart = cacheStart - slideOverreach;
final effectiveCacheEnd = cacheEnd + slideOverreach;
// ────────────────────────────────────────────────────────────────────────
// PASS 1: Calculate offsets and extents
// ────────────────────────────────────────────────────────────────────────
double totalScrollExtent;
final bool hasAnimations = controller.hasActiveAnimations;
// Fetch the bulk-animation snapshot once so downstream branches share a
// single read of value/generation/membership. Per-key membership
// queries inside _rebuildBulkCumulatives go through this snapshot too.
final BulkAnimationData<TKey> bulkData = controller.bulkAnimationData();
final bool bulkOnly = bulkData.isValid && !controller.hasOpGroupAnimations;
if (bulkOnly) {
// Fast path: bulk animation only. Every node's offset is a scalar
// function of position via the precomputed cumulatives. Avoid touching
// _nodeOffsetsByNid for nodes outside the cache region, that write
// is what the per-frame O(N) cost was buying.
final bulkGen = bulkData.generation;
final n = visibleNodes.length;
if (!_bulkCumulativesValid ||
_bulkCumulativesCount != n ||
bulkGen != _lastBulkAnimationGeneration ||
_structureChanged) {
_rebuildBulkCumulatives(visibleNodes, bulkData);
_lastBulkAnimationGeneration = bulkGen;
_structureChanged = false;
}
_bulkValueCached = bulkData.value;
totalScrollExtent = _offsetAtVisibleIndex(n);
_lastFrameUsedBulkCumulatives = true;
} else if (_structureChanged || _lastFrameUsedBulkCumulatives) {
// Either the visible order changed OR we just exited the bulk-only
// fast path, in both cases the per-nid offset/extent arrays are
// not guaranteed fresh for every visible node, so do a full walk.
_bulkCumulativesValid = false;
_lastFrameUsedBulkCumulatives = false;
totalScrollExtent = 0.0;
final orderNids = controller.orderNidsView;
final n = visibleNodes.length;
for (int i = 0; i < n; i++) {
final nid = orderNids[i];
_nodeOffsetsByNid[nid] = totalScrollExtent;
final extent = controller.getCurrentExtentNid(nid);
_nodeExtentsByNid[nid] = extent;
totalScrollExtent += extent;
}
_structureChanged = false;
} else if (!hasAnimations && !_animationsWereActive) {
// Pure scrolling: no animations active now or last frame.
// Offsets and extents are unchanged, reuse cached total.
totalScrollExtent = _lastTotalScrollExtent;
} else if (hasAnimations) {
// Active animation frame: only indices at or beyond the first
// animating node can have changed offsets/extents. Everything
// before them has stable cached values from the prior frame.
final firstAnimIdx = controller.computeFirstAnimatingVisibleIndex();
if (firstAnimIdx >= visibleNodes.length) {
// Animating nodes exist but none are in the visible order
// (e.g. an animation on a subtree that was moved out of view).
// Nothing to recompute here.
totalScrollExtent = _lastTotalScrollExtent;
} else {
final orderNids = controller.orderNidsView;
if (firstAnimIdx == 0) {
totalScrollExtent = 0.0;
} else {
final prevNid = orderNids[firstAnimIdx - 1];
totalScrollExtent =
_nodeOffsetsByNid[prevNid] + _nodeExtentsByNid[prevNid];
}
for (int i = firstAnimIdx; i < visibleNodes.length; i++) {
final nid = orderNids[i];
final newExtent = controller.getCurrentExtentNid(nid);
_nodeOffsetsByNid[nid] = totalScrollExtent;
_nodeExtentsByNid[nid] = newExtent;
totalScrollExtent += newExtent;
}
}
} else {
// Transitional frame: no active animations this frame, but there
// were last frame. Some just-settled nodes may have their cached
// extent stuck at an intermediate interpolated value if the
// settling frame fired before our last layout. Walk the list with
// the extent-equality short-circuit so stable-prefix nodes stay
// cheap and only changed nodes get rewritten.
totalScrollExtent = 0.0;
bool foundAnimating = false;
final orderNids = controller.orderNidsView;
for (int i = 0; i < visibleNodes.length; i++) {
final nid = orderNids[i];
final newExtent = controller.getCurrentExtentNid(nid);
final oldExtent = _nodeExtentsByNid[nid];
if (!foundAnimating && oldExtent == newExtent) {
// Structure is stable in this branch, so the prior-layout slot
// value is valid; no null-vs-zero ambiguity to guard against.
totalScrollExtent = _nodeOffsetsByNid[nid] + newExtent;
} else {
foundAnimating = true;
_nodeOffsetsByNid[nid] = totalScrollExtent;
_nodeExtentsByNid[nid] = newExtent;
totalScrollExtent += newExtent;
}
}
}
// Pass 1 is done: does `_nodeOffsetsByNid` now describe EVERY visible
// row, off-cache ones included? Each non-bulk branch above writes the
// full visible prefix, so yes; the bulk fast path maintains only the
// cache region, so no. That includes a bulk frame which LEAVES the
// fast path later in this method: Pass 2's fall-off rewrites just the
// tail from the first changed row, so earlier off-cache slots still
// hold the previous non-bulk frame's values.
//
// Frame-local by construction: written here, refined by the sticky
// block's full recompute below, and read by the parentData refresh,
// all within this one `performLayout`. Nothing carries it across
// frames, so there is no stale value to reset anywhere.
bool offsetsAuthoritative = !bulkOnly;
// ────────────────────────────────────────────────────────────────────────
// PASS 2: Create children for nodes in cache region
// ────────────────────────────────────────────────────────────────────────
// Clear prior-layout cache-region flags in one memset-style pass.
// Admission then flags this frame's members one at a time as it admits
// them; there is no slice of members to mark, the admitted set is
// sparse inside [cacheStartIndex, cacheEndIndex).
// Sparse clear of last frame's writes. Iterate the nids we wrote
// last frame instead of memset'ing the whole nid-indexed array, the
// array's length tracks nidCapacity, which grows monotonically and
// dwarfs the actual cache-region size on a long-lived tree.
for (int i = 0; i < _writtenCacheRegionNidsLen; i++) {
final nid = _writtenCacheRegionNids[i];
if (nid < _inCacheRegionByNid.length) {
_inCacheRegionByNid[nid] = 0;
}
}
_writtenCacheRegionNidsLen = 0;
final cacheStartIndex = _findFirstVisibleIndex(effectiveCacheStart);
// Dispatch the per-iteration `if (_bulkCumulativesValid)` branch out of
// the loop body, it's invariant across one loop run, so a single
// up-front decision replaces N per-iteration branches. Here it picks
// the admit CALLBACK and, on the bulk arm, builds the
// [BulkAdmissionView]; the rule itself is ONE implementation serving
// both arms, in [LayoutAdmissionPolicy.admit].
//
// Bulk arm: scalar offset = _stableCumulative[i] + value *
// _bulkFullCumulative[i]. Only the DECISION moved. The per-nid slot
// writes stay here, in the callback, because this object owns those
// arrays and owns the extent arithmetic that has to stay bitwise equal
// to the Pass 2 measurement.
//
// Both arms: dual-view (live/post) admission cap that pre-mounts
// post-animation visible rows during a collapse and caps mass-mounting
// during an expand, under a paint-region floor that admits any
// non-animating row the viewport paints whatever the two views say.
// Lives in [LayoutAdmissionPolicy.admit].
final BulkAdmissionView<TKey>? bulkView;
final void Function(int nid, int visibleIndex) onAdmit;
if (_bulkCumulativesValid) {
bulkView = BulkAdmissionView<TKey>(
stableCumulative: _stableCumulative,
fullCumulative: _bulkFullCumulative,
value: _bulkValueCached,
isCollapsing: bulkData.isCollapsing,
bulkData: bulkData,
hasZeroExtentNonMember: _bulkHasZeroExtentNonMember,
);
// Same field `bulkView.value` carries and `_offsetAtVisibleIndex`
// reads, assigned once per frame from `bulkData.value`, so the slot
// and the admission decision cannot disagree about `v`.
final double v = _bulkValueCached;
onAdmit = (int nid, int visibleIndex) {
// Moved verbatim from the deleted `_admitBulkFastPath`, carrying
// M6's extent arithmetic rather than the cumulative difference.
// Bitwise-identical to the Pass 2 measurement
// (`AnimationCoordinator.getAnimatedExtent`, which returns
// `fullExtent * bulk.group.value` for a member and `fullExtent`
// otherwise), which is what keeps the exact `!=` in the
// measurement loop firing only on a real height change.
_nodeOffsetsByNid[nid] = _offsetAtVisibleIndex(visibleIndex);
final full = controller.getEstimatedExtentNid(nid);
_nodeExtentsByNid[nid] = bulkData.containsMemberNid(nid)
? full * v
: full;
_writeCacheRegionNid(nid);
};
} else {
bulkView = null;
onAdmit = _admitNonBulk;
}
debugLastLayoutUsedBulkAdmission = bulkView != null;
// The returned `cacheEndIndex` is an iteration bound, never a
// membership test, and after the sparse-track switch no in-layout
// consumer reads it: freshness is [_inCacheRegionByNid] per nid and
// enumeration is [_writtenCacheRegionNids]. Discarded.
_admission.admit(
cacheStartIndex: cacheStartIndex,
visibleNodes: visibleNodes,
nodeOffsetsByNid: _nodeOffsetsByNid,
nodeExtentsByNid: _nodeExtentsByNid,
inCacheRegionByNid: _inCacheRegionByNid,
onCacheRegionAdmit: onAdmit,
effectiveCacheStart: effectiveCacheStart,
effectiveCacheEnd: effectiveCacheEnd,
paintRegionStart: scrollOffset,
paintRegionEnd: scrollOffset + remainingPaintExtent,
slideOverreach: slideOverreach,
remainingCacheExtent: remainingCacheExtent,
bulkView: bulkView,
);
// Create children for the rows admission actually admitted.
//
// Iterate the sparse track, not `[cacheStartIndex, cacheEndIndex)`.
// That range is an iteration bound and contains rows that were iterated
// but not admitted (off-screen exits during a collapse, iterated past
// to reach the post-animation-visible following rows; and under a bulk
// collapse the entire hopped-over span), which must not be built. The
// track holds admitted nids only, in ascending visible-index order, so
// no per-row gate is needed and no TKey hash is paid to reach the nid.
if (_writtenCacheRegionNidsLen > 0) {
invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
for (int pos = 0; pos < _writtenCacheRegionNidsLen; pos++) {
final nodeId = controller.keyOfNid(_writtenCacheRegionNids[pos]);
if (nodeId == null) continue;
childManager?.createChild(nodeId);
}
});
}
// Layout the children: track whether any extent changed to skip
// the O(N) _recomputeOffsets when sizes are stable (cache hit path).
// Also track the smallest index whose extent changed so we can walk
// only from there when recomputing offsets, and the POSITION of that
// row in the sparse track, so the parentData rewrite below resumes the
// track without a second search.
bool extentsChanged = false;
int firstChangedIdx = visibleNodes.length;
double aboveAnchorDelta = 0.0;
int firstChangedTrackPos = _writtenCacheRegionNidsLen; // none yet
for (int pos = 0; pos < _writtenCacheRegionNidsLen; pos++) {
final nid = _writtenCacheRegionNids[pos];
final nodeId = controller.keyOfNid(nid);
if (nodeId == null) continue;
final measured = _layoutNodeChild(nodeId, nid, crossAxisExtent);
if (measured == null) continue;
final actualAnimatedExtent = measured.extent;
final estimatedExtent = _nodeExtentsByNid[nid];
if (actualAnimatedExtent != estimatedExtent) {
_nodeExtentsByNid[nid] = actualAnimatedExtent;
totalScrollExtent += actualAnimatedExtent - estimatedExtent;
// H1: a row entirely above the viewport top, measured to a new
// extent, shifts everything below it; accumulate the residual so
// the frame can emit a scrollOffsetCorrection that moves
// `pixels` with the content. The offset compare IS "this row
// lies entirely above the anchor row", the same comparison
// `_findFirstVisibleIndex` binary-searches, evaluated on
// pre-measurement geometry: `estimatedExtent` was read before
// the slot was overwritten, and this loop writes no offsets.
// Animating rows are excluded: their movement is the
// animation's, already accounted for by Pass 1, not a
// measurement residual.
if (_nodeOffsetsByNid[nid] + estimatedExtent <= scrollOffset &&
!controller.isAnimatingNid(nid)) {
aboveAnchorDelta += actualAnimatedExtent - estimatedExtent;
}
if (!extentsChanged) {
extentsChanged = true;
firstChangedTrackPos = pos;
// Once per layout: the track ascends in visible index, so the
// first tracked entry whose extent changed is also the
// lowest-index changed row.
firstChangedIdx = controller.visibleIndexOfNid(nid);
}
}
final parentData = measured.child.parentData! as SliverTreeParentData;
parentData.layoutOffset = _nodeOffsetsByNid[nid];
}
// Only recompute offsets if actual extents differed from estimates.
// During steady-state animation (constraint cache hit means same sizes),
// this skips the full O(N) recomputation. When extents did change,
// only walk from the first changed index forward, offsets before
// that point are unaffected by later-index extent changes.
if (extentsChanged) {
_sticky.dirty = true;
if (_bulkCumulativesValid) {
// A child's measured size perturbed _fullExtents mid-bulk; the
// cumulatives are now inconsistent with truth for positions beyond
// firstChangedIdx. Materialize per-nid extents for the affected
// tail so _recomputeOffsetsFrom can walk it, then fall back off
// the fast path for this frame.
_materializeBulkStaleExtents(
fromIndex: firstChangedIdx,
visibleCount: visibleNodes.length,
);
}
totalScrollExtent = _recomputeOffsetsFrom(firstChangedIdx);
// Only rewrite parentData.layoutOffset for admitted rows at or after
// the first one whose extent changed. Earlier admitted rows already
// had the correct value written in the measurement loop above.
// Resuming the sparse track at `firstChangedTrackPos` is the sparse
// equivalent of `max(cacheStartIndex, firstChangedIdx)`: every
// tracked nid sits at a visible index >= cacheStartIndex by
// construction, and the track ascends in that index. Rows inside the
// range that were iterated but NOT admitted are deliberately not
// rewritten here; the post-sticky parentData refresh owns them.
for (int pos = firstChangedTrackPos;
pos < _writtenCacheRegionNidsLen;
pos++) {
final nid = _writtenCacheRegionNids[pos];
final nodeId = controller.keyOfNid(nid);
if (nodeId == null) continue;
final child = getChildForNode(nodeId);
if (child == null) continue;
final parentData = child.parentData! as SliverTreeParentData;
parentData.layoutOffset = _nodeOffsetsByNid[nid];
}
}
// Precompute subtree bottoms BEFORE sticky identification so that
// candidate probing can use O(1) lookups instead of O(n)-per-candidate
// subtree scans. Skip during animation: candidate probing bails on
// animating nodes anyway, so the O(3N) precomputation is wasted. The
// fallback per-candidate scan inside the computer is trivially cheap
// since it also bails immediately. Also skip when nothing changed
// since last precomputation (pure scrolling).
if (_animationsWereActive && !hasAnimations) {
_sticky.dirty = true; // animation just settled, one final pass
}
if (_maxStickyDepth > 0 && !hasAnimations && _sticky.dirty) {
_sticky.precomputeStableSubtreeBottoms(
visibleNodes: visibleNodes,
nodeOffsetsByNid: _nodeOffsetsByNid,
nodeExtentsByNid: _nodeExtentsByNid,
);
_sticky.dirty = false;
} else if (hasAnimations || _maxStickyDepth == 0) {
_sticky.invalidatePrecompute();
}
// Sticky headers are recomputed on EVERY layout, deliberately with no
// throttle. Skipping frames during a structural animation would serve a
// WRONG band rather than an approximate one: every row's extent moves
// each frame, so `pinnedY` and the candidate probe's answer move with
// it. (`_forEachStickyCandidate` does not bail on animating candidates
// either, for the same reason.)
//
// The probe merge below is what pays for that: this block runs the
// candidate walk once per computed layout, repeating it only when it
// force-creates a row.
//
// Compute the sticky set and learn the candidates in one walk.
//
// `freshOffsetAt`: under the bulk-only fast path the per-nid arrays
// are refreshed only for cache-region nids, but the pinned
// candidate's header row sits ABOVE the viewport, outside the cache
// region, so its slots hold pre-bulk values while its true offsets
// move every frame (content before it growing or shrinking). The
// probe's candidate SELECTION already reads the live bulk cumulatives
// through `_findFirstVisibleIndex`; hand it the same authority for
// its geometry (naturalY, the subtree bottom feeding pushUpY), or the
// two disagree and the band blanks or mispositions for the whole
// bulk animation. Mirrors `_structuralOffsetAt`'s gate.
final stickyCandidates = _sticky.computeStickyHeaders(
scrollOffset: scrollOffset,
overlap: constraints.overlap,
visibleNodes: visibleNodes,
nodeOffsetsByNid: _nodeOffsetsByNid,
nodeExtentsByNid: _nodeExtentsByNid,
findFirstVisibleIndex: _findFirstVisibleIndex,
// Guarded so a maxStickyDepth == 0 tree never pays the getter's
// fall-off-frame cumulative build for a probe that returns
// immediately.
freshOffsetAt: _maxStickyDepth > 0 ? _stickyFreshOffsetAt : null,
);
// Force-create and layout any sticky nodes not already in cache region.
// Filter by the cache-region flag rather than allocating a diff set.
final newStickyNodes = <TKey>{};
for (final id in stickyCandidates) {
final nid = _controller.nidOf(id);
if (nid < 0 || _inCacheRegionByNid[nid] == 0) {
newStickyNodes.add(id);
}
}
// True when the sticky block below measured a force-created row at a
// different extent than its stored estimate and ran the full
// `_recomputeOffsets`. The parentData refresh loop keys off this: the
// measurement loop wrote cache-region parentData BEFORE that recompute,
// so on these frames the cache-region skip premise is void and every
// mounted child must be re-based on the recomputed offsets, or in-flow
// rows paint one frame at pre-measure offsets while the sticky band
// (served by the post-measure re-probe) sits at post-measure ones. The
// visible symptom was a bare strip between an entering pinned header's
// band bottom and the first in-flow row below it, exactly on its first
// pinned frame (see `sticky_entering_root_handover_test.dart`).
bool stickyMeasurementMovedOffsets = false;
if (newStickyNodes.isNotEmpty) {
invokeLayoutCallback<SliverConstraints>((
SliverConstraints constraints,
) {
for (final nodeId in newStickyNodes) {
childManager?.createChild(nodeId);
}
});
// Track whether any measured sticky extent actually differs from the
// prior stored (estimated) extent. When all match, Pass 1's offsets
// and subtree-bottom precompute are still valid, so both the O(N)
// offset recompute and the O(3N) subtree-bottom precompute can be
// skipped entirely.
bool stickyExtentsChanged = false;
for (final nodeId in newStickyNodes) {
final nid = _controller.nidOf(nodeId);
// Prior estimate read through the SAME chain the measurement
// resolves: `_layoutNodeChild` returns `getAnimatedExtent(nodeId,
// child.size.height)`. `_nodeExtentsByNid[nid]` is not that value
// for an off-cache ANIMATING nid on a bulk frame; these nids were
// selected precisely because `_inCacheRegionByNid[nid] == 0`, and
// the bulk admission's `onAdmit` callback writes extent slots only
// for the nids it admits (the deleted `_admitBulkFastPath` wrote
// them only from `cacheStartIndex` to its break), so a pinned bulk
// member holds a previous frame's product (or a never-written 0.0)
// and the exact `!=` below fires on nothing.
final priorExtent = controller.getCurrentExtentNid(nid);
final measured = _layoutNodeChild(nodeId, nid, crossAxisExtent);
if (measured != null) {
final extent = measured.extent;
_nodeExtentsByNid[nid] = extent;
if (extent != priorExtent) {
stickyExtentsChanged = true;
// H1: the same residual class as the measurement loop.
// Position test through the structural authority: these nids
// are off-cache by construction, so their raw offset slots
// are stale on a bulk frame (M7's rule). The compare is a
// guard, not a formality: no sticky gate asserts the
// candidate's BOTTOM is above `scrollOffset`.
final visIdx = _controller.visibleIndexOfNid(nid);
if (visIdx >= 0 &&
_structuralOffsetAt(visIdx, nid) + priorExtent <=
scrollOffset &&
!controller.isAnimatingNid(nid)) {
aboveAnchorDelta += extent - priorExtent;
}
}
}
}
if (stickyExtentsChanged) {
if (_bulkCumulativesValid) {
// Mirror Pass 2's extent-change handler: the full recompute
// below walks EVERY visible nid, but under the bulk fast path
// only cache-region slots are fresh, materialize the rest
// first or the recompute overwrites correct offsets and this
// frame's geometry.scrollExtent with stale-extent garbage.
_materializeBulkStaleExtents(
fromIndex: 0,
visibleCount: visibleNodes.length,
);
}
totalScrollExtent = _recomputeOffsets();
// A full recompute rewrites every visible nid's slot, so the
// array is authoritative even on a bulk frame.
offsetsAuthoritative = true;
stickyMeasurementMovedOffsets = true;
if (_maxStickyDepth > 0 && !hasAnimations) {
_sticky.precomputeStableSubtreeBottoms(
visibleNodes: visibleNodes,
nodeOffsetsByNid: _nodeOffsetsByNid,
nodeExtentsByNid: _nodeExtentsByNid,
);
_sticky.dirty = false;
}
}
// Always write the newly-created sticky children's layoutOffset,
// they were outside the cache region during Pass 1 and never had it set.
for (final nodeId in newStickyNodes) {
final child = getChildForNode(nodeId);
if (child == null) continue;
final nid = _controller.nidOf(nodeId);
final visIdx = _controller.visibleIndexOfNid(nid);
if (visIdx < 0) continue;
final parentData = child.parentData! as SliverTreeParentData;
// Write the structural authority, never a raw per-nid slot:
// these nids are outside the cache region by construction, so
// on a bulk frame their offset slots are stale.
// `_structuralOffsetAt` is the same authority
// `_stickyFreshOffsetAt` hands the re-probe, so write and
// probe cannot disagree.
final structural = _structuralOffsetAt(visIdx, nid);
parentData.layoutOffset = structural;
assert(() {
if (parentData.layoutOffset != structural) {
debugStickyOffsetAuthorityMismatchCount++;
}
return true;
}());
}
// Re-probe, and ONLY here. Force-creating measured children, which
// can change `_nodeExtentsByNid` and therefore every offset after
// them, is the one thing between the first walk and now that can
// move the probe's inputs. A never-measured candidate also entered
// the first walk with `getEstimatedExtent`'s `defaultExtent`
// fallback and needs its real height reflected in `pinnedY`.
_sticky.computeStickyHeaders(
scrollOffset: scrollOffset,
overlap: constraints.overlap,
visibleNodes: visibleNodes,
nodeOffsetsByNid: _nodeOffsetsByNid,
nodeExtentsByNid: _nodeExtentsByNid,
findFirstVisibleIndex: _findFirstVisibleIndex,
// Re-evaluated here rather than reused from the first probe:
// the force-create block above can flip the authority mid-layout
// (`_materializeBulkStaleExtents` + `_recomputeOffsets` drop the
// fast path and refresh EVERY per-nid slot, so the getter then
// correctly serves the fall-off cumulative or the arrays).
freshOffsetAt: _stickyFreshOffsetAt,
);
}
// ────────────────────────────────────────────────────────────────────────
// Calculate paint extent
// ────────────────────────────────────────────────────────────────────────
// H1: emit the anchor-preserving correction ONCE, after both
// measurement sources, budgeted per frame (see the fields' docs).
// `correctionsAllowed` is a RE-ENTRANCY gate, not a displacement
// gate: the emit aborts this method and the viewport re-runs it at a
// different scrollOffset, which must not replay the once-per-frame
// pre-Pass-1 slide pipeline (ghost normalization fires on
// scrollChanged; the baseline slot is CONSUME-ONCE and its slides
// bind to this frame's viewport, so the re-run would get null and
// could redo none of it). FLIP-only for the family's usual reason
// asked of a re-entrancy question: a held preview never goes idle,
// and the composed flag would refuse every correction for the whole
// of a drag, precisely when the offset array is read against
// `pixels` on every pointer event (the accumulator itself reads only
// STRUCTURAL offsets, so displacement is not the issue). Carve-out
// inventory category (e), SCROLL-CORRECTION RE-ENTRANCY.
final bool correctionsAllowed =
!controller.hasActiveFlipSlides && !_composer.hasGhosts;
final bool wantsCorrection =
correctionsAllowed &&
aboveAnchorDelta.abs() > _kScrollCorrectionEpsilon;
if (wantsCorrection && _correctionsThisFrame >= _kMaxCorrectionsPerFrame) {
// Budget spent for this frame. Consume the rest next frame instead
// of dropping it, then fall through and finish the layout.
_scheduleCorrectionFollowUp();
} else if (wantsCorrection) {
_correctionsThisFrame++;
// Replicate the settled checkpoint so the same-frame re-run starts
// consistent: without these, the re-run would force a redundant
// full Pass 1 walk (visible-count mismatch) or reuse a stale total
// on the pure-scrolling branch.
_lastVisibleNodeCount = visibleNodes.length;
_lastTotalScrollExtent = totalScrollExtent;
_animationsWereActive = hasAnimations;
geometry = SliverGeometry(scrollOffsetCorrection: aboveAnchorDelta);
childManager?.didFinishLayout();
return;
}
// Rows are contiguous by construction: every Pass 1 branch writes
// offset(i+1) = offset(i) + extent(i), and the bulk fast path's
// cumulatives are prefix sums by definition
// (`_rebuildBulkCumulatives`). The visible portion of a contiguous
// [0, totalScrollExtent) band is therefore exactly the
// sliver-protocol intersection, with no per-row walk and no
// dependence on per-nid slot freshness (under the bulk-only fast
// path, off-cache slots are stale; reading them under-reported
// this extent and let a following sliver paint inside the tree).
double paintExtent = calculatePaintOffset(
constraints,
from: 0.0,
to: totalScrollExtent,
);
// Ensure paintExtent covers sticky headers. Sticky headers
// paint at pinnedY (near viewport top) but content may have scrolled far
// enough that the natural paint extent doesn't cover them, causing
// clipping.
bool stickyInflationClamped = false;
for (final sticky in _sticky.headers) {
final stickyBottom = sticky.pinnedY + sticky.extent;
if (stickyBottom > remainingPaintExtent) {
// This header would extend past our paint budget and overlap the
// next sliver. We cannot relocate it here (pinnedY is final), but
// flagging visual overflow ensures the viewport clips us to
// paintExtent so it doesn't bleed through.
stickyInflationClamped = true;
}
if (stickyBottom > paintExtent) paintExtent = stickyBottom;
}
// Bound by both the viewport's remaining paint budget AND the sliver's own
// maxPaintExtent (= totalScrollExtent below). Sticky inflation above can
// push paintExtent above totalScrollExtent for a short tree with a tall
// header; without this second cap, `SliverGeometry.debugAssertIsValid`
// fails on `paintExtent <= maxPaintExtent`.
paintExtent = paintExtent.clamp(
0.0,
math.min(remainingPaintExtent, totalScrollExtent),
);
geometry = SliverGeometry(
scrollExtent: totalScrollExtent,
paintExtent: paintExtent,
maxPaintExtent: totalScrollExtent,
// Protocol: the cache-region portion THIS sliver consumes,
// accounts for where the cache region starts relative to the
// sliver, unlike min(remainingCacheExtent, totalScrollExtent),
// which over-reports once the sliver is scrolled past the cache
// origin and starves every subsequent sliver's cache budget.
cacheExtent: calculateCacheOffset(
constraints,
from: 0.0,
to: totalScrollExtent,
),
// Overflow means: our painted region would exceed the portion of the
// scroll extent visible within our own paintExtent. Comparing against
// remainingPaintExtent (which includes space occupied by later slivers)
// gave false negatives and missed clipping. Also flag when a sticky
// header's inflated bottom was clamped against remainingPaintExtent.
// The `scrollOffset > 0` clause mirrors RenderSliverMultiBoxAdaptor:
// when the first visible row starts before scrollOffset, it paints at
// a negative y relative to the sliver's paint origin. Without this
// flag, the viewport skips its clip layer and the partial top row
// spills above the sliver, visible at max scroll extent, where the
// "content extends below" clause is false.
hasVisualOverflow:
stickyInflationClamped ||
scrollOffset + paintExtent < totalScrollExtent ||
scrollOffset > 0.0,
);
// Refresh parentData (layoutOffset, indent, visibleExtent) for
// children mounted in a prior frame that admission did not admit this
// frame, i.e. every mounted row with `_inCacheRegionByNid[nid] == 0`,
// whether it sits outside [cacheStartIndex, cacheEndIndex) or inside a
// span the bulk collapse's prefix hop skipped. The admission cap
// deliberately limits *new* mounts to prevent mass-mounting during
// large expansions, but already-mounted rows below an expanding or
// collapsing subtree keep their stale parentData and would otherwise
// paint at stale positions until something re-admits them to cache.
//
// Placed after the sticky pass so any _recomputeOffsets triggered by
// stickyExtentsChanged has already landed in _nodeOffsetsByNid /
// cumulatives before we write to parentData.
//
// Runs UNCONDITIONALLY when there are mounted children, gating on
// `hasAnimations || hasActiveSlides` leaves a staleness window. A
// structural mutation that installs no slides (e.g. a rapid cascaded
// toggle whose composedY/X both round to 0, so the engine clears the
// entry) updates parentData only for in-cache rows; a following pure
// scroll then sees neither flag set and skips the refresh. Three ways
// that shows up:
//
// * Stale `layoutOffset`: the row paints at its OLD structural Y,
// "stuck until I scroll again".
// * Stale `indent` on a depth-changing reparent: painted X =
// `parentData.indent + slideDeltaX` resolves to
// `oldIndent + (oldIndent - newIndent)` at slide t=0.
// * Stale `visibleExtent`: `_paintRow`'s clip-and-translate slices
// the wrong portion of the child box.
//
// Cost is O(_children): bounded by the cache region plus retained
// off-cache rows (edge ghosts, exit phantoms, slide-active rows),
// NOT O(visibleNodes), which on a dense expandAll would walk 10^5
// entries per frame for ~50 writes.
//
// In non-bulk mode `_nodeOffsetsByNid` is stale for off-cache rows, so
// a fresh structural cumulative is computed on demand below.
if (_children.isNotEmpty) {
// Built LAZILY on the first off-cache, live, visible child the loop
// actually encounters. On the common scroll frame every mounted
// child is in-cache, so the O(N_visible) accumulation, a
// Float64List(N+1) allocation plus N extent-chain resolutions, the
// dominant steady-state cost for large trees if done
// unconditionally, never runs.
Float64List? freshCumulative;
for (final nodeId in _children.keys) {
debugLastParentDataRefreshIterationCount++;
final child = _children[nodeId]!;
final nid = _controller.nidOf(nodeId);
if (nid < 0) {
// Dead key: purge handled by stale-node eviction.
continue;
}
// Admitted children already had their parentData written by the
// measurement loop. Skip them. Everything else reaching this line
// has `_inCacheRegionByNid[nid] == 0`, and this loop is its ONLY
// parentData write of the frame: the measurement loop iterates the
// sparse admitted track, so it no longer calls [_layoutNodeChild]
// on non-admitted rows inside [cacheStartIndex, cacheEndIndex).
// Under a bulk collapse that population is not rare, it is every
// mounted row in the hopped-over span, which is why the three
// writes below (layoutOffset, indent, visibleExtent) resolve from
// `controller.getIndent` and `controller.getCurrentExtentNid`, the
// same chain [_layoutNodeChild] consumes via `getAnimatedExtent`.
// Such a row is mounted but not laid out this frame, which is
// already the normal state of every retained off-cache row (edge
// ghosts, exit phantoms, slide-active rows) and is bounded by the
// viewport-bounded paint and hit-test bands.
//
// EXCEPT when the sticky block's force-create measurement moved the
// offsets: the measurement loop's writes predate that recompute, so
// the skip would leave cache-region rows painting one frame at
// pre-measure offsets while the sticky band uses post-measure ones.
if (!stickyMeasurementMovedOffsets &&
nid < _inCacheRegionByNid.length &&
_inCacheRegionByNid[nid] != 0) {
continue;
}
// A stale child (mounted, not admitted this frame) may have been
// rebuilt this frame with a layout-affecting change: the make-room
// placeholder swap (H5) does exactly that to every mounted row of
// the dragged subtree, admitted or not. Post-frame eviction is too
// late for this frame's semantics flush, which asserts, while
// merging a node's semantics children, that none of their render
// objects still needs layout, so lay it out here with the
// constraints [_layoutNodeChild] would use. A clean child
// early-outs inside `layout`, so the steady state pays a few
// comparisons per stale row. No extent write: the row is off the
// painted surface and its stored extent stays authoritative.
{
final indent = controller.getIndentNid(nid);
final w = math.max(0.0, constraints.crossAxisExtent - indent);
child.layout(
BoxConstraints(
minWidth: w,
maxWidth: w,
minHeight: 0.0,
maxHeight: double.infinity,
),
parentUsesSize: true,
);
}
final visIdx = _controller.visibleIndexOfNid(nid);
if (visIdx < 0) {
// Mounted but no longer in visible order, happens transiently
// during structure changes. Eviction sweeps it next.
continue;
}
final double offset;
if (stickyMeasurementMovedOffsets) {
// The sticky block's full `_recomputeOffsets` just rewrote every
// visible nid's slot, so the array is authoritative for in-cache
// and off-cache rows alike; no cumulative needs building. (The
// bulk fast path cannot be active here: the sticky block
// materializes stale bulk extents before recomputing, which
// invalidates the cumulatives.)
offset = _nodeOffsetsByNid[nid];
} else if (_bulkCumulativesValid) {
// Bulk-only fast path: per-nid offset slots are not kept fresh
// for out-of-cache-region nids, derive from cumulatives.
offset = _offsetAtVisibleIndex(visIdx);
} else if (!offsetsAuthoritative) {
// Bulk frame that left the fast path mid-frame: the slots
// before the first changed row are still the previous
// non-bulk frame's, so derive from a fresh cumulative.
final cumulative =
freshCumulative ??= _buildParentDataRefreshCumulative(
visibleNodes.length,
);
offset = cumulative[visIdx];
} else {
// Non-bulk: the per-nid slot is authoritative for off-cache
// rows too (see `offsetsAuthoritative` above), which spares the
// O(N_visible) prefix sum this used to build on EVERY frame
// that force-mounted a pinned header outside the cache region.
offset = _nodeOffsetsByNid[nid];
assert(() {
// Cross-check against the cumulative under the invariant
// suites only: building it here unconditionally would
// reintroduce the cost in debug and defeat the perf pin.
if (!TreeController.debugFullConsistencyChecks) {
return true;
}
final cumulative =
freshCumulative ??= _buildParentDataRefreshCumulative(
visibleNodes.length,
);
assert(
cumulative[visIdx] == offset,
"_nodeOffsetsByNid stale for off-cache nid $nid: slot "
"$offset, cumulative ${cumulative[visIdx]}",
);
return true;
}());
}
final parentData = child.parentData! as SliverTreeParentData;
parentData.layoutOffset = offset;
// Refresh indent + visibleExtent against the controller's live
// values. Both are read directly from the controller (no per-
// child layout call required), matching the assignments
// `_layoutNodeChild` would have performed on a cache-region row.
// `controller.getIndent` reads `getDepth(key) * indentWidth`,
// and `controller.getCurrentExtentNid` resolves the
// bulk, then operation-group, then standalone, then fullExtent chain,
// the
// same chain `_layoutNodeChild` consumes via `getAnimatedExtent`.
parentData.indent = controller.getIndent(nodeId);
parentData.visibleExtent = controller.getCurrentExtentNid(nid);
}
}
_correctionsThisFrame = 0;
_lastVisibleNodeCount = visibleNodes.length;
_lastTotalScrollExtent = totalScrollExtent;
_animationsWereActive = hasAnimations;
childManager?.didFinishLayout();
}