paint method

  1. @override
void paint(
  1. PaintingContext context,
  2. Offset offset
)
override

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;
      if (ghostSlide == 0.0 && ghostSlideX == 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) continue;
      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 = anchor's PAINTED band top + ghost's
      // own slide delta. The painted band is read at paint time (sticky
      // `pinnedY` when pinned — fresh because computeStickyHeaders
      // already ran this frame — else structural), so the ghost
      // converges on the header WHERE IT APPEARS on screen (Defect 1+2,
      // Invariant 5). Falls back to the structural value when the band
      // can't be resolved.
      final anchorBand = _anchorPaintedBounds(anchorKey);
      final anchorPaintedTop = anchorBand?.top ??
          (anchorParentData.layoutOffset -
              scrollOffset +
              (anchorNid >= 0
                  ? controller.getSlideDeltaNid(anchorNid)
                  : 0.0));
      // 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);
      },
    );
  }
}