paint method
Paint this render object into the given context at the given offset.
Subclasses should override this method to provide a visual appearance for themselves. The render object's local coordinate system is axis-aligned with the coordinate system of the context's canvas and the render object's local origin (i.e, x=0 and y=0) is placed at the given offset in the context's canvas.
Do not call this function directly. If you wish to paint yourself, call
markNeedsPaint instead to schedule a call to this function. If you wish
to paint one of your children, call PaintingContext.paintChild on the
given context.
When painting one of your children (via a paint child function on the given context), the current canvas held by the context might change because draw operations before and after painting children might need to be recorded on separate compositing layers.
Implementation
@override
void paint(PaintingContext context, Offset offset) {
// Debug capture is paint-time-scoped: cleared each frame, rewritten
// by Pass A.5 only for actively-sliding ghosts (Invariant 8/10).
debugLastPhantomGhostPaint.clear();
if (geometry == null || geometry!.paintExtent == 0) return;
final scrollOffset = constraints.scrollOffset;
final remainingPaintExtent = constraints.remainingPaintExtent;
final visibleNodes = controller.visibleNodes;
final orderNids = controller.orderNidsView;
// Hoist per-axis slide-activity checks out of the loop. When idle
// (no slides in flight), the per-row deltas are guaranteed 0 — skip
// the lookups entirely. X-axis slides are rare even during slide
// cycles (most reorders are same-depth) so a separate check
// suppresses X reads in the common Y-only case.
final hasSlides = controller.hasActiveSlides;
final hasXSlides = hasSlides && controller.hasActiveXSlides;
// Widen the paint iteration start by the active FLIP-slide overreach
// so rows structurally before the viewport but painting INTO it (via
// a positive slide delta) are not skipped. `_paintRow` already bails
// on rows whose painted y lies past the viewport, so extra iterated
// rows on the bottom edge are harmless. See the matching comment in
// `performLayout` for why structural offsets alone aren't enough.
final slideOverreach = controller.maxActiveSlideAbsDelta;
final startIndex = _findFirstVisibleIndex(scrollOffset - slideOverreach);
// Pass A: Paint non-sticky nodes. Rows with a non-zero slide delta are
// deferred to a second sub-pass so they paint on top of static rows —
// without this, an upward-moving row that hasn't yet crossed into its
// final index slot would be covered by siblings sliding down past it.
// Among sliding rows, sort by ascending |delta| so the row that moved
// the most (typically the just-dropped row) paints last and lands on
// top. Ties preserve natural iteration order.
final hasEdgeGhosts = _composer.hasGhosts;
List<int>? slidingIndices;
for (int i = startIndex; i < visibleNodes.length; i++) {
final nid = orderNids[i];
if (_sticky.isSticky(nid)) {
continue;
}
final nodeId = visibleNodes[i];
final child = getChildForNode(nodeId);
if (child == null) continue;
// Paint-only FLIP slide delta — read from the controller on every
// frame so localToGlobal / semantics (which can resolve between
// ticks) always see the current value. Skipped entirely when no
// slides are active.
final slideDelta = hasSlides ? controller.getSlideDeltaNid(nid) : 0.0;
final slideDeltaX = hasXSlides ? controller.getSlideDeltaXNid(nid) : 0.0;
// Edge-ghost rows paint via the parallel edge-ghost pass at
// `entry.edgeY + slideDelta` — skip standard paint so they don't
// double-paint at the wrong (structural) position. BUT only when
// the engine still has a live slide entry for this nid; if the
// engine cleared the slide via composition (composedY/X both 0)
// while the ghost registry entry survived (e.g.
// direction-flip kept the entry, then composition zeroed the
// delta), the edge-ghost paint pass will prune the entry without
// painting. Skipping standard paint here would leave the row
// invisible until the next layout's prune. Instead, only skip
// when there's actually a delta to render via the edge-ghost
// pass; otherwise fall through to standard paint at structural+0.
if (hasEdgeGhosts
&& _composer.ghosts.entryFor(nodeId) != null
&& (slideDelta != 0.0 || slideDeltaX != 0.0)) {
continue;
}
if (slideDelta != 0.0 || slideDeltaX != 0.0) {
(slidingIndices ??= <int>[]).add(i);
continue;
}
_paintRow(
context: context,
offset: offset,
nid: nid,
child: child,
slideDelta: 0.0,
slideDeltaX: 0.0,
scrollOffset: scrollOffset,
remainingPaintExtent: remainingPaintExtent,
);
}
if (slidingIndices != null) {
// Sort by Y delta only — X delta is bounded by indent (~24-200px
// typical) and much smaller than Y; Y-only sort suffices for "row
// that moved most paints last."
slidingIndices.sort((a, b) {
final da = controller.getSlideDeltaNid(orderNids[a]).abs();
final db = controller.getSlideDeltaNid(orderNids[b]).abs();
final cmp = da.compareTo(db);
if (cmp != 0) return cmp;
return a.compareTo(b);
});
for (final i in slidingIndices) {
final nodeId = visibleNodes[i];
final child = getChildForNode(nodeId);
if (child == null) continue;
// hasSlides is implicitly true here (slidingIndices is non-empty
// means at least one row had a non-zero delta). Read directly.
_paintRow(
context: context,
offset: offset,
nid: orderNids[i],
child: child,
slideDelta: controller.getSlideDeltaNid(orderNids[i]),
slideDeltaX:
hasXSlides ? controller.getSlideDeltaXNid(orderNids[i]) : 0.0,
scrollOffset: scrollOffset,
remainingPaintExtent: remainingPaintExtent,
);
}
}
// Pass A.5: Paint phantom-exit ghosts. These are rows that were
// visible at staging time but are now hidden under a collapsed
// parent; they slide INTO the parent's row and disappear behind
// it. Iterated in a separate pass because they're not in
// visibleNodes (they were purged when the move ran). Each ghost
// is painted at the exit anchor's current painted position offset
// by the ghost's own slide delta. The clip in `_paintRow`
// (driven by _phantomClipAnchors) handles the "occluded by
// parent" effect.
final ghosts = _phantomExitGhosts;
// Layout's Step 0a (_pruneSettledPhantomExitGhosts) handles cleanup;
// paint stays read-only. When slides are idle, the map is empty by
// the next layout's Step 0a, so we don't need to clear it here.
if (ghosts != null && ghosts.isNotEmpty && hasSlides) {
// Snapshot keys to avoid concurrent-modification when nested
// controller reads in the loop touch state.
final ghostKeys = ghosts.keys.toList();
for (final ghostKey in ghostKeys) {
final anchorKey = ghosts[ghostKey];
if (anchorKey == null) continue;
final ghostNid = controller.nidOf(ghostKey);
if (ghostNid < 0) {
// Freed key — Step 0a reaps on next layout.
continue;
}
final ghostSlide = controller.getSlideDeltaNid(ghostNid);
final ghostSlideX =
hasXSlides ? controller.getSlideDeltaXNid(ghostNid) : 0.0;
// An ADJACENT exit-ghost has ZERO own-slide (its baseline already
// equals the settled destination-header position) yet is NOT done:
// it must keep painting (stationary, getting progressively occluded)
// while its anchor — the destination header — slides UP to absorb it.
// So "settled" requires BOTH the ghost AND its anchor to be at rest.
// (Edge ghosts: their off-screen anchor carries slide 0, so this is
// byte-equivalent to the old ghost-only test for them.)
final anchorNidForGate = controller.nidOf(anchorKey);
final anchorSlideForGate = anchorNidForGate >= 0
? controller.getSlideDeltaNid(anchorNidForGate)
: 0.0;
if (ghostSlide == 0.0 &&
ghostSlideX == 0.0 &&
anchorSlideForGate == 0.0) {
// Settled — Step 0a reaps on next layout.
continue;
}
final ghostChild = getChildForNode(ghostKey);
if (ghostChild == null) continue;
final anchorChild = getChildForNode(anchorKey);
if (anchorChild == null) {
// Anchor unmounted (off-screen, beyond cache). If this ghost was
// staged as an EDGE exit (off-screen-anchor consume branch),
// paint it at the LIVE viewport edge using the GHOST's own
// retained RenderBox — the same model as Pass A.6 — so it slides
// off-screen instead of vanishing (the snap). No clip: there is
// no on-screen band to clip to (I-MUTEX: edge ghosts register no
// _phantomClipAnchors entry).
final edge = _phantomExitEdge?[ghostKey];
if (edge == null) continue; // truly orphaned anchor: skip as before
final viewport = _currentViewportSnapshot();
final paintedY =
viewport.baseForEdge(edge) - scrollOffset + ghostSlide;
final paintedX = controller.getIndent(ghostKey) + ghostSlideX;
// Skip if entirely outside the paint region.
if (paintedY >= remainingPaintExtent) continue;
if (paintedY + ghostChild.size.height <= 0) continue;
context.paintChild(ghostChild, offset + Offset(paintedX, paintedY));
continue; // edge ghost painted; skip the anchor-relative tail
}
final anchorParentData = anchorChild.parentData;
if (anchorParentData is! SliverTreeParentData) continue;
final anchorNid = controller.nidOf(anchorKey);
final anchorSlideX = (hasXSlides && anchorNid >= 0)
? controller.getSlideDeltaXNid(anchorNid)
: 0.0;
// Ghost's painted position converges on the anchor's SETTLED top:
// sticky `pinnedY` when pinned (fresh — computeStickyHeaders already
// ran this frame), else the structural `layoutOffset` WITHOUT the
// anchor's own slide delta.
//
// Using the SETTLED top (not the live, sliding band) is load-bearing.
// The destination header itself FLIP-slides when the SOURCE section
// shrinks — e.g. unfavoriting compacts the favorites list, so the
// "My Workspaces" header slides UP into the vacated row. Reading the
// live band here (`_anchorPaintedBounds`, which adds that anchorSlide)
// DOUBLE-COUNTS the destination's motion: the ghost would converge on
// the live band and jump by the anchor's slide at t=0. That error is
// masked for a FAR ghost (large own-slide swamps it) but FATAL for an
// ADJACENT ghost whose own FLIP delta is zero — its baseline already
// equals the settled header position, so it would pin to the live band
// top and the EXIT down-clip (`visible = [0, bandTop]`) would clip it
// to nothing every frame → the "vanishes in place" bug.
//
// The EXIT clip below STILL reads the LIVE band so the rising header
// occludes the (now stationary) ghost correctly as it absorbs it.
// Defect 1+2 / Invariant 5 are preserved: the pinned branch is
// byte-identical to `_anchorPaintedBounds`'s pinned branch.
// LIVE band (with the anchor's own slide) — drives the EXIT clip and
// the Defect-1 debug-capture oracle, so occlusion tracks the header's
// on-screen position. NOT used for the ghost's convergence top.
final anchorBand = _anchorPaintedBounds(anchorKey);
final anchorPinnedInfo =
anchorNid >= 0 ? _sticky.infoForNid(anchorNid) : null;
final double anchorPaintedTop = anchorPinnedInfo != null
? anchorPinnedInfo.pinnedY
: anchorParentData.layoutOffset - scrollOffset;
// Subtract the SAME direction-aware tuck the consume destination
// applied (I-AGREE / the TRAP). The persisted `_phantomExitSlidUp`
// flag reproduces the consume-time direction here (paint has no
// baseline). Because consume injected `current.y = settledAnchorY −
// tuck + ghostSlideY`, the initial `ghostSlide` (= baseline.y −
// current.y) and this paint anchor (`anchorPaintedTop − tuck`)
// compose so `paintedY == baseline.y` at t=0 (no jump) and converge
// to `bandTop − tuck` at settle (card bottom == bandBottom).
final slidUp = _phantomExitSlidUp?[ghostKey] ?? false;
final tuck = _exitTuckFor(
ghostNid: ghostNid,
anchorKey: anchorKey,
slidUp: slidUp,
);
final paintedY = anchorPaintedTop - tuck + ghostSlide;
final paintedX =
anchorParentData.indent + anchorSlideX + ghostSlideX;
// Skip if entirely outside the paint region.
if (paintedY >= remainingPaintExtent) continue;
if (paintedY + ghostChild.size.height <= 0) continue;
// Apply the EXIT clip so the ghost is bounded to the destination
// header's painted band on its trailing side (far overhang
// killed; band occluded by the header repaint in Pass A.7/B).
final clipRect = _resolvePhantomAnchorBounds(
nid: ghostNid,
paintedY: paintedY,
offset: offset,
remainingPaintExtent: remainingPaintExtent,
role: PhantomClipRole.exit,
);
final paintOffset = offset + Offset(paintedX, paintedY);
// Debug capture for the Defect-1 oracle (sliding-ghost-only;
// sliver-local, NOT offset by `offset`). Reached ONLY for a
// sliding ghost (the settled-`continue` above precedes this), so
// the map stays empty at settle by construction (Invariant 8).
if (anchorBand != null) {
debugLastPhantomGhostPaint[ghostKey] = (
ghostRect: Rect.fromLTWH(
paintedX,
paintedY,
ghostChild.size.width,
ghostChild.size.height,
),
clipRect: clipRect,
anchorBand: Rect.fromLTWH(
0,
anchorBand.top,
constraints.crossAxisExtent,
anchorBand.height,
),
);
}
if (clipRect != null) {
context.pushClipRect(
needsCompositing,
offset,
clipRect,
(ctx, off) => ctx.paintChild(ghostChild, paintOffset),
);
} else {
context.paintChild(ghostChild, paintOffset);
}
}
}
// Pass A.6: Paint edge-anchor exit ghosts (live-edge-anchored
// ghosts for long slide-OUTs). These rows ARE in visibleNodes but
// skipped by the standard paint pass — their painted position is
// `_composer.baseFor(key, currentViewport) + slideDelta` in
// scroll-space, recomputed against the live viewport so the ghost
// stays pinned to the live edge under concurrent scrolling. As the
// slide settles, the row converges on the viewport edge and is
// then lazily pruned (no visible cut because the row's structural
// position is far off-screen).
// Layout's Step 0b (`_composer.ghosts.pruneSettled` / `clearAll` when
// idle) handles cleanup; paint stays read-only.
if (_composer.hasGhosts && hasSlides) {
final viewport = _currentViewportSnapshot();
final edgeKeys = _composer.ghosts.activeKeys.toList();
for (final ghostKey in edgeKeys) {
final entry = _composer.ghosts.entryFor(ghostKey);
if (entry == null) continue;
final ghostNid = controller.nidOf(ghostKey);
if (ghostNid < 0) {
// Freed key — Step 0b reaps on next layout.
continue;
}
// Defensive: if a ghost row is also a sticky header, let the
// sticky pass handle it (paints at pinned structural y). Edge
// ghost behaviour is lost for this row, but no double-paint.
// Sticky + slide-OUT-to-far-off-screen is uncommon.
if (_sticky.isSticky(ghostNid)) continue;
final ghostSlide = controller.getSlideDeltaNid(ghostNid);
final ghostSlideX =
hasXSlides ? controller.getSlideDeltaXNid(ghostNid) : 0.0;
if (ghostSlide == 0.0 && ghostSlideX == 0.0) {
// Settled — Step 0b reaps on next layout.
continue;
}
final ghostChild = getChildForNode(ghostKey);
if (ghostChild == null) continue;
final indent = controller.getIndent(ghostKey);
// Ghost paints at `liveBaseY + slideDelta` in scroll-space,
// converted to local paint coords by subtracting scrollOffset.
final paintedY =
viewport.baseForEdge(entry.edge) - scrollOffset + ghostSlide;
final paintedX = indent + ghostSlideX;
// Skip if entirely outside the paint region.
if (paintedY >= remainingPaintExtent) continue;
if (paintedY + ghostChild.size.height <= 0) continue;
context.paintChild(ghostChild, offset + Offset(paintedX, paintedY));
}
}
// Pass A.7: Header-occludes-ghost (Defect 2). Repaint each NON-sticky
// EXIT-ghost destination/crossed header clipped to its painted band,
// so it lands ON TOP of the ghost painted in Pass A.5. This closes the
// gaps where no Pass-B repaint re-asserts the header (`maxStickyDepth:
// 0`, or a header dropped from sticky because it's animating / has no
// children). Sticky anchors are skipped — Pass B repaints them
// deepest-first below. A header is repainted by EXACTLY ONE of {Pass
// A.7 (non-sticky), Pass B (sticky)} per frame (Invariant 6).
//
// Iterate `_phantomExitGhosts.values` ONLY (deduped) — NOT
// `_phantomClipAnchors.values`, which also holds ENTRY-phantom anchors
// that must NOT be repainted on top of an emerging entry row.
if (_phantomExitGhosts != null &&
_phantomExitGhosts!.isNotEmpty &&
hasSlides) {
final seenAnchors = <TKey>{};
for (final anchorKey in _phantomExitGhosts!.values) {
if (!seenAnchors.add(anchorKey)) continue; // dedupe shared anchors
final anchorNid = controller.nidOf(anchorKey);
if (anchorNid < 0) continue; // freed key
if (_sticky.isSticky(anchorNid)) continue; // Pass B owns it
if (controller.isExiting(anchorKey)) continue; // animating out
final anchorChild = _children[anchorKey];
if (anchorChild == null) continue; // not mounted in-flow
final anchorParentData = anchorChild.parentData;
if (anchorParentData is! SliverTreeParentData) continue;
final band = _anchorPaintedBounds(anchorKey);
if (band == null) continue;
// Skip if the band is fully outside the paint region.
if (band.top >= remainingPaintExtent) continue;
if (band.top + band.height <= 0) continue;
final paintOffset =
offset + Offset(anchorParentData.indent, band.top);
context.pushClipRect(
needsCompositing,
paintOffset,
Rect.fromLTWH(0, 0, anchorChild.size.width, band.height),
(ctx, off) => ctx.paintChild(anchorChild, off),
);
}
}
// Pass B: Paint sticky headers (deepest first so shallower paints on top).
final paintExtent = geometry!.paintExtent;
final stickyHeaders = _sticky.headers;
for (int i = stickyHeaders.length - 1; i >= 0; i--) {
final sticky = stickyHeaders[i];
final child = getChildForNode(sticky.nodeId);
if (child == null) continue;
// Skip nodes currently animating out. Sticky recompute is throttled
// during animations, so _stickyHeaders may still contain entries for
// nodes that just entered pendingRemoval — painting them would leave
// a ghost row until the next recompute tick.
if (controller.isExiting(sticky.nodeId)) continue;
// Don't paint a header that has been pushed entirely past the sliver's
// paint region (e.g. by a tiny remainingPaintExtent near the bottom).
if (sticky.pinnedY >= paintExtent) continue;
// Clip to whichever is smaller: the header's natural extent, or the
// remaining paint region. Without this clamp the header would spill
// into the next sliver when pinnedY + extent > paintExtent.
final clippedExtent = math.min(
sticky.extent,
paintExtent - sticky.pinnedY,
);
if (clippedExtent <= 0) continue;
final paintOffset = offset + Offset(sticky.indent, sticky.pinnedY);
context.pushClipRect(
needsCompositing,
paintOffset,
Rect.fromLTWH(0, 0, child.size.width, clippedExtent),
(context, offset) {
context.paintChild(child, offset);
},
);
}
}