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) {
  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 edgeExits = _phantomEdgeExits;
  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 `_phantomEdgeExits` map 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 (edgeExits != null
        && edgeExits.containsKey(nodeId)
        && (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;
  if (ghosts != null && ghosts.isNotEmpty) {
    // Idle-state shortcut: if no slides are active, every ghost in
    // the map is settled (no slide entry exists for any of them).
    // Drop the whole map and skip the loop.
    if (!hasSlides) {
      for (final ghostKey in ghosts.keys) {
        _phantomClipAnchors?.remove(ghostKey);
      }
      _phantomExitGhosts = null;
    } else {
      // Snapshot keys to avoid concurrent-modification when we lazily
      // evict settled ghosts mid-iteration.
      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) {
          ghosts.remove(ghostKey);
          continue;
        }
        final ghostSlide = controller.getSlideDeltaNid(ghostNid);
        final ghostSlideX =
            hasXSlides ? controller.getSlideDeltaXNid(ghostNid) : 0.0;
        // Slide settled — drop the ghost. The next stale-eviction pass
        // will release the render box.
        if (ghostSlide == 0.0 && ghostSlideX == 0.0) {
          ghosts.remove(ghostKey);
          _phantomClipAnchors?.remove(ghostKey);
          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 anchorSlide = anchorNid >= 0
            ? controller.getSlideDeltaNid(anchorNid)
            : 0.0;
        final anchorSlideX = (hasXSlides && anchorNid >= 0)
            ? controller.getSlideDeltaXNid(anchorNid)
            : 0.0;
      // Ghost's painted position = anchor's painted position + ghost's
      // own slide delta. As the ghost's slide settles to 0, the ghost
      // converges on the anchor's row.
      final paintedY =
          anchorParentData.layoutOffset - scrollOffset + anchorSlide +
          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 same clip mechanism as the entry-phantom case so
      // the anchor visually occludes the ghost as it slides in.
      final clipRect = _resolvePhantomAnchorBounds(
        nid: ghostNid,
        paintedY: paintedY,
        offset: offset,
        remainingPaintExtent: remainingPaintExtent,
      );
      final paintOffset = offset + Offset(paintedX, paintedY);
      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
  // `_edgeGhostBaseY(entry, 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).
  final edgeGhosts = _phantomEdgeExits;
  if (edgeGhosts != null && edgeGhosts.isNotEmpty) {
    // Idle-state shortcut.
    if (!hasSlides) {
      _phantomEdgeExits = null;
    } else {
      final viewport = _currentViewportSnapshot();
      final edgeKeys = edgeGhosts.keys.toList();
      for (final ghostKey in edgeKeys) {
        final entry = edgeGhosts[ghostKey];
        if (entry == null) continue;
        final ghostNid = controller.nidOf(ghostKey);
        if (ghostNid < 0) {
          edgeGhosts.remove(ghostKey);
          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;
        // Eager prune on settle — avoids one-frame lingering at edge
        // between settle and next consume's lazy-prune.
        if (ghostSlide == 0.0 && ghostSlideX == 0.0) {
          edgeGhosts.remove(ghostKey);
          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 =
            _edgeGhostBaseY(entry, viewport) - 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));
      }
      if (edgeGhosts.isEmpty) _phantomEdgeExits = null;
    }
  }

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