hitTestChildren method
bool
hitTestChildren(
- SliverHitTestResult result, {
- required double mainAxisPosition,
- required double crossAxisPosition,
override
Override this method to check whether any children are located at the given position.
Typically children should be hit-tested in reverse paint order so that hit tests at locations where children overlap hit the child that is visually "on top" (i.e., paints later).
Used by hitTest. If you override hitTest and do not call this function, then you don't need to implement this function.
For a discussion of the semantics of the arguments, see hitTest.
Implementation
@override
bool hitTestChildren(
SliverHitTestResult result, {
required double mainAxisPosition,
required double crossAxisPosition,
}) {
final scrollOffset = constraints.scrollOffset;
final visibleNodes = controller.visibleNodes;
final orderNids = controller.orderNidsView;
// Phase 1: Test sticky headers first (they're visually on top).
// Iterate shallowest first (index 0) = topmost = first hit priority.
for (final sticky in _sticky.headers) {
final child = getChildForNode(sticky.nodeId);
if (child == null) continue;
if (controller.isExiting(sticky.nodeId)) continue;
final localMain = mainAxisPosition - sticky.pinnedY;
if (localMain < 0 || localMain >= sticky.extent) continue;
final localCross = crossAxisPosition - sticky.indent;
if (localCross < 0) continue;
final hit = result.addWithAxisOffset(
paintOffset: Offset(sticky.indent, sticky.pinnedY),
mainAxisOffset: sticky.pinnedY,
crossAxisOffset: sticky.indent,
mainAxisPosition: mainAxisPosition,
crossAxisPosition: crossAxisPosition,
hitTest:
(
SliverHitTestResult result, {
required double mainAxisPosition,
required double crossAxisPosition,
}) {
return child.hitTest(
BoxHitTestResult.wrap(result),
position: Offset(crossAxisPosition, mainAxisPosition),
);
},
);
if (hit) return true;
}
// Phase 2: Test normal nodes (skip sticky IDs). Widen the start by
// the FLIP-slide overreach so a tap on a row whose structural y is
// above the hit offset, but which has slid down into the tap point
//, is still tested. The per-row `localMainAxisPosition` bounds
// check below naturally skips non-overlapping rows, so iterating
// extra rows at the top is cheap.
// Summed bound, not max: see the matching performLayout comment.
final slideOverreach = controller.composedSlideAbsDeltaBound;
final hitOffset = scrollOffset + mainAxisPosition;
final startIndex = _findFirstVisibleIndex(hitOffset - slideOverreach);
// Hoist per-axis slide-activity checks (idle-state fast path).
final hasSlides = controller.hasActiveSlides;
final hasXSlides = hasSlides && controller.hasActiveXSlides;
// Lazy viewport: only built if a ghost row is encountered.
ViewportSnapshot? hitViewport;
// Per-row test shared by the idle single loop, the slide-active
// z-ordered passes, and Phase 2b (edge ghosts).
bool testRow(int i) {
final nid = orderNids[i];
final nodeId = visibleNodes[i];
final child = getChildForNode(nodeId);
if (child == null) return false;
// Skip exiting nodes - they should not receive interactions
// This prevents crashes when rapidly tapping delete buttons
if (controller.isExitingNid(nid)) return false;
final parentData = child.parentData! as SliverTreeParentData;
// Edge-ghost rows paint at `composer.baseFor + slideDelta` (NOT at
// structural + slideDelta). Substitute the live edge base for
// the structural offset so hit-tests land on the painted (ghost)
// position. Lazy: build the snapshot once, only if any ghost is
// actually encountered.
final double nodeOffset;
if (_composer.ghosts.entryFor(nodeId) != null) {
hitViewport ??= _currentViewportSnapshot();
nodeOffset =
_composer.baseFor(nodeId, hitViewport!) ?? parentData.layoutOffset;
} else {
nodeOffset = parentData.layoutOffset;
}
final nodeExtent = parentData.visibleExtent;
// Shift the hit coordinate by the node's current slide delta so a
// tap lands on the visually-displaced child rather than on the
// structural position nobody sees during a slide. Skip the read
// when no slides are in flight.
final slideDelta = hasSlides ? controller.getSlideDeltaNid(nid) : 0.0;
final slideDeltaX = hasXSlides ? controller.getSlideDeltaXNid(nid) : 0.0;
final localMainAxisPosition =
mainAxisPosition + scrollOffset - nodeOffset - slideDelta;
if (localMainAxisPosition < 0) return false;
if (localMainAxisPosition >= nodeExtent) return false;
final localCrossAxisPosition =
crossAxisPosition - parentData.indent - slideDeltaX;
if (localCrossAxisPosition < 0) return false;
final paintedMainOffset = nodeOffset - scrollOffset + slideDelta;
final paintedCrossOffset = parentData.indent + slideDeltaX;
// Paint clips an entry-phantom row to the region outside
// its anchor's bounds; the clipped-away (occluded) portion must not
// steal hits from the anchor covering it. Reject when the hit point
// (already in sliver-local paint space, the space the clip rect is
// computed in) lies outside the visible region.
if (_phantomClipAnchors?.containsKey(nodeId) ?? false) {
final clipRect = _resolvePhantomAnchorBounds(
nid: nid,
paintedY: paintedMainOffset,
offset: Offset.zero,
remainingPaintExtent: constraints.remainingPaintExtent,
role: PhantomClipRole.entry,
);
if (clipRect != null &&
!clipRect.contains(Offset(crossAxisPosition, mainAxisPosition))) {
return false;
}
}
// Mirror paint's clip-and-translate trick. When a node is animating
// and its visible extent is smaller than its intrinsic box, paint
// draws the child shifted up by (height - extent) so the bottom slice
// peeks through the clipped visible strip. Hit tests must apply the
// same Y adjustment or taps on the visible slice would route to the
// clipped-away top of the child box.
final yAdjust =
(controller.isAnimatingNid(nid) && nodeExtent < child.size.height)
? (child.size.height - nodeExtent)
: 0.0;
return result.addWithAxisOffset(
paintOffset: Offset(paintedCrossOffset, paintedMainOffset),
mainAxisOffset: paintedMainOffset,
crossAxisOffset: paintedCrossOffset,
mainAxisPosition: mainAxisPosition,
crossAxisPosition: crossAxisPosition,
hitTest:
(
SliverHitTestResult result, {
required double mainAxisPosition,
required double crossAxisPosition,
}) {
return child.hitTest(
BoxHitTestResult.wrap(result),
position: Offset(crossAxisPosition, mainAxisPosition + yAdjust),
);
},
);
}
// Bounded iteration end: see paint's Pass A for the
// derivation. Composer edge-ghost rows past the break are covered by
// Phase 2b below.
final hitBound = hitOffset + slideOverreach;
debugLastHitTestIterationCount = 0;
if (!hasSlides) {
// Idle fast path: structural order, single loop, zero overhead on
// the common path (paint z-order only diverges during slides).
for (int i = startIndex; i < visibleNodes.length; i++) {
debugLastHitTestIterationCount++;
final nid = orderNids[i];
if (_structuralOffsetAt(i, nid) > hitBound) break;
if (_sticky.isSticky(nid)) continue;
if (testRow(i)) return true;
}
return false;
}
// Slide-active path: match paint's z-order. Paint draws
// static rows first, then sliding rows in ASCENDING |delta| (the row
// that moved most paints last, on top), so hit priority is the
// reverse: sliding rows in DESCENDING |delta| first, then static
// rows. Ties preserve paint's natural-order rule (later index paints
// later, so tested first).
final slidingIdx = <int>[];
final staticIdx = <int>[];
for (int i = startIndex; i < visibleNodes.length; i++) {
debugLastHitTestIterationCount++;
final nid = orderNids[i];
if (_structuralOffsetAt(i, nid) > hitBound) break;
if (_sticky.isSticky(nid)) continue;
final dy = controller.getSlideDeltaNid(nid);
final dx = hasXSlides ? controller.getSlideDeltaXNid(nid) : 0.0;
if (dy != 0.0 || dx != 0.0) {
slidingIdx.add(i);
} else {
staticIdx.add(i);
}
}
slidingIdx.sort((a, b) {
final da = controller.getSlideDeltaNid(orderNids[a]).abs();
final db = controller.getSlideDeltaNid(orderNids[b]).abs();
final cmp = db.compareTo(da);
if (cmp != 0) return cmp;
return b.compareTo(a);
});
for (final i in slidingIdx) {
if (testRow(i)) return true;
}
for (final i in staticIdx) {
if (testRow(i)) return true;
}
// Phase 2b: composer edge-ghost rows paint at the live viewport edge
// regardless of structural position, so the bounded main loop may
// have broken before reaching them. The registry is small; test any
// entries the main loop did not cover.
if (_composer.hasGhosts) {
for (final ghostKey in _composer.ghosts.activeKeys) {
final nid = controller.nidOf(ghostKey);
if (nid < 0) continue;
final i = controller.visibleIndexOfNid(nid);
if (i < 0) continue;
// Rows inside the bounded range were already tested above.
if (i >= startIndex && _structuralOffsetAt(i, nid) <= hitBound) {
continue;
}
if (_sticky.isSticky(nid)) continue;
if (testRow(i)) return true;
}
}
return false;
}