概要
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() }
終わりに
そのうち標準で用意されるか、既にあるのに車輪の再発明しているかもしれませんね。