| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
- BottomSheetScaffold
- fling
- ModalBottomSheet
- nestedScroll
- Extras
- 안드로이드
- Kotilin
- playstore
- Android
- enableEdgetoEdge
- Flexiblie BottomSheet
- Collpasing Header
- EdgetoEdge
- AnchoredDraggable
- thumbnail
- Compose
- bottomsheet
- 모바일
- NestedScrollConnection
- StickyHeader
- Intent
- Today
- Total
choiminjun 블로그
Compose에서 부드러운 Collapsing Header 구현하기: stickyHeader vs NestedScrollConnection 본문
Compose에서 부드러운 Collapsing Header 구현하기: stickyHeader vs NestedScrollConnection
mj010504 2026. 3. 16. 01:00여행 앱의 장소 상세 화면을 만들면서 다음과 같은 요구사항이 있었다.

요구사항
- 스크롤 시 헤더가 순차적으로 접혀야함
1. navigationBar가 먼저 사라짐
2. thumbnail이 점점 부드럽게 사라짐
- 썸네일이 모두 접히면 Header와 TabRow가 화면 상단에 고정되어야함
최종 결과를 미리보자면 다음과 같다.

시행착오 1: StickyHeader 2개 사용하기
첫번째로 시도했던 방법은 2개의 Sticky Header를 사용하는 방식이다. 중간에 Thumbnail을 제외한 Header와 TabRow를 LazyColumn 안의 stickyHeader로 감싸도록했다.
LazyColumn(modifier = Modifier.fillMaxSize()) {
stickyHeader {
Header()
}
item {
Thumbnail()
}
stickyHeader {
TabRow()
}
}
하지만 다음과 같이 2개의 stickyHeader는 공존하지 않는다. 한 stickyHeader가 상단에 붙고 다음 stickyHeader가 붙는 방식이다. 따라서 Header와 TabRow 중간에 Thumbnail만 사라진다는 요구사항과 적합하지는 않다.

시행착오 2: StickyHeader 1개 사용하고 StickyHeader가 상단에 붙었을 때 thumbnail 접기
다음과 같이 LazyColumn의 listState를 활용해서 Header가 상단에 붙었을 때를 판단할 수 있도록 isHeaderSticky 변수를 만든다.
isHeaderSticky를 통해 Header가 상단에 고정되었다고 판단되면 thumbnail을 없애도록 애니메이션을 만들었다.
val listState = rememberLazyListState()
val isHeaderSticky by remember {
derivedStateOf {
val firstItem = listState.layoutInfo.visibleItemsInfo.firstOrNull()
val result = firstItem?.index == 1 && firstItem.offset <= 0
result
}
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
NavigationBar()
}
stickyHeader {
Header()
val thumbnailAlpha by animateFloatAsState(
targetValue = if (isHeaderSticky) 0f else 1f,
animationSpec = tween(durationMillis = 300),
label = "ThumbnailAlpha",
)
if (thumbnailAlpha > 0f) {
AsyncImage(
model = state.placeInfo.thumbnail,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(230.dp * thumbnailAlpha)
.alpha(thumbnailAlpha)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(Color.LightGray),
contentScale = ContentScale.Crop,
)
}
TabRow()
}
}
결과는 다음과 같다. 이전보다는 나아지긴 했지만 썸네일이 너무 빠르게 내려가는 감이 있어서 썸네일을 반정도 내렸을 때 썸네일이 접히도록 시도해보았다.

시행착오 3: StickyHeader를 1개 사용하고 Thumbnail이 반정도 내려갔을 때 접기
다음과 같이 listState()를 활용해서 isSecondItemReached 변수를 만든다.
val listState = rememberLazyListState()
val density = LocalDensity.current
val thresholdPx = with(density) { 115.dp.toPx() }
val isSecondItemReached by remember {
derivedStateOf {
val secondItem = listState.layoutInfo.visibleItemsInfo.find { it.index == 2 }
secondItem != null && secondItem.offset <= thresholdPx
}
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
NavigationBar()
}
stickyHeader {
Header()
val thumbnailAlpha by animateFloatAsState(
targetValue = if (isSecondItemReached) 0f else 1f,
animationSpec = tween(durationMillis = 300),
label = "ThumbnailAlpha",
)
if (thumbnailAlpha > 0f) {
AsyncImage(
model = state.placeInfo.thumbnail,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(230.dp * thumbnailAlpha)
.alpha(thumbnailAlpha)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(Color.LightGray),
contentScale = ContentScale.Crop,
)
}
TabRow()
}
}
결과는 예상과는 달리 stickyHeader안에 썸네일이 포함되어 있기 때문에 썸네일이 내려가지 않고 어느정도 스크롤 되었을 때 한번에 접히는 것을 확인할 수 있다. 따라서 썸네일이 내려가는 UI를 표현하기 위해서는 NestedScrollConnection을 사용해야 한다.

문제 해결 - NestedScrollConnection 사용
val density = LocalDensity.current
val thumbnailHeight = 230.dp
val navBarSectionHeight = 48.dp
val thumbnailHeightPx = with(density) { thumbnailHeight.toPx() }
val navBarSectionHeightPx = with(density) { navBarSectionHeight.toPx() }
val maxCollapseHeightPx = navBarSectionHeightPx + thumbnailHeightPx
var collapseOffset by remember { mutableFloatStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < 0f) {
val oldOffset = collapseOffset
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
return Offset(0f, -(collapseOffset - oldOffset))
}
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0f) {
val oldOffset = collapseOffset
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
return Offset(0f, oldOffset - collapseOffset)
}
return Offset.Zero
}
}
}
val navBarProgress = (1f - collapseOffset / navBarSectionHeightPx).coerceIn(0f, 1f)
val thumbnailProgress = (1f - (collapseOffset - navBarSectionHeightPx).coerceAtLeast(0f) / thumbnailHeightPx).coerceIn(0f, 1f)
Column(
modifier = Modifier.fillMaxSize().nestedScroll(nestedScrollConncetion)
) {
if(navBarProgress > 0f) {
NavBar()
}
Header()
if(thumbnailProgress > 0f) {
Thumbnail()
}
TabRow()
}
최종 결과는 다음과 같다.

NestedScrollConnection의 중첩 주기는 다음과 같다.

NestedScrollConnection은 사용자로부터 전달된 스크롤 제스처를 얻어서 처리할 수 있다. onPreScroll에서 자식 요소가 스크롤을 먹기전, 부모에서 먼저 스크롤 제스처를 얻는다.onPresCroll에서 처리되고 남은 스크롤을 자식 요소가 처리하고 onPostScroll에서 자식 요소가 스크롤 한 후, 남은 스크롤 제스처를 부모에서 얻는다.
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < 0f) {
val oldOffset = collapseOffset
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
return Offset(0f, -(collapseOffset - oldOffset))
}
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0f) {
val oldOffset = collapseOffset
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
return Offset(0f, oldOffset - collapseOffset)
}
return Offset.Zero
}
}
}
val navBarProgress = (1f - collapseOffset / navBarSectionHeightPx).coerceIn(0f, 1f)
val thumbnailProgress = (1f - (collapseOffset - navBarSectionHeightPx).coerceAtLeast(0f) / thumbnailHeightPx).coerceIn(0f, 1f)
- onPreScroll (올릴 때: 부모 우선): 사용자가 손가락을 위로 밀면(available.y < 0), 자식인 LazyColumn이 움직이기 전에 부모인 헤더가 먼저 스크롤 에너지를 뺏어옵니다. 헤더가 완전히 접힐 때(maxCollapseHeightPx 도달)까지 리스트는 멈춰 있게 됩니다.
- onPostScroll (내릴 때: 자식 우선): 사용자가 아래로 당기면(available.y > 0), 이번엔 자식인 리스트가 먼저 다 내려올 때까지 기다립니다. 리스트가 맨 위까지 다 내려와서 더 이상 갈 곳이 없을 때, 남은 에너지를 부모가 받아 헤더를 다시 펼치게 됩니다.
navBarPorgress는 navBarSectionHeightPx만큼 위로 스크롤되어서(collapseoffset >= navBarSectionHegihtPx) 0f가 되었을 때 navBar가 보여지지 않게 되고, thumbnailProgress는 thumbnailHeightPx + navBarSectionHeightPx 만큼 위로 스크롤 되어서(collapseoffset >= thumbnailHeightPx + navBarSectionHeightPx) 0f가 되었을 때 thumbnail이 점점 내려가면서 사라지게 된다. 사용자가 아래로 스크롤 할때는 반대로 동작한다.
다음과 같이 NestedScrollConnection을 통해 스크롤 제스처를 직접 관리하여 스크롤시 navBar와 thumbnail이 순차적으로 접히도록 해서 해결하였다.
'Android' 카테고리의 다른 글
| 3가지 이상의 상태를 가지는 바텀시트 구현(+ 하단의 고정된 버튼 추가하기) (0) | 2026.03.14 |
|---|---|
| Edge-to-edge 공부 (0) | 2025.12.22 |
| PlayStore 앱 열기로 진입 시 Intent는 extras가 존재한다 (0) | 2025.12.22 |
