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;
// Debug: entering sticky computation summary
try {
final String cTag = rs.target.tagName.toLowerCase();
final String pTag = parent.renderStyle.target.tagName.toLowerCase();
final String sTag = scroller != null ? scroller.renderStyle.target.tagName.toLowerCase() : 'none';
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => '<$cTag> sticky enter parent=<$pTag> scroller=<$sTag> '
'parentIsScroller=${scroller != null && identical(parent, scroller)} '
'viewport=${viewport.width.isFinite ? viewport.width.toStringAsFixed(2) : '∞'}×${viewport.height.isFinite ? viewport.height.toStringAsFixed(2) : '∞'} '
'scroll=(${scrollLeft.toStringAsFixed(2)},${scrollTop.toStringAsFixed(2)}) '
'childSize=${childW.toStringAsFixed(2)}×${childH.toStringAsFixed(2)}',
);
} catch (_) {}
// 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;
// Debug: natural position
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'nat=(${natX.toStringAsFixed(2)},${natY.toStringAsFixed(2)}) desired(init)='
'(${desiredX.toStringAsFixed(2)},${desiredY.toStringAsFixed(2)})',
);
} catch (_) {}
// 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;
final double before = desiredY;
if (natY < topLimit) desiredY = math.max(desiredY, topLimit);
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'viewportY top inset=${topLimit.toStringAsFixed(2)} natY=${natY.toStringAsFixed(2)} '
'desired: ${before.toStringAsFixed(2)} → ${desiredY.toStringAsFixed(2)}',
);
} catch (_) {}
}
// 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;
final double before = desiredY;
if (natY > maxY) {
if (!parentIsScrollerForViewport || isPartiallyVisible) {
desiredY = math.min(desiredY, maxY);
}
}
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'viewportY bottom inset=${bottomInset.toStringAsFixed(2)} '
'maxY=${maxY.toStringAsFixed(2)} natY=${natY.toStringAsFixed(2)} '
'parentIsScroller=$parentIsScrollerForViewport partiallyVisible=$isPartiallyVisible '
'desired: ${before.toStringAsFixed(2)} → ${desiredY.toStringAsFixed(2)}',
);
} catch (_) {}
}
}
}
// 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;
final double before = desiredX;
if (natX < leftLimit) desiredX = math.max(desiredX, leftLimit);
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'viewportX left inset=${leftLimit.toStringAsFixed(2)} natX=${natX.toStringAsFixed(2)} '
'desired: ${before.toStringAsFixed(2)} → ${desiredX.toStringAsFixed(2)}',
);
} catch (_) {}
}
if (rs.right.isNotAuto) {
final double rightInset = rs.right.computedValue + scrollerPaddingRight;
final double maxX = viewport.width - rightInset - childW;
final bool isPartiallyVisibleX = natX < viewport.width;
final double before = desiredX;
if (isPartiallyVisibleX && natX > maxX) desiredX = math.min(desiredX, maxX);
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'viewportX right inset=${rightInset.toStringAsFixed(2)} maxX=${maxX.toStringAsFixed(2)} '
'natX=${natX.toStringAsFixed(2)} partiallyVisible=$isPartiallyVisibleX '
'desired: ${before.toStringAsFixed(2)} → ${desiredX.toStringAsFixed(2)}',
);
} catch (_) {}
}
}
}
// 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;
// Debug: suppression flags for initial clamp when parent is the scroller
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'parentIsScroller=$parentIsScroller canScrollY=$canScrollY canScrollX=$canScrollX '
'suppressYClampInitially=$suppressYClampInitially suppressXClampInitially=$suppressXClampInitially',
);
} catch (_) {}
// 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;
// Debug: parent bounds and clamping window
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'parentBoundsV left=${padLeftEdgeV.toStringAsFixed(2)} top=${padTopEdgeV.toStringAsFixed(2)} '
'right=${padRightEdgeV.toStringAsFixed(2)} bottom=${padBottomEdgeV.toStringAsFixed(2)} '
'Xbounds=[${minXBound.toStringAsFixed(2)},${maxXBound.toStringAsFixed(2)}] '
'Ybounds=[${minYBound.toStringAsFixed(2)},${maxYBound.toStringAsFixed(2)}]',
);
} catch (_) {}
// 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) {
final double before = desiredX;
desiredX = desiredX.clamp(minXBound, maxXBound);
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'parentClampX desired: ${before.toStringAsFixed(2)} → ${desiredX.toStringAsFixed(2)}',
);
} catch (_) {}
}
if (!suppressYClampInitially) {
final double before = desiredY;
desiredY = desiredY.clamp(minYBound, maxYBound);
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => 'parentClampY desired: ${before.toStringAsFixed(2)} → ${desiredY.toStringAsFixed(2)}',
);
} catch (_) {}
}
} 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;
}
try {
PositionedLayoutLog.log(
impl: PositionedImpl.layout,
feature: PositionedFeature.sticky,
message: () => '<${child.renderStyle.target.tagName.toLowerCase()}>'
' sticky base=(${baseInScroller.dx.toStringAsFixed(2)},${baseInScroller.dy.toStringAsFixed(2)})'
' scroll=(${scrollLeft.toStringAsFixed(2)},${scrollTop.toStringAsFixed(2)})'
' nat=(${natX.toStringAsFixed(2)},${natY.toStringAsFixed(2)})'
' desired=(${desiredX.toStringAsFixed(2)},${desiredY.toStringAsFixed(2)})'
' add=(${addX.toStringAsFixed(2)},${addY.toStringAsFixed(2)})',
);
} catch (_) {}
}