본문 바로가기

Android/Jetpack Compose

[Android] 사용성 높은 StickyHeader 구현하기

728x90

Compose를 통해 다양한 UI를 그리다 보면, Header라는 이름의 UI를 많이 그려봤을 것이다.

그중에서 Scroll이 가능한 영역에서 Header를 구현할 때 StickyHeader를 많이 사용했을 텐데, 필자는 최근에 기본적인 StickyHeader가 아닌 UI가 변경되면서 다른 형태의 Header를 구현해야 했다.

 

따라서 기본적인 StickyHeader를 사용하는 것이 아닌 다른 형태의 Custom Header를 구현하는 법을 찾아보았고,

생각보다 자주 쓰일 수 있을 것이며 한번 구현해 두면 쉽게 가져다 사용할 수 있는 Component라고 생각이 되었다.

 

간단하지만,

StickyHeader와 더불어 Compose에서 제공하는 Component를 사용하여 구현하는 방법, 직접 Custom 하여 Header를 구현하는 방법에 대하여 작성하고자 한다.


우선,

Compose에서 제공해주는 StickyHeader, opAppBar나 별도의 Header를 커스텀하여 구현하는 방식을 작성한 글이지만,

흔히 스크롤을 해도 상단에 붙어있는 Header를 StickyHeader라고 통칭하기 때문에 글의 제목을 위와 같이 작성하였다는 것을 말하고 진행하도록 하겠다.

 

처음은 기본적으로 StickyHeader이다.

StickyHeader는 앞서 설명했지만 스크롤이 가능한 UI일 때, 상단에 고정되어 더 이상 스크롤 되지 않고 계속 유지되어 보이는 UI를 말한다.

LazyColumn(
    state = listState,
    modifier = Modifier
        .fillMaxSize()
        .background(color = Color.LightGray)
        .padding(padding)
) {
    stickyHeader {
        ExpandedHeaderUI(
            onBackButtonClick = {
                onBackButtonClick.invoke()
            }
        )
    }

    item {
        ...
    }
}

 

간단하게 구현해 보면, 이처럼 나오게 된다.

LazyColumn에 Item을 많이 만들어두고 스크롤 할 때, 상단의 DargGray로 표현된 영역은 고정되어 스크롤되지 않는다.

 

필자는 대부분의 구현에서 이와 같이 단순한 StickyHeader만 사용해서 구현을 많이 했었고, Scroll View가 있는 대다수의 UI는 이와 같이 StickyHeader만 존재해도 쉽게 구현이 가능하다.

 

하지만 필자는 이렇게 고정된 header가 아니라, 스크롤할 때 자연스럽게 사이즈가 조절되어 상단에 붙을 수 있는 Header가 필요했다.

그래서 찾아보다가 발견한 것이 TopBar Component이다.

 

기본적으로 StickyHeader Component가 아닌, Scaffold에서 간단하게 header를 구현하기 위해 제공해 주는 component로 다음과 같이 사용이 가능하다.

Scaffold(
    topBar = {
        TopAppBar(title = { Text("내 앱") }, scrollBehavior = scrollBehavior)
    }
) { padding ->
    ...
}

 

이렇게 사용하게 되면, StickyHeader와 보이는 것은 완전히 동일하게 보이지만, 기본적으로 레이아웃의 계층 구조가 다르게 된다.

 

stickyHeader는 Lazy한 List Component에서 사용이 가능한 것으로, 위에 붙어있는 것은 동일하지만 전체 영역에서 스크롤 시 해당 영역을 고정시킬 뿐이지 처음부터 맨 위에 고정되어 있는 것이 아니다.

이게 무슨말이냐 하면, 다음 영상을 보면 이해가 될 것이다.

item {
    ...
}

stickyHeader {
    ExpandedHeaderUI(
        onBackButtonClick = {
            onBackButtonClick.invoke()
        }
    )
}

item {
    ...
}

 

stickyHeader 위에 item을 하나 두고 구현하게 되면 위와 같이 header로 보이는 영역보다 더 위에 추가한 item이 보이고, 스크롤을 수행하면 stickyHeader가 위에 붙은 상태에서 고정이 되게 된다.

 

하지만, scaffold를 사용해서 구현하게 되면 topBar 영역은 내부 스크롤이 가능한 영역보다 더 상위 레이어에 존재하기 때문에, header 위에 다른 item을 넣을 수 없다.

 

그렇다면 이것을 갑자기 왜 말했는가 생각이 들 수 있다.

위와 같이 topBar만의 구조적인 특징이 있지만, LargeTopAppBar를 사용하면 필자가 원래 구현하고자 했던 형태의 header를 구현할 수 있기 때문이다. 

 

우선, 어떤 형태인지 부터 확인해 보자.

 

똑같이 StickyHeader로 보이지만, LazyColumn 영역을 스크롤하면 Header 영역이 줄어들면서 자연스럽게 더 좁은 UI의 Header로 변경된다.

 

위와 같이 구현하기 위해서 많은 코드를 넣어야 할 것 같지만, 생각보다 간단하게 구현이 가능하다.

val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())

...

Scaffold(
    modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
    topBar = {
        // 크기가 동적으로 변경되는 TopBar
        LargeTopBar(
            scrollBehavior = scrollBehavior,
            onBackButtonClick = {
                onBackButtonClick.invoke()
            }
        )
    }
) { padding ->
    ...
}

...

@Composable
private fun LargeTopBar(
    scrollBehavior: TopAppBarScrollBehavior,
    onBackButtonClick: () -> Unit,
) = LargeTopAppBar(
    title = {
        StickyHeaderComponent(
            onBackButtonClick = onBackButtonClick
        )
    },
    colors = TopAppBarDefaults.mediumTopAppBarColors(
        containerColor = Color.DarkGray, // Expanded
        scrolledContainerColor = Color.DarkGray, // Collapsed
        titleContentColor = Color.LightGray
    ),
    scrollBehavior = scrollBehavior,
)

 

LargeTopAppBar를 사용하면 되고, title이 텍스트가 아닌 compose 함수를 인자로 받기 때문에 위와 같이 구현이 가능하다.

물론, 변수 이름이 title인 만큼 텍스트 형태를 넣었을 때 보다 자연스럽게 형태가 나오기는 하지만 필자처럼 다른 UI를 넣어도 상관없다.

 

또한, 영상을 보면 알 수 있듯이 스크롤하지 않았을 때 header의 height가 상당히 넓은 것으로 보이는데, 저것은 StickyHeaderComponent의 height가 아닌, LargeTopAppBar로 구현하였을 때 기본적으로 잡히는 height 값이고 스크롤하여 header를 줄였을 때의 height가 실제 Component의 높이라는 것을 짚고 넘어가자.

 

즉, LargeTopAppBar의 이름 그대로 넓은 영역의 Header로 보이지만, 스크롤했을 때는 더 많은 컨텐츠를 보여주기 위하여 Header 영역을 줄여주는 component라고 볼 수 있다.

 

여기서 중요한 부분은, nestedScroll 부분이다.

modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),

 

Scaffold의 modifier에 위와 같이 선언을 해주어야 하는데, LargeTopAppbar를 사용할 때 이처럼 nestedScroll을 선언하여 스크롤을 탐지하지 않으면 정상적인 동작을 하지 않는다.

LazyColumn도 Scroll이 가능한 영역이고, 그것의 스크롤 여부를 감지하여 LargeTopAppBar도 스크롤되면서 ui를 변경해 주는 것인데, 하나의 영역에서 스크롤을 한 이벤트가 두 개의 영역에서 감지가 되어야 하기 때문에 반드시 nestedScroll을 선언해주어야 한다.

 

여기서 잠깐 추가적으로 설명하고 넘어가자면,

scrollBehavior 변수를 선언할 때 사용하는 TopAppBarDefaults로 사용할 수 있는 함수는 여러 가지가 존재한다.

TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())

 

이처럼 다른 형태의 함수를 사용하면 그에 따라 LargeTopAppBar가 다르게 동작하니 필요에 따라 지정해서 사용하면 될 것이다.

 

이렇게 Compose에서 제공해 주는 Component를 사용해서 구현해 보았으니,

이제 마지막으로 Custom 하여 Header를 구하는 방식에 대하여 알아보자.

 

구현하는 방법으로는 셀 수 없이 많은 방법이 있겠지만,

필자는 우선 간단하게 LazyColumn 안에 Header로 사용할 item을 구현해 주고,

일정 부분 이상 스크롤이 되면 감지하여 topBar 영역에 선언해 둔 별도의 header를 보여주는 형태로 구현해 보았다.

 

StickyHeader를 사용해도 비슷하게 구현이 가능할 것 같지만, StickyHeader를 사용하지 않고 구현하였으며 최대한 겹치지 않는 component를 사용하여 구현하고자 하였다.

 

우선 색상을 다르게 하여 다른 component임을 영상에서 볼 수 있도록 해보았다.

 

높이가 큰 header를 처음에 보여주고, 스크롤해서 위로 올리면 작은 높이의 header를 보여주면서 scroll을 수행시켜 다른 ui로 변경되도록 하였다.

애니메이션이나 그런 것들의 처리를 하면 비교적 더 자연스럽게 ui를 만들 수 있을 것으로 보이지만, 샘플 코드이므로 아무런 애니메이션을 넣지 않고 구현해 보았다.

 

구현 결과만 보면 LargeTopAppBar와 동일해 보이며, LargeTopAppBar를 사용하는 것이 코드의 양도 적고 훨씬 자연스러운 애니메이션이 기본으로 들어가 쉽게 구현이 가능하지 않는가?라고 생각할 수 있다.

물론 간단하게 같은 UI로 보여준다고 하면 LargeTopAppBar를 사용하는 것이 편하지만, 색을 다르게 표현한 것처럼 스크롤 했을 때 완전히 다른 UI를 보여주고자 한다면 LargeTopAppBar로는 구현하기가 쉽지 않기 때문이다.

 

그렇다면 코드를 확인해 보자.

val listState = rememberLazyListState()
val overlapHeightPx = with(LocalDensity.current) {
    EXPANDED_TOP_BAR_HEIGHT.toPx() - COLLAPSED_TOP_BAR_HEIGHT.toPx()
}
val isCollapsed: Boolean by remember {
    derivedStateOf {
        val isFirstItemHidden =
            listState.firstVisibleItemScrollOffset > overlapHeightPx
        isFirstItemHidden || listState.firstVisibleItemIndex > 0
    }
}

Scaffold(
    topBar = {
        CollapsedHeaderUI(
            modifier = Modifier.zIndex(2f),
            isCollapsed = isCollapsed,
            onBackButtonClick = {
                onBackButtonClick.invoke()
            }
        )
    }
) { padding ->
    LazyColumn(
        state = listState,
        modifier = Modifier
            .fillMaxSize()
            .background(color = Color.LightGray)
            .padding(padding)
    ) {
        item {
            ExpandedHeaderUI(
                onBackButtonClick = {
                    onBackButtonClick.invoke()
                }
            )
        }

        item {
            ViewModelPreview4()
        }

        item {
            repeat(30) { index ->
                Text(text = "Text View : $index", fontSize = 30.sp)
            }
        }
    }
}

 

item에서 보여줄 header의 height와 topBar에서 보여줄 header의 height를 구한 후, 적당히 스크롤 됐을 때 isCollapsed 변수가 변경되도록 하여 topBar의 header를 보여주는 형식이다.

 

item으로 구현되었기 때문에 Header라는 이름을 가지고 있는 Component가 위에 고정되는 것이 아닌 끝까지 스크롤되어 올라가는 형태이고, 타이밍을 맞춰서 숨겨두었던 실제 Header를 보여주기 때문에 처음에 보여주던 header와 실제 header의 ui를 완전히 다르게 구현해도 상관이 없다.

 

이러한 형태로 구현할 때 중요한 점은 isCollapsed의 값이 변경되는 시점을 어떻게 설정하느냐에 따라 여러 가지 형태의 header를 만들 수 있으며, 보다 넓은 확장성을 가질 수 있다.

이 부분은 custom 하여 header를 구현할 때 가장 어려운 부분이면서도 확장성을 생각하면 가장 큰 장점이 될 수 있는 부분이라고 생각한다.


이것으로 stickyHeader를 구현하는 3가지 방법에 대하여 간단하게 설명을 하였다.

 

알고 보면 생각보다 간단한 것들이지만, 실제로 구현하고자 할 때 이런 것이 있다고 알고 있지 않다면 어떻게 검색해서 찾아봐야 하는 것인지 감이 안 잡힐 수 있을 것 같다고 생각이 들었다.

실제로, 필자도 이거 어디서 보긴 했고 구현이 가능하다는 것은 알겠는데 어떻게 검색을 하여 구현을 해야 하는가?라는 점에서 잠깐 막혔었기 때문에 샘플을 만들어 둔다면 추후에 작업할 때 쉽게 가져다 쓸 수 있을 것이라고 생각한다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

728x90