본문 바로가기
Android/Compose

[Android] Compose를 활용한 Collapsing Toolbar Scaffold 제작기

by 굿햄 2023. 7. 23.

✏️ 안드로이드 Compose 기반의Collapsing Toolbar Scaffold 제작기

현재 회사에서 서비스중인 앱 중 일부를 기존 명령형 UI 방식인 View에서, Compose 기반으로 변경하게 되었습니다.

 

그러던 도중 아래와 같이 View 시스템의 CoordinatorLayout을 이용하여 Collapsing Toolbar와 TabRow 및 ViewPager2가 구현된 곳을 발견하였고, 이를 Compose에서 어떻게 구현할 것인지 고민이 되었습니다.

 

 

Compose에서는 CoordinatorLayout이 없으며, CollapsingToolbarLayout 또한 없기에 한번 직접 구현해보기로 하였습니다.

 

구현해야 할 사항을 나열하면 아래와 같습니다.

  • 아래로 스크롤 시, 헤더 영역(게임 주요 정보)이 우선 가려진 후 ViewPager내에서 스크롤이 이루어져야 한다.
  • 위로 스크롤 시, ViewPager내에서 스크롤이 우선 이루어진 후 부모 영역에 스크롤을 전달하여 헤더가 보이도록 한다.
  • 가로 스크롤 시, ViewPager내에서 이벤트가 처리되도록 한다.
  • LazyColumn을 사용해도 오류가 발생하지 않아야하며, 툴바 및 헤더 영역을 제외한 화면 크기를 전체 사이즈로 측정할 수 있게 한다.

 

Compose 환경에서 기본적으로 세로 스크롤을 구현하기 위해 Modifier에 verticalScroll 수정자를 사용합니다.

이 때 스크롤 이벤트는 가장 하위 컴포저블로 전달이 되며, 이를 Parent Composable로 전달받기 위해 nestedScroll 수정자를 활용합니다.

 

NestedScrollConnection은 4개의 오버라이딩 함수를 제공하는데,
아래에서는 헤더가 보이는지 유무에 따라 Parent composable이 이벤트를 받았다가 경우에 따라 무시할 수 있도록 작성하였습니다.

private var isHeaderHide = false

internal class BackdropScrollConnection(
    private val scrollState: ScrollState,
) : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val dy = available.y

        return when {
            /**
             * 헤더가 보이지 않는 상태일 경우, onPreScroll에서 Offset.Zero를 반환하여 부모가 스크롤을 받지 않도록 한다.
             */
            isHeaderHide -> {
                Offset.Zero
            }

            /**
             * 터치를 아래에서 위로, 즉 화면을 위로 올릴 때,
             * 부모가 스크롤 이벤트를 우선적으로 받는다.
             */
            dy < 0 -> {
                scrollState.dispatchRawDelta(dy * -1)
                Offset(0f, dy)
            }

            /**
             * 터치를 위에서 아래로, 즉 화면을 아래로 내릴 때,
             * NestedScrollConnection을 통해 부모가 스크롤 이벤트를 받지 않도록 한다.
             */
            else -> {
                Offset.Zero
            }
        }
    }
}

 

그 다음 헤더가 보이는지 판단할 수 있도록 헤더에서 스스로 판단할 수 있도록 작성합니다.

만약 보이지 않는다면 onHide(true) 함수를 호출하여 상위 컴포저블에 전달합니다.

@Composable
private fun HeaderSection(
    header: @Composable () -> Unit,
    onHide: (Boolean) -> Unit,
) {
    var contentHeight by remember { mutableStateOf(0) }
    var visiblePercentage by remember { mutableStateOf(1f) }

    /**
     * 헤더가 보이는 비율이 변경되면, onHide를 호출하여 헤더가 보이는지 여부를 전달한다.
     */
    LaunchedEffect(visiblePercentage) {
        if (visiblePercentage <= 0f) {
            onHide(true)
        } else {
            onHide(false)
        }
    }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .onGloballyPositioned { layoutCoordinates ->
                /**
                 * 헤더가 보이는 비율을 계산한다.
                 */
                visiblePercentage = layoutCoordinates.boundsInRoot().height / contentHeight
            }.onSizeChanged {
                contentHeight = it.height
            }
            .alpha(visiblePercentage)
    ) {
        header()
    }
}

 

이제 마지막으로 Scaffold와 같은 형식으로 사용할 수 있게 전체 코드를 작성합니다.

private var isHeaderHide = false

internal class BackdropScrollConnection(
    private val scrollState: ScrollState,
) : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val dy = available.y

        return when {
            /**
             * 헤더가 보이지 않는 상태일 경우, onPreScroll에서 Offset.Zero를 반환하여 부모가 스크롤을 받지 않도록 한다.
             */
            isHeaderHide -> {
                Offset.Zero
            }

            /**
             * 터치를 아래에서 위로, 즉 화면을 위로 올릴 때,
             * 부모가 스크롤 이벤트를 우선적으로 받는다.
             */
            dy < 0 -> {
                scrollState.dispatchRawDelta(dy * -1)
                Offset(0f, dy)
            }

            /**
             * 터치를 위에서 아래로, 즉 화면을 아래로 내릴 때,
             * NestedScrollConnection을 통해 부모가 스크롤 이벤트를 받지 않도록 한다.
             */
            else -> {
                Offset.Zero
            }
        }
    }
}

@Composable
fun CollapsingToolbarScaffold(
    modifier: Modifier = Modifier,
    topBar: @Composable () -> Unit = {},
    header: @Composable () -> Unit = {},
    stickyHeader: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable () -> Unit = {},
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    containerColor: Color = MaterialTheme.colorScheme.background,
    contentColor: Color = contentColorFor(containerColor),
    contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
    content: @Composable () -> Unit,
) {
    val scrollState = rememberScrollState()

    val nestedScrollConnection = remember {
        BackdropScrollConnection(
            scrollState = scrollState,
        )
    }

    Scaffold(
        modifier = modifier,
        topBar = topBar,
        bottomBar = bottomBar,
        snackbarHost = snackbarHost,
        floatingActionButton = floatingActionButton,
        floatingActionButtonPosition = floatingActionButtonPosition,
        containerColor = containerColor,
        contentColor = contentColor,
        contentWindowInsets = contentWindowInsets,
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize()
        ) {
            var globalHeight by remember { mutableStateOf(0) }

            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Transparent)
                    .onSizeChanged { size ->
                        globalHeight = size.height
                    }
                    .nestedScroll(
                        connection = nestedScrollConnection,
                    )
                    .verticalScroll(scrollState),
            ) {
                HeaderSection(
                    header = header,
                    onHide = { isHide ->
                        isHeaderHide = isHide
                    }
                )

                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(globalHeight.pxToDp().dp)
                ) {
                    stickyHeader()
                    content()
                }
            }
        }
    }
}

@Composable
private fun HeaderSection(
    header: @Composable () -> Unit,
    onHide: (Boolean) -> Unit,
) {
    var contentHeight by remember { mutableStateOf(0) }
    var visiblePercentage by remember { mutableStateOf(1f) }

    /**
     * 헤더가 보이는 비율이 변경되면, onHide를 호출하여 헤더가 보이는지 여부를 전달한다.
     */
    LaunchedEffect(visiblePercentage) {
        if (visiblePercentage <= 0f) {
            onHide(true)
        } else {
            onHide(false)
        }
    }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .onGloballyPositioned { layoutCoordinates ->
                /**
                 * 헤더가 보이는 비율을 계산한다.
                 */
                visiblePercentage = layoutCoordinates.boundsInRoot().height / contentHeight
            }
            .onSizeChanged {
                contentHeight = it.height
            }
            .alpha(visiblePercentage)
    ) {
        header()
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Preview(
    showBackground = true,
    showSystemUi = false,
)
@Composable
internal fun CollapsingToolbarScaffoldPreview() {
    AppTheme {
        CollapsingToolbarScaffold(
            topBar = {
                Text(text = "TopBar")
            },
            header = {
                repeat(10) {
                    Text(text = "Header")
                }
            },
            stickyHeader = {
                Text(text = "Sticky Header")
            },
        ) {
            HorizontalPager(
                pageCount = 5,
                state = rememberPagerState(),
                contentPadding = PaddingValues(start = 20.dp, end = 120.dp),
                pageSpacing = 8.dp
            ) { index ->
                LazyColumn(
                    modifier = Modifier
                        .fillMaxWidth()
                ) {
                    items(100) {itemNumber ->
                        Text(text = "page: $index, item: $itemNumber")
                    }
                }
            }
        }
    }
}

 

테스트를 돌려보면!

이제 header와 stickHeader가 존재하면서 fillMaxSize 수정자를 사용하여도 에러가 발생하지 않는다!!

 

📕 후일담

Compose 에서의 CollapsingToolbarLayout를 사용하기 위해 몇몇 개발자분께서 라이브러리를 만든 것을 확인할 수 있었습니다.

 

https://github.com/onebone/compose-collapsing-toolbar

 

GitHub - onebone/compose-collapsing-toolbar: A simple implementation of collapsing toolbar for Jetpack Compose

A simple implementation of collapsing toolbar for Jetpack Compose - GitHub - onebone/compose-collapsing-toolbar: A simple implementation of collapsing toolbar for Jetpack Compose

github.com

대표적인 것이 onebone님께서 제작하신 위 라이브러리였고, 개발자님의 제작기를 보고 활용해보니 원활하게 구동이 되었고 완성도가 높다고 판단되어 제가 작성한 코드는 더이상 사용하지 않게 되었습니다

 

Layout과 SubComposeLayout쪽을 좀 더 학습해서 다음에 비슷한 경우가 생기면, Low Level로 작성할 수 있도록 해야할 듯 싶네요.


ⓒ 굿햄 2022. daryeou@gmail.com all rights reserved.

댓글