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, MINUS the exit-ghost anchors
  // `_computeA7Anchors` selected: Pass A.7 paints each of those exactly
  // once, on top of the ghost. That skip and A.7's paint list are ONE
  // list by construction, so no row falls between the two passes, and
  // the skip sits ABOVE the sliding-bucket branch below so a row that
  // is both an edge-ghost row and an exit-ghost anchor cannot be
  // bucketed AND repainted. 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;
  final a7Paints = _computeA7Anchors(
    remainingPaintExtent: remainingPaintExtent,
    hasSlides: hasSlides,
  );
  Set<int>? a7AnchorNids;
  if (a7Paints != null) {
    a7AnchorNids = <int>{for (final a in a7Paints) a.nid};
  }
  // 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 the FLIP-live ones
  // (Pass A.6 iterates the small ghost registry), so the break cannot
  // drop one of those; a FLIP-settled ghost row falls through to
  // standard paint, and `paintBound` already carries the composed
  // overreach, so a row past this break cannot paint inside the paint
  // region at any composed offset.
  // 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;
    }
    if (a7AnchorNids?.contains(nid) ?? false) {
      continue; // painted by Pass A.7, exactly once
    }

    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 while
    // the row still has a live FLIP delta to render there: the skip
    // mirrors Pass A.6's criterion and `GhostRegistry.pruneSettled`,
    // so the set Pass A skips is exactly the set Pass A.6 paints. The
    // question is FLIP-ONLY ([TreeController.getFlipSlideDeltaNid]),
    // never the composed delta: a held make-room preview offset on a
    // FLIP-settled ghost keeps the composed delta non-zero while Pass
    // A.6 declines the row, and a composed skip here left it painted
    // by NEITHER pass on every paint-only frame until the next layout
    // pruned the entry (unbounded while another FLIP kept ticking). A
    // settled ghost falls through to the composed test below and
    // paints at structural + FLIP + preview like any other row.
    if (hasEdgeGhosts &&
        _composer.ghosts.entryFor(nodeId) != null &&
        (controller.getFlipSlideDeltaNid(nid) != 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.)
      //
      // "At rest" is FLIP-ONLY ([TreeController.getFlipSlideDeltaNid]),
      // never the COMPOSED delta that feeds `paintedY` below. An exit
      // ghost is an artifact of a FLIP slide exactly as an edge ghost is,
      // and a make-room preview offset is HELD rather than decaying
      // (`_reorder_preview_engine.dart`), so a composed read here
      // never goes idle for the length of a drag: the ghost would keep
      // painting, keep its unconditional [isNodeRetained] pin, and keep
      // `findRowAtPaintedY` on its O(N) full scan. This gate and Step
      // 0a's prune criterion ([_pruneSettledPhantomExitGhosts]) are a
      // PAIR and MUST read the SAME delta: one decides whether a row
      // stays a ghost, the other whether it paints as one. The
      // edge-ghost side does the same thing, at the per-entry gate in
      // the edge-ghost pass against `_ghost_registry.dart`'s
      // `pruneSettled`.
      //
      // The X read needs no FLIP-only form: previews are Y offsets, so
      // [TreeController.getSlideDeltaXNid] is already FLIP-only, the
      // same argument `_ghost_registry.dart` makes for its own X read.
      final ghostFlip = controller.getFlipSlideDeltaNid(ghostNid);
      final anchorNidForGate = controller.nidOf(anchorKey);
      final anchorFlip = anchorNidForGate >= 0
          ? controller.getFlipSlideDeltaNid(anchorNidForGate)
          : 0.0;
      if (ghostFlip == 0.0 && ghostSlideX == 0.0 && anchorFlip == 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; the band itself is EXCLUDED by the clip, which is why
      // Pass A.7 skips clipped ghosts and repaints EDGE anchors only).
      // The clip reads the LIVE band, which [_anchorPaintedBounds]
      // builds with the anchor's COMPOSED delta (FLIP + held preview),
      // so occlusion tracks the header exactly where it is drawn this
      // frame. That is NOT the ghost's convergence top: the shared base
      // uses the anchor's SETTLED top, which carries the HELD preview
      // but not the decaying FLIP delta (see
      // [_exitGhostPaintedBaseScrollSpace]). The two therefore differ by
      // exactly the anchor's FLIP delta, and the preview term is COMMON
      // to both, which is what stops a ghost drifting out of its own
      // clip band for the length of a drag.
      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, for EDGE-painted ghosts ONLY. An
  // EDGE ghost takes the UNCLIPPED tail of Pass A.5, so it CAN paint
  // inside its anchor's band; repaint the anchor here, through
  // `_paintRow`, so it lands ON TOP. A CLIPPED ghost is skipped and
  // needs no repaint: `_resolvePhantomAnchorBounds` (role: exit)
  // already restricts it to the far side of the band in BOTH slide
  // directions, so no ghost pixel falls inside the band and the
  // anchor's Pass A paint stands unchallenged. `_ExitGhost` asserts
  // `(edge != null) ^ clipped`, so the two cases are exhaustive.
  // Sticky anchors are skipped here too, Pass B repaints them
  // deepest-first below. Each anchor is painted EXACTLY ONCE per
  // frame, by exactly one of {Pass A, Pass A.7 (non-sticky anchor of
  // an EDGE ghost, which `_computeA7Anchors` removes from Pass A),
  // Pass B (sticky)}.
  if (a7Paints != null) {
    for (final a in a7Paints) {
      _paintRow(
        context: context,
        offset: offset,
        nid: a.nid,
        child: a.child,
        slideDelta: hasSlides ? controller.getSlideDeltaNid(a.nid) : 0.0,
        slideDeltaX: hasXSlides ? controller.getSlideDeltaXNid(a.nid) : 0.0,
        scrollOffset: scrollOffset,
        remainingPaintExtent: remainingPaintExtent,
      );
    }
  }

  // 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);
    if (clippedExtent >= child.size.height) {
      // L25.1: the clip cannot cut anything (not clamped by the paint
      // region, not mid-extent-animation), so skip the push; with
      // RepaintBoundary rows it would allocate a ClipRectLayer per
      // header per paint. Compared against `child.size.height`, not
      // `sticky.extent`: the two diverge while the header animates,
      // and the clip is load-bearing there. Deliberate visual change,
      // recorded in the changelog: a child that paints outside its box
      // now shows that overflow while pinned, as it already does in
      // flow.
      context.paintChild(child, paintOffset);
    } else {
      context.pushClipRect(
        needsCompositing,
        paintOffset,
        Rect.fromLTWH(0, 0, child.size.width, clippedExtent),
        (context, offset) {
          context.paintChild(child, offset);
        },
      );
    }
  }
}