matsudamper.hatenablog.com のModifier.Node APIを使った実装です。
@Stable public class StickyHeaderState( private val enterAlways: Boolean, ) { internal var headerHeight by mutableStateOf(0) internal var scrolled by mutableStateOf(0f) public fun createNestedScrollState( listState: ScrollableState, ): NestedScrollConnection { return object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val listState = listState if (!enterAlways) { if (listState.canScrollBackward && available.y > 0) { return Offset.Zero } } val newScrolled = (scrolled + available.y) .coerceAtLeast(-headerHeight.toFloat()) .coerceAtMost(0f) val consume = newScrolled - scrolled scrolled = newScrolled return Offset(0f, consume) } override suspend fun onPreFling(available: Velocity): Velocity { return Velocity.Zero } override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { return onPreScroll(available, source) } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { return Velocity.Zero } } } } public fun Modifier.stickyHeader(state: StickyHeaderState): Modifier { return this then StickyHeaderModifierNodeElement( scrolled = state.scrolled, updateHeight = { height -> state.headerHeight = height }, ) } public fun Modifier.stickyHeaderScrollable( listState: ScrollableState, state: StickyHeaderState, ): Modifier { return this.composed { nestedScroll( remember(listState) { state.createNestedScrollState(listState) }, ) } } internal class StickyHeaderModifierNodeElement( internal var scrolled: Float, internal var updateHeight: (Int) -> Unit, ) : ModifierNodeElement<StickyHeaderModifierNode>() { override fun create(): StickyHeaderModifierNode { return StickyHeaderModifierNode( scrolled = scrolled, updateHeight = updateHeight, ) } override fun update(node: StickyHeaderModifierNode) { node.scrolled = scrolled node.updateHeight = updateHeight } override fun hashCode(): Int = scrolled.hashCode() override fun equals(other: Any?): Boolean { if (other !is StickyHeaderModifierNodeElement) return false return other.scrolled == scrolled } } internal class StickyHeaderModifierNode( internal var scrolled: Float, internal var updateHeight: (Int) -> Unit, ) : LayoutModifierNode, Modifier.Node() { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val placeable = measurable.measure(constraints) updateHeight(placeable.height) val containerHeight = (placeable.height + scrolled).roundToInt() return layout( width = placeable.width, height = containerHeight, ) { placeable.place(0, Alignment.Bottom.align(placeable.height, containerHeight)) } } }