시배's Android

Compose | Compose로 CollapsingToolbarLayout 구현하기 본문

Android/Compose

Compose | Compose로 CollapsingToolbarLayout 구현하기

si8ae 2023. 7. 12. 11:32

CollapsingToolbarLayout

CollapsingToolbarLayout은 스크롤 동작에 따라 다양한 효과를 적용할 수 있습니다. 예를 들어 스크롤을 아래로 내릴 때 툴바가 축소되면 화면 상단에 고정되고 이미지가 페이드아웃되는 효과 등을 설정할 수 있습니다.

 

위 Gif처럼 Compose에서 구현을 하려면 TopAppBarDefaults에 정의되어 있는 ScrollBehavior와 LargeTopAppBar를 통해 구현할 수 있습니다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Test() {
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()

    Scaffold(
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = {
            LargeTopAppBar(
                title = { Text("ABC") },
                scrollBehavior = scrollBehavior
            )
        },
    ) {
        LazyColumn(modifier = Modifier.fillMaxSize().padding(it)){
            items(100){
                Text("test $it")
            }
        }
    }
}

TopAppBarDefaults에서도 exitUntilCollapsedScrollBehavior를 활용하여 scrollBehavior를 LargeTopAppBar의 scrollBehavior 파라미터에 넣어줍니다.

그리고 LargeTopAppBar의 부모인 Scaffold의 modifier에 nestedScroll에 nestedScrollConnection을 넣어주면 끝입니다.

 

협업 중 디자이너와 함께 작업할 때, Material 디자인을 그대로 사용하는 것보다는 커스텀 디자인을 적용해야 하는 경우가 많습니다. 예를 들어, TopAppBar의 높이, 제목의 글꼴 크기 등을 최소 크기와 최대 크기로 지정하려는 경우, 기존의 scrollBehavior와 LargeAppBar 만으로는 원하는 결과를 얻기 어려울 수 있습니다.

이러한 경우에는 커스텀 디자인을 구현해야 합니다.

 

@OptIn(ExperimentalMaterial3Api::class)
private class ExitUntilCollapsedScrollBehavior(
    override val state: TopAppBarState,
    override val snapAnimationSpec: AnimationSpec<Float>?,
    override val flingAnimationSpec: DecayAnimationSpec<Float>?,
    val canScroll: () -> Boolean = { true }
) : TopAppBarScrollBehavior {
    override val isPinned: Boolean = false
    override var nestedScrollConnection =
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Don't intercept if scrolling down.
                if (!canScroll() || available.y > 0f) return Offset.Zero

                val prevHeightOffset = state.heightOffset
                state.heightOffset = state.heightOffset + available.y
                return if (prevHeightOffset != state.heightOffset) {
                    // We're in the middle of top app bar collapse or expand.
                    // Consume only the scroll on the Y axis.
                    available.copy(x = 0f)
                } else {
                    Offset.Zero
                }
            }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                if (!canScroll()) return Offset.Zero
                state.contentOffset += consumed.y

                if (available.y < 0f || consumed.y < 0f) {
                    // When scrolling up, just update the state's height offset.
                    val oldHeightOffset = state.heightOffset
                    state.heightOffset = state.heightOffset + consumed.y
                    return Offset(0f, state.heightOffset - oldHeightOffset)
                }

                if (consumed.y == 0f && available.y > 0) {
                    // Reset the total content offset to zero when scrolling all the way down. This
                    // will eliminate some float precision inaccuracies.
                    state.contentOffset = 0f
                }

                if (available.y > 0f) {
                    // Adjust the height offset in case the consumed delta Y is less than what was
                    // recorded as available delta Y in the pre-scroll.
                    val oldHeightOffset = state.heightOffset
                    state.heightOffset = state.heightOffset + available.y
                    return Offset(0f, state.heightOffset - oldHeightOffset)
                }
                return Offset.Zero
            }

            override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
                val superConsumed = super.onPostFling(consumed, available)
                return superConsumed + settleAppBar(
                    state,
                    available.y,
                    flingAnimationSpec,
                    snapAnimationSpec
                )
            }
        }
}

안드로이드는 오픈 소스와 풍부한 공식 문서를 가지고 있어 개발자들에게 많은 장점을 제공합니다. 기본적인 디자인 컴포넌트 중 하나인 TopAppBar의 동작을 커스텀하기 위해 TopAppBarDefaults 클래스의 구현을 살펴보면, nestedScrollConnection을 상속받아 onPreScroll, onPostScroll, onPostFling을 재정의하고 있는 것을 확인할 수 있습니다.

NestedScrollConnection은 NestedScrollingChild와 NestedScrollingParent 사이의 상호 작용을 관리하는 역할을 하며, 이를 활용하여 TopAppBar의 동작을 커스텀할 수 있습니다.

  • onPreScroll 메서드는 스크롤 이벤트가 발생하기 전에 호출되는 메서드입니다. 이 메서드를 재정의하여 사용자의 동작에 대한 사전 처리를 수행할 수 있습니다.
  • onPostScroll 메서드는 스크롤 이벤트가 발생한 후에 호출되는 메서드입니다. 이 메서드를 재정의하여 사용자의 스크롤 동작에 대한 사후 처리를 수행할 수 있습니다.
  • onPostFling 메서드는 스크롤 터치 후 손을 떼었을 때 발생하는 플링 동작에 대한 처리를 담당합니다. 이 메서드를 재정의하여 플링 동작에 대한 추가적인 처리를 수행할 수 있습니다.

위의 기본 구현을 참고하여 커스텀한 동작을 구현할 있습니다. TopAppBar nestedScrollConnection 상속받아 원하는 동작을 onPreScroll, onPostScroll, onPostFling에서 처리하면 됩니다.

 

@Composable
fun Test() {
    val spacerSize = 10.dp
    val spacerSizePx = with(LocalDensity.current) { spacerSize.roundToPx().toFloat() }
    val spacerSizePxState = rememberSaveable { mutableStateOf(spacerSizePx) }

    val fontSize = 26.sp
    val fontSizePx = with(LocalDensity.current) { fontSize.roundToPx().toFloat() }
    val fontSizePxState = rememberSaveable { mutableStateOf(fontSizePx) }

    val minFontSizePx = with(LocalDensity.current) { 20.sp.roundToPx().toFloat() }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                if (available.y > 0f) return Offset.Zero

                val prevSpacerSizePx = spacerSizePxState.value
                val newSpacerSizePx =
                    prevSpacerSizePx + available.y * spacerSizePx / (fontSizePx - minFontSizePx)
                spacerSizePxState.value = newSpacerSizePx.coerceIn(0f, spacerSizePx)

                val newFontSizePx = fontSizePxState.value + available.y
                fontSizePxState.value = newFontSizePx.coerceIn(minFontSizePx, fontSizePx)

                return if (prevSpacerSizePx != spacerSizePxState.value) {
                    available.copy(x = 0f)
                } else {
                    Offset.Zero
                }
            }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                if (available.y > 0f) {
                    val newSpacerSizePx =
                        spacerSizePxState.value + available.y * spacerSizePx / (fontSizePx - minFontSizePx)
                    spacerSizePxState.value = newSpacerSizePx.coerceIn(0f, spacerSizePx)

                    val newFontSizePx = fontSizePxState.value + available.y
                    fontSizePxState.value = newFontSizePx.coerceIn(minFontSizePx, fontSizePx)

                    return Offset(0f, available.y)
                }

                return Offset.Zero
            }
        }
    }

    Scaffold(
        modifier = Modifier.nestedScroll(nestedScrollConnection),
        topBar = {
            Column() {
                Spacer(modifier = Modifier.height(with(LocalDensity.current){spacerSizePxState.value.toDp()}))
                Text(text="ABC", fontSize = with(LocalDensity.current){fontSizePxState.value.toSp()})
            }
        },
    ) {
        LazyColumn(modifier = Modifier
            .fillMaxSize()
            .padding(it)){
            items(100){
                Text("test $it")
            }
        }
    }
}

exitUntilCollapsed 동작을 구현하기 위해서는 onPreScroll에서 available.y 값이 음수인지 확인하고, TopAppBar 크기를 조정하는 작업을 수행하면 됩니다. 그리고 onPostScroll에서 available.y 값이 양수인지 확인하고, TopAppBar 크기를 다시 증가시키는 작업을 수행합니다.