アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録。

【Jetpack Compose】CoordinatorLayoutのヘッダーの動作を作る【Modifier.Node API実装】

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))
        }
    }
}