-
사용자 경험을 고려한 NestedScroll 계층 구조 전략Android 2025. 9. 17. 08:56
개발하고자 하는 화면은 앱의 메인화면으로, 다양한 컴포넌트로 구성되어 있다. 화면 안에서 사용성을 해치지 않으면서 모든 구성요소들을 적절하게 표시하는 전략이 필요했다. 이런 구성을 한 화면에 알맞게 담기 위해 중첩 스크롤과 고정헤더 그리고 스크롤 이벤트 발생 시 우선순위 조정을 기획에 맞게 처리하였다. 그 과정에서 겪은 이슈와 해결 방법을 정리한다.
화면 요구사항


원본 / 컴포넌트 영역 사용자 경험을 고려하며 적절한 화면 스크롤 처리 전략을 구성하기 위해 다음 기획을 따른다.
1. AppBar와 Bottom Navigation을 제외한 나머지 영역에 대해 스크롤이 가능하다. 2. 기기 목록은 전체 영역에 대한 스크롤과 별개로 내부적으로 스크롤될 수 있다. 3. 전체 영역 스크롤 시 방 목록은 사라지지 않고 AppBar 하위에 고정되어야 한다. 4. 연결된 기기가 없거나 스크롤할 만큼 기기 수가 표시되지 않더라도 방 목록이 App Bar 하위에 위치할 때까지 화면 스크롤이 가능해야 한다. 5. 기기 목록을 스크롤하더라도 상위 영역이 표시된 상태라면 상위 뷰가 우선 스크롤된다.중첩 스크롤 처리
기획 1.AppBar와 Bottom Navigation을 제외한 나머지 영역에 대해 스크롤 처리
AppBar와 동일 계층에 스크롤 가능한 영역을 먼저 구현했다.
이때 화면 전체 영역을 스크롤 가능하게 하려면 두 가지 방식을 고려할 수 있다.- Column의 modifier에 scrollState를 설정한다.
- Column을 Lazy Column으로 대체한다.
1번 방식은 최소한의 코드 수정만으로 스크롤을 처리할 수 있다는 장점이 있지만, 기획 5번을 구현을 하기 위해선 pointerInput 등으로 터치 이벤트를 직접 커스텀하는 방식을 사용해 스크롤 동작을 우회 해야 하며 이로 인해 안정성이 떨어질 수 있다. 따라서 2번 방식을 채택하면서 LazyColumn + stickyHeader + item(grid) 패턴을 적용하였다.
기획 2. 기기 목록은 전체 영역에 대한 스크롤과 별개로 내부적으로 스크롤될 수 있다.
기기 목록을 표시하기 위해 기존에 사용하는 LazyVerticalGrid는 기본적으로 스크롤을 제공한다. 다만, LazyVerticalGrid 상위 뷰에 스크롤이 가능한 뷰가 위치하는 경우 LazyVerticalGrid 최대 높이를 지정해주어야 한다. 지정하지 않은 경우 다음 에러가 발생한다.
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container.LazyVerticalGrid는 스스로 길이(여기서는 높이)를 결정하고 스크롤 가능한 컴포넌트이다. 지금과 같이 상위에 스크롤이 가능한 LazyColumn이 배치되면 한 화면에 두 개의 스크롤 가능한 컴포넌트가 겹쳐지고 외부 Column이 얼마나 길어질 수 있는지 계산을 못해 내부 컴포넌트인 LazyGrid의 높이가 무한한 값으로 처리된다. 그래서 Compose 시스템이 이를 감지하고 IllegalStateException이 발생되는 것이다.
이를 해결하기 위해 LazyColumn를 감싸는 BoxWithConstraints를 생성하여 뷰가 가질 수 있는 최대 높이를 가져와 LazyVerticalGrid에 적용해 주었다.BoxWithConstraints { val height = this.maxHeight LazyColumn( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { LazyVerticalGrid(modifier = Modifier.height(height)) } } }기획1, 2 적용 스크롤 시 특정 컴포넌트 고정 및 적절한 높이 계산
기획 3. 전체 영역 스크롤 시 방 목록은 사라지지 않고 AppBar 하위에 고정되어야 한다.
LazyColumn를 상위 뷰로 채택한 덕분에 item 대신 stickyHeader를 사용하여 방 목록을 고정시킬 수 있다.
stickyHeader { RoomComponent(uiState.home.rooms) }기획3 적용 위 영상에서는 크게 문제없어 보이지만 기기 목록이 없거나 적은 경우, 방 목록에 기기 목록 상단이 가려지는 문제가 생긴다.
기획3 문제점 이를 해결하기 위해서는 기기목록이 적용된 LazyVerticalGrid의 높이를 수정해야 한다. 기존 상위 뷰의 최대 높이에서 고정된 방 목록의 높이만큼을 제거해 주면 된다.
BoxWithConstraints { // 화면에서 스크롤 가능한 높이 val height = this.maxHeight // 방 목록의 높이 var roomHeight by remember { mutableIntStateOf(0) } LazyColumn( modifier = Modifier.fillMaxWidth(), state = parentListState, verticalArrangement = Arrangement.spacedBy(12.dp) ) { stickyHeader { RoomComponent( modifier = Modifier.onGloballyPositioned { // 방 목록 높이 가져오기 roomHeight = it.size.height }, rooms = uiState.home.rooms ) { // TODO } } item { LazyVerticalGrid( modifier = Modifier.height(height - roomHeight.pxToDp()) // 전체 높이 - 방 목록 높이 ) } } }계산된 높이를 LazyVerticalGrid높이에 적용함으로써 기기 목록 상단이 가려지는 문제와 기획 4를 처리할 수 있었다.
기획3 개선, 기획4 적용 NestedScroll 계층 구조
기획 5. 기기 목록을 스크롤하더라도 상위 영역이 표시된 상태라면 상위 뷰가 우선 스크롤된다.
현재 사용자가 기기 목록을 스크롤할 때 화면의 절반도 안 되는 영역을 스크롤 한 다음 전체 영역이 스크롤되는 구조다. 만약 기기가 매우 많은 사용자라면 상단 영역을 스크롤한 뒤 기기 목록을 스크롤하거나 모든 기기 목록을 스크롤해야 기기를 보여주는 영역이 넓어지는 불편함을 겪을 것이다. 이를 해결하기 위해 두 스크롤의 상태를 확인하고 스크롤 이벤트 우선순위를 지정한다.
1. 상태 값 준비
// LazyColumn의 스크롤 위치와 상태를 관리하는 State로 현재 LazyColumn이 어느 만큼 스크롤됐는지, 몇 번째 아이템이 화면에 보이는지를 알 수 있다. val parentListState = rememberLazyListState() // LazyVerticalGrid의 스크롤 상태로 grid의 현재 스크롤 상태 파악 및 조작에 사용된다. val gridListState = rememberLazyGridState() // LazyColumn 내에서 그리드(Grid)가 몇 번째에 들어오는지를 의미하는 인덱스 val gridStartIndex = 32. NestedScroll 동작 분배
겹쳐진 스크롤 컴포넌트에서 터치/스크롤 이벤트를 부모와 자식 사이에서 어떻게 분배할지 결정해야 한다.
val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { println("Received onPreScroll callback.") return Offset.Zero } }이러한 상황에서 NestedScrollConnection를 사용하면 적절히 처리할 수 있다.
NestedScrollConnection 클래스가 제공하는 함수 중 onPreScroll는 터치/드래그 이벤트가 컴포넌트에 전달되기 직전에 호출된다.
여기서 스크롤 이동량을 부모에 넘길 수 있다. 해당 기능을 이용해 상위 뷰에 부착해 둔 parentListState의 상태 값을 조건으로 하위 뷰 스크롤 이동량을 상위 뷰로 넘기면 원하는 기획 의도에 맞출 수 있다.val nestedScrollConnection = remember(parentListState) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (parentListState.firstVisibleItemIndex < gridStartIndex) { parentListState.dispatchRawDelta(-available.y) return Offset(0f, available.y) } return Offset.Zero } } }LazyColumn의 현재 최상단에 보이는 item의 index가 gridStartIndex보다 작은 경우 상위 뷰를 우선 스크롤해야 한다. 조건에 부합하는 경우 부모 스크롤에 이벤트를 넘기기 위해 LazyListState.dispatchRawDelta에 offset 값을 넘겨주면 된다. 이렇게 구현한 nestedScrollConnection를 Modifier.nestedScroll를 이용해 하위 스크롤에 부착하면 기획 의도에 맞게 작동한다.
기획5 적용 'Android' 카테고리의 다른 글
Macrobenchmark를 이용한 성능 분석 (2) 2025.09.13 캐시 전략 도입, 화면 로딩 속도 개선 (1) 2025.09.13