applyStickyChildOffset static method

void applyStickyChildOffset(
  1. RenderBoxModel parent,
  2. RenderBoxModel child, {
  3. 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 (_) {}
}