Modifier.Node APIを使った最新版実装はこちら。
https://matsudamper.hatenablog.com/entry/2026/02/11/120944
概要
CoordinatorLayoutのenterAlwaysを作成します。
必要なもの等
- スクロールを検知して止めたりする
- レイアウトを移動させる
- 影の制御
Compose
解説は末尾でやります。
完成品
@Composable public fun HeaderEnterAlwaysColumn( modifier: Modifier = Modifier, state: HeaderEnterAlwaysColumnState = remember { HeaderEnterAlwaysColumnState() }, content: @Composable HeaderEnterAlwaysColumnScope.() -> Unit ) { val connection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val currentHeaderOffset = state.headerOffset.value val currentHeight = state.headerHeight.value when (val preOffset = currentHeaderOffset + available.y) { in -Float.MAX_VALUE..-currentHeight.toFloat() -> { // Up Overscroll state.headerOffset.value = -currentHeight.toFloat() return Offset(0f, currentHeaderOffset + currentHeight) } in -currentHeight.toFloat()..0f -> { state.headerOffset.value = preOffset return available } else -> { // Down Overscroll state.headerOffset.value = 0f return Offset(0f, currentHeaderOffset) } } } } } Column( modifier = Modifier .nestedScroll(connection) .clipToBounds() .then(modifier) ) { with(HeaderEnterAlwaysColumnScope(state)) { content() } } } public class HeaderEnterAlwaysColumnState { internal val headerHeight = MutableStateFlow(0) internal val headerOffset = MutableStateFlow(0f) } public class HeaderEnterAlwaysColumnScope( private val state: HeaderEnterAlwaysColumnState ) { public fun Modifier.registerHeader(): Modifier = composed { val headerOffset = state.headerOffset.collectAsState().value return@composed layout { measurable, constraints -> val placeable = measurable.measure(constraints) state.headerHeight.value = placeable.height val newHeight = when (val height = placeable.height + headerOffset) { in -Float.MAX_VALUE..0f -> 0f in 0f..placeable.height.toFloat() -> height else -> placeable.height } layout(placeable.width, newHeight.toInt()) { placeable.place(x = 0, y = headerOffset.toInt()) } } } }
Preview
@Composable @Preview private fun Preview() { val items: List<String> = remember { (0..100).map { it.toString() } } Column(modifier = Modifier.fillMaxSize()) { TopAppBar( modifier = Modifier .fillMaxWidth(), ) {} HeaderEnterAlwaysColumn(modifier = Modifier.fillMaxSize()) { Surface( modifier = Modifier .zIndex(Float.MAX_VALUE) .registerHeader() .fillMaxWidth() .height(50.dp), elevation = 10.dp, ) { } LazyColumn( state = rememberLazyListState(), modifier = Modifier .fillMaxSize() .zIndex(1f) ) { itemsIndexed(items, key = { _, item -> item }) { index, item -> Card( modifier = Modifier .height(100.dp) .fillMaxWidth() .padding(vertical = 4.dp, horizontal = 8.dp), ) { } } } } } }
解説
スクロールの検知
今回の一番重要な部分。スクロールの検知です。
onPreScroll では available に実際のスクロール量が来るので、消費したい分を戻り値でOffsetを返します。
どれだけヘッダーが隠れているかの値を保持し、それと available を見て、どれだけ消費するか(または消費しないか)を決定します。
val connection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { TODO() } } }
ZIndex
今回はshadow(zIndex)を制御するために、Modifierを使用しましたが、影を使用しない場合はScopeとModifier無しでも作れます。
zIndexを指定しない場合はこのようになります。
1 -> 2 -> 3の順番で下から上に乗っかっているので、2で描かれたshadowは、3の裏に来てしまいます。

そして、zIndexは同一親の直下の子でないと動作しないので、フラットにレイアウトを組む必要があり、そのためにModifierを使用しました。
例)
|
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Surface(modifier = Modifier.size(100.dp)) {
}
Surface(modifier = Modifier.size(150.dp), color = Color.Green) {
}
}
Box(
Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Surface(modifier = Modifier
.size(100.dp)
.zIndex(1f) // これでこっちが上に来る
) { }
Surface(modifier = Modifier.size(150.dp), color = Color.Green) {
}
}
Box(
Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Box {
// 直下ではなく、1つBoxをはさんでいるので、このZIndexは無効
Surface(modifier = Modifier.size(100.dp).zIndex(1f)) {
}
}
Surface(modifier = Modifier.size(150.dp), color = Color.Green) {
}
}
}
|
Modifier
スクロールをハンドリングの仕方はわかりました。そして、フラットに組まないといけないので、組んでいきます。
// まずはStateを作成します。 public class HeaderEnterAlwaysColumnState { internal val headerHeight = MutableStateFlow(0) internal val headerOffset = MutableStateFlow(0f) } // Scopeを作成します。 public class HeaderEnterAlwaysColumnScope( private val state: HeaderEnterAlwaysColumnState ) { // HeaderEnterAlwaysColumnScopeが"this"にあるときだけ呼び出せるModifierの拡張関数を作成します。 public fun Modifier.registerHeader(): Modifier = composed { // composedを使用することで、中でComposableな関数を呼び出せます。 // ここではcollectAsState()を呼び出しています。これにより、headerOffsetが変更されたら、レイアウトが変更されるModifierを作成することができます。 val headerOffset = state.headerOffset.collectAsState().value // 以下はlayoutの基本的な形になります。これをheaderOffsetを使用してレイアウトするようにします。 return@composed layout { measurable, constraints -> val placeable = measurable.measure(constraints) layout(placeable.width, placeable.height) { placeable.place(x = 0, y = 0) } } } } @Composable public fun HeaderEnterAlwaysColumn( modifier: Modifier = Modifier, // ユーザーの任意のスコープで状態を保存できるように、引数で受け取れるようにします。 state: HeaderEnterAlwaysColumnState = remember { HeaderEnterAlwaysColumnState() }, // レシーバはスコープにします。 content: @Composable HeaderEnterAlwaysColumnScope.() -> Unit ) { val connection: NestedScrollConnection = remember { TODO() } Column( modifier = Modifier .nestedScroll(connection) .clipToBounds() .then(modifier) ) { // スコープを作成してcontentを呼びます。 with(HeaderEnterAlwaysColumnScope(state)) { content() } } } HeaderEnterAlwaysColumn(modifier = Modifier.fillMaxSize()) { // this is HeaderEnterAlwaysColumnScope Surface( modifier = Modifier .zIndex(Float.MAX_VALUE) .registerHeader(), // ここで呼びます。 elevation = 10.dp, ) { TODO() } TODO() }
終わりに
そのうち標準で用意されるか、既にあるのに車輪の再発明しているかもしれませんね。