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. Assert-guarded so
  // release builds skip the map churn entirely, @visibleForTesting
  // alone does not strip it.
  assert(() {
    debugLastPhantomGhostPaint.clear();
    debugLastPaintedStickyKeys.clear();
    return true;
  }());
  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.
  // Summed bound, not max: see the matching performLayout comment.
  final slideOverreach = controller.composedSlideAbsDeltaBound;
  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;
  // Bounded iteration end, symmetric to the widened start:
  // structural offsets are monotonic in visible order and painted y
  // differs from structural by at most `slideOverreach`, so nothing at
  // or past this offset can paint inside the viewport, break instead
  // of paying a sticky check + hash probe for every below-viewport row.
  // Composer edge-ghost rows paint at the live viewport edge regardless
  // of structural position, but Pass A already skips them (Pass A.6
  // iterates the small ghost registry), so the break cannot drop one.
  // Sticky headers likewise paint from their own pass.
  final paintBound = scrollOffset + remainingPaintExtent + slideOverreach;
  List<int>? slidingIndices;
  debugLastPaintIterationCount = 0;
  for (int i = startIndex; i < visibleNodes.length; i++) {
    debugLastPaintIterationCount++;
    final nid = orderNids[i];
    if (_structuralOffsetAt(i, nid) > paintBound) {
      break;
    }
    if (_sticky.isSticky(nid)) {
      continue;
    }

    final nodeId = visibleNodes[i];

    final child = getChildForNode(nodeId);
    if (child == null) {
      assert(
        _debugAssertUnbuiltRowIsOffScreen(i: i, nid: nid, nodeId: nodeId),
      );
      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) {
    // Hoisted once for the whole pass: edge-anchored ghosts resolve
    // their base against it.
    final a5Viewport = _currentViewportSnapshot();
    // Read-only pass: Step 0a owns all mutation of the ghost maps (at
    // layout time), so iterating the map directly is safe, no
    // per-paint key-list allocation.
    for (final ghostEntry in ghosts.entries) {
      final ghostKey = ghostEntry.key;
      final ghost = ghostEntry.value;
      final anchorKey = ghost.anchor;
      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 the
      // anchor term drops out and this reduces to the ghost's own delta.)
      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;
      // Shared base: SINGLE SOURCE OF TRUTH with the snapshot
      // augmentation in [snapshotVisibleOffsets] (see
      // [_exitGhostPaintedBaseScrollSpace] for both branches'
      // derivations, including why the anchor's SETTLED top, not the
      // live sliding band, is load-bearing for adjacent ghosts, and
      // the direction-aware tuck that composes with the consume-time
      // destination so `paintedY == baseline.y` at t=0).
      final base = _exitGhostPaintedBaseScrollSpace(
        ghostKey: ghostKey,
        ghost: ghost,
        viewport: a5Viewport,
      );
      if (base == null) continue; // truly orphaned anchor: skip as before
      final paintedY = base.y - scrollOffset + ghostSlide;
      final paintedX = base.x + ghostSlideX;
      // Skip if entirely outside the paint region.
      if (paintedY >= remainingPaintExtent) continue;
      if (paintedY + ghostChild.size.height <= 0) continue;
      final anchorChild = getChildForNode(anchorKey);
      if (anchorChild == null) {
        // Anchor unmounted (off-screen, beyond cache): the base above
        // resolved to the LIVE viewport edge (Pass A.6's model) so the
        // ghost slides off-screen instead of vanishing. No clip: there
        // is no on-screen band to clip to, and edge ghosts register no
        // _phantomClipAnchors entry.
        context.paintChild(ghostChild, offset + Offset(paintedX, paintedY));
        continue; // edge ghost painted; skip the anchor-relative tail
      }
      // 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).
      // The clip reads the LIVE band (with the anchor's own slide) so
      // occlusion tracks the header's on-screen position, NOT used
      // for the ghost's convergence top (that's the settled top inside
      // the shared base).
      final clipRect = _resolvePhantomAnchorBounds(
        nid: ghostNid,
        paintedY: paintedY,
        offset: offset,
        remainingPaintExtent: remainingPaintExtent,
        role: PhantomClipRole.exit,
      );
      final paintOffset = offset + Offset(paintedX, paintedY);
      // Debug capture for the far-overhang oracle (sliding-ghost-only;
      // sliver paint space, 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. Assert-guarded
      // so release builds skip both the LIVE-band read and the record
      // allocation per sliding ghost per frame.
      assert(() {
        final anchorBand = _anchorPaintedBounds(anchorKey);
        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,
            ),
          );
        }
        return true;
      }());
      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();
    // Read-only pass: Step 0b owns registry mutation (layout time), so
    // no per-paint key-list snapshot is needed.
    for (final ghostKey in _composer.ghosts.activeKeys) {
      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;
      // Settled: Step 0b reaps on next layout.
      //
      // FLIP-only, and it MUST match `GhostRegistry.pruneSettled`'s
      // criterion: that decides whether the row stays a ghost, this
      // decides whether it paints as one, so two different deltas here
      // let the pair disagree. Both directions are reachable:
      //
      //   - FLIP live, composed zero (a preview offset cancelling the
      //     delta): the registry keeps the ghost, so paint must draw it
      //     rather than drop the row for a frame. Composed zero puts it
      //     at the edge base, which is exactly where it belongs.
      //   - FLIP settled, composed non-zero (a preview offset on a
      //     finished slide): the ghost is retiring, and painting it at
      //     the edge displaced by a make-room offset would be wrong.
      //     The reap lands on the next layout, which can be a frame
      //     away while other slides keep ticking paint-only.
      //
      // The painted offset below stays COMPOSED: painted position is
      // structural + FLIP + preview everywhere, without exception.
      if (controller.getFlipSlideDeltaNid(ghostNid) == 0.0 &&
          ghostSlideX == 0.0) {
        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. 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.
  //
  // Iterate the EXIT-ghost records' anchors ONLY (deduped), NOT
  // `_phantomClipAnchors.values`, which 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 ghost in _phantomExitGhosts!.values) {
      final anchorKey = ghost.anchor;
      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;
    // An EXITING header is painted, deliberately. Do NOT reinstate a
    // `controller.isExiting` skip here: the set is rebuilt every layout,
    // so an exiting entry is a live decision by `computeStickyHeaders`,
    // and skipping it blanks the band while a section is being removed.
    // It retires on its own: as the subtree collapses, `pushUpY` falls and
    // the probe's `pinnedY + extent <= stackTop` gate drops it.

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

    assert(() {
      debugLastPaintedStickyKeys.add(sticky.nodeId);
      return true;
    }());

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