choiminjun 블로그

Compose에서 부드러운 Collapsing Header 구현하기: stickyHeader vs NestedScrollConnection 본문

Android

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)
  1. onPreScroll (올릴 때: 부모 우선): 사용자가 손가락을 위로 밀면(available.y < 0), 자식인 LazyColumn이 움직이기 전에 부모인 헤더가 먼저 스크롤 에너지를 뺏어옵니다. 헤더가 완전히 접힐 때(maxCollapseHeightPx 도달)까지 리스트는 멈춰 있게 됩니다.
  2. onPostScroll (내릴 때: 자식 우선): 사용자가 아래로 당기면(available.y > 0), 이번엔 자식인 리스트가 먼저 다 내려올 때까지 기다립니다. 리스트가 맨 위까지 다 내려와서 더 이상 갈 곳이 없을 때, 남은 에너지를 부모가 받아 헤더를 다시 펼치게 됩니다.

 

 navBarPorgress는 navBarSectionHeightPx만큼 위로 스크롤되어서(collapseoffset >= navBarSectionHegihtPx) 0f가 되었을 때 navBar가 보여지지 않게 되고, thumbnailProgress는 thumbnailHeightPx + navBarSectionHeightPx 만큼 위로 스크롤 되어서(collapseoffset >= thumbnailHeightPx + navBarSectionHeightPx) 0f가 되었을 때 thumbnail이 점점 내려가면서 사라지게 된다. 사용자가 아래로 스크롤 할때는 반대로 동작한다.

 

다음과 같이 NestedScrollConnection을 통해 스크롤 제스처를 직접 관리하여 스크롤시 navBar와 thumbnail이 순차적으로 접히도록 해서 해결하였다.