アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

【Jetpack Compose】CoordinatorLayoutのenterAlwaysを作る

概要

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の裏に来てしまいます。
f:id:matsudamper:20211105231205p:plain

そして、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()
}

終わりに

そのうち標準で用意されるか、既にあるのに車輪の再発明しているかもしれませんね。