applyStickyChildOffset static method
void
applyStickyChildOffset(
- RenderBoxModel parent,
- RenderBoxModel child, {
- RenderBoxModel? scrollContainer,
Implementation
static void applyStickyChildOffset(
RenderBoxModel parent, RenderBoxModel child,
{RenderBoxModel? scrollContainer}) {
if (child.renderStyle.position != CSSPositionType.sticky) return;
// Identify the scroll container that constrains stickiness.
RenderBoxModel? scroller = scrollContainer ??
_nearestScrollContainer(parent) ??
_nearestScrollContainer(child);
// Use zero scroll if no container; sticky behaves like relative.
final double scrollTop = scroller?.scrollTop ?? 0.0;
final double scrollLeft = scroller?.scrollLeft ?? 0.0;
// Prefer the scroller's computed viewport (content + padding inside borders) if available
final Size viewport = (scroller != null && scroller.hasSize)
? scroller.scrollableViewportSize
: Size.infinite;
// Measure child's base offset in the scroll container's coordinate space (content box),
// excluding any paint-time scroll transform. This ensures sticky math uses the correct
// reference regardless of intermediate wrappers or containing block differences.
Offset baseInScroller = Offset.zero;
if (scroller != null) {
try {
baseInScroller = child.getOffsetToAncestor(Offset.zero, scroller,
excludeScrollOffset: true);
} catch (_) {
// Fallback to local parent offset if transform fails; better than nothing.
final RenderLayoutParentData pd =
child.parentData as RenderLayoutParentData;
baseInScroller = pd.offset;
}
}
final CSSRenderStyle rs = child.renderStyle;
final double childW = child.boxSize?.width ?? child.size.width;
final double childH = child.boxSize?.height ?? child.size.height;
final CSSRenderStyle? scrollerStyle = scroller?.renderStyle;
final double scrollerPaddingLeft =
scrollerStyle?.paddingLeft.computedValue ?? 0.0;
final double scrollerPaddingRight =
scrollerStyle?.paddingRight.computedValue ?? 0.0;
final double scrollerPaddingTop =
scrollerStyle?.paddingTop.computedValue ?? 0.0;
final double scrollerPaddingBottom =
scrollerStyle?.paddingBottom.computedValue ?? 0.0;
// Natural on-screen position relative to the scroll container's viewport.
double natY = baseInScroller.dy - scrollTop;
double natX = baseInScroller.dx - scrollLeft;
double desiredY = natY;
double desiredX = natX;
// Do not add sticky insets at rest. The natural position should remain
// unchanged until it crosses the specified threshold relative to the
// viewport (or the containing block bounds). Insets participate only as
// constraints below via clamping, which matches browser behavior.
// Apply vertical stickiness constraints relative to the viewport.
if (rs.top.isNotAuto || rs.bottom.isNotAuto) {
if (viewport.height.isFinite) {
// Top stick: engage as soon as the natural top would cross the top edge.
if (rs.top.isNotAuto) {
final double topLimit = rs.top.computedValue + scrollerPaddingTop;
if (natY < topLimit) desiredY = math.max(desiredY, topLimit);
}
// Bottom stick: engage when the natural top exceeds the bottom clamp threshold.
// For non-scroller parents (e.g., body/root flow), clamp even if not yet visible so a
// bottom-sticky appears at the viewport bottom at rest. For scroller parents, only clamp
// when at least partially visible to avoid premature snapping.
if (rs.bottom.isNotAuto) {
final double bottomInset =
rs.bottom.computedValue + scrollerPaddingBottom;
final double maxY = viewport.height - bottomInset - childH;
final bool parentIsScrollerForViewport =
(scroller != null) && identical(parent, scroller);
final bool isPartiallyVisible = natY < viewport.height;
if (natY > maxY) {
if (!parentIsScrollerForViewport || isPartiallyVisible) {
desiredY = math.min(desiredY, maxY);
}
}
}
}
}
// Apply horizontal stickiness constraints (relative to viewport) only when entering horizontally.
if (rs.left.isNotAuto || rs.right.isNotAuto) {
if (viewport.width.isFinite) {
if (rs.left.isNotAuto) {
final double leftLimit = rs.left.computedValue + scrollerPaddingLeft;
if (natX < leftLimit) desiredX = math.max(desiredX, leftLimit);
}
if (rs.right.isNotAuto) {
final double rightInset =
rs.right.computedValue + scrollerPaddingRight;
final double maxX = viewport.width - rightInset - childW;
final bool isPartiallyVisibleX = natX < viewport.width;
if (isPartiallyVisibleX && natX > maxX) {
desiredX = math.min(desiredX, maxX);
}
}
}
}
// Constrain within the containing block (parent) padding box so sticky never
// leaves its own containing block. Compute the parent's padding-box edges in
// the scroller's coordinate space.
if (parent.attached && scroller != null && parent.boxSize != null) {
try {
final Offset parentToScroller = parent.getOffsetToAncestor(
Offset.zero, scroller,
excludeScrollOffset: true);
final CSSRenderStyle p = parent.renderStyle;
final double padLeftEdgeS =
parentToScroller.dx + p.effectiveBorderLeftWidth.computedValue;
final double padTopEdgeS =
parentToScroller.dy + p.effectiveBorderTopWidth.computedValue;
final double padRightEdgeS = parentToScroller.dx +
parent.boxSize!.width -
p.effectiveBorderRightWidth.computedValue;
final double padBottomEdgeS = parentToScroller.dy +
parent.boxSize!.height -
p.effectiveBorderBottomWidth.computedValue;
// Convert parent padding edges to viewport coordinates.
// When the parent IS the scroller, its padding box does not move relative to its
// own viewport as scrolling occurs, so do NOT subtract the scroller's scroll offset.
// For non-scroller parents (ancestors inside the scroller's content), subtracting
// scroll aligns them to the scroller's viewport coordinate space.
final bool parentIsScroller = identical(parent, scroller);
double padLeftEdgeV;
double padTopEdgeV;
double padRightEdgeV;
double padBottomEdgeV;
if (parentIsScroller) {
// When clamping to the scroll container itself, use the scrollport (viewport inside padding edges)
// in the same coordinate space as natX/natY (which already excludes the scroller's borders).
padLeftEdgeV = 0.0;
padTopEdgeV = 0.0;
padRightEdgeV = viewport.width.isFinite
? viewport.width
: (parent.boxSize!.width -
p.effectiveBorderLeftWidth.computedValue -
p.effectiveBorderRightWidth.computedValue);
padBottomEdgeV = viewport.height.isFinite
? viewport.height
: (parent.boxSize!.height -
p.effectiveBorderTopWidth.computedValue -
p.effectiveBorderBottomWidth.computedValue);
} else {
// For non-scroller parents, transform the parent's padding edges into the scroller's viewport space.
padLeftEdgeV = padLeftEdgeS - scrollLeft;
padTopEdgeV = padTopEdgeS - scrollTop;
padRightEdgeV = padRightEdgeS - scrollLeft;
padBottomEdgeV = padBottomEdgeS - scrollTop;
}
// Clamp within containing block padding box in viewport space; honor sticky insets.
// Special-case large top/left when the parent is the scroller: Chrome keeps the sticky
// out of view at initial scroll (scrollTop/Left == 0) if the requested inset exceeds
// the viewport AND the container can actually scroll on that axis. If there is no
// scrollable overflow, clamp to the container bounds so the sticky remains visible.
final bool topOnly = rs.top.isNotAuto && !rs.bottom.isNotAuto;
final bool leftOnly = rs.left.isNotAuto && !rs.right.isNotAuto;
final bool canScrollY = parent.scrollableSize.height -
parent.scrollableViewportSize.height >
0.5;
final bool canScrollX =
parent.scrollableSize.width - parent.scrollableViewportSize.width >
0.5;
final double clampTopInset = rs.top.computedValue + scrollerPaddingTop;
final double clampLeftInset =
rs.left.computedValue + scrollerPaddingLeft;
final bool suppressYClampInitially = parentIsScroller &&
topOnly &&
viewport.height.isFinite &&
(clampTopInset > (padBottomEdgeV - padTopEdgeV - childH)) &&
scrollTop == 0.0 &&
canScrollY;
final bool suppressXClampInitially = parentIsScroller &&
leftOnly &&
viewport.width.isFinite &&
(clampLeftInset > (padRightEdgeV - padLeftEdgeV - childW)) &&
scrollLeft == 0.0 &&
canScrollX;
// Bounds within the containing block padding box should not incorporate
// sticky insets; insets constrain against the viewport (scrollport).
// Here we only ensure the sticky box never leaves its containing block.
final double minXBound = padLeftEdgeV;
final double maxXBound = padRightEdgeV - childW;
final double minYBound = padTopEdgeV;
final double maxYBound = padBottomEdgeV - childH;
// Always respect containing block bounds (do not allow the sticky box to
// leave its containing block), except in the special initial suppression
// cases for scroller parents handled above.
if (!suppressXClampInitially) {
desiredX = desiredX.clamp(minXBound, maxXBound);
}
if (!suppressYClampInitially) {
desiredY = desiredY.clamp(minYBound, maxYBound);
}
} catch (_) {}
}
// Convert desired on-screen delta back to an additional paint offset.
// additional = desiredOnScreen - currentOnScreen = desired - (base - scroll)
final double addY = desiredY - natY;
final double addX = desiredX - natX;
if ((child.additionalPaintOffsetY ?? 0.0) != addY) {
child.additionalPaintOffsetY = addY;
}
if ((child.additionalPaintOffsetX ?? 0.0) != addX) {
child.additionalPaintOffsetX = addX;
}
}