Compose adaptive 라이브러리(material3-adaptive)의 ListDetailPaneScaffold로 태블릿·폴더블 적응형 UI 구현

지난달에 폴더블을 펼쳤더니 제 앱 화면이 좌우로 쭉 늘어나면서 LazyColumn 하나만 덩그러니 떠있더라고요. 그 순간 '아, 이거 손봐야겠다' 싶었습니다.
폰에서는 멀쩡했어요. 목록 화면 누르면 상세 화면으로 쏙 넘어가니까요. 근데 화면이 넓어지는 순간 그 흐름이 어색해집니다. 좌우 여백만 휑하게 비고, 넓은 화면을 하나도 못 살리고 있었거든요.
태블릿에서 폰 UI가 비효율적인 이유
윈도우 크기는 너비를 Compact·Medium·Expanded 세 단계로 나눕니다. 폰은 보통 Compact, 태블릿을 가로로 펼치면 Expanded 구간이에요.
문제는 폰 기준으로 짠 단일 화면을 Expanded에 그대로 띄우면 콘텐츠 한 덩어리만 가운데 박히고 양옆이 다 빈다는 점입니다. 넓어진 공간을 활용하려면 목록과 상세를 한 화면에 같이 보여줘야 하는데요.
그렇다고 화면 너비를 if로 직접 갈라서 분기하면 코드가 금방 지저분해져요. 폴더블 접힘·펼침에, 멀티윈도우로 화면을 반만 차지하는 상황까지 더해지면 조건문이 끝도 없이 늘어나거든요.

ListDetailPaneScaffold로 목록·상세 골격 잡기
이걸 표준으로 풀어주는 게 androidx.compose.material3.adaptive 라이브러리입니다. 2024년 9월에 1.0 안정 버전이 나왔어요. 다만 이 영역은 API 변경이 잦은 편이라, 쓰실 땐 최신 릴리스 노트로 버전을 한 번 확인하시는 게 안전합니다.
의존성은 역할별로 셋을 넣습니다.
// libs.versions.toml 의 Version Catalog 로 버전 관리 권장
implementation(libs.androidx.adaptive) // currentWindowAdaptiveInfo 등 크기 감지
implementation(libs.androidx.adaptive.layout) // ListDetailPaneScaffold 본체
implementation(libs.androidx.adaptive.navigation) // 페인 탐색 상태 관리
골격은 슬롯에 컴포저블을 끼우는 방식이에요. listPane, detailPane 두 개가 기본이고, 설정이나 필터를 따로 띄우고 싶으면 extraPane까지 셋입니다.
navigator.navigateTo()는 suspend 함수이므로 rememberCoroutineScope()로 얻은 스코프 안에서 호출해야 합니다.
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
val scope = rememberCoroutineScope()
NavigableListDetailPaneScaffold(
navigator = navigator,
listPane = {
AnimatedPane {
ListContent(onItemClick = { id ->
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contentKey = id)
}
})
}
},
detailPane = {
AnimatedPane {
// 선택된 아이템 키를 받아 상세 그리기
navigator.currentDestination?.contentKey?.let { id ->
DetailContent(id)
}
}
}
)
각 슬롯을 AnimatedPane으로 감싸두면 창 개수가 바뀔 때 페이드가 부드럽게 들어갑니다. 이거 안 감싸면 화면이 툭툭 끊겨서 어색하더라고요.
WindowAdaptiveInfo가 창 개수를 자동 계산
여기서 진짜 편한 게, 제가 창을 몇 개 띄울지 직접 안 정해도 된다는 점이에요.
currentWindowAdaptiveInfo()가 너비 단계를 돌려주는데요. 핵심은 이게 물리 화면이 아니라 앱에 할당된 공간 기준이라는 겁니다. 그래서 태블릿에서 멀티윈도우로 앱을 반만 띄워도 알아서 좁은 공간으로 인식해 한 창으로 접어줘요. Scaffold가 이 값을 보고 창 개수를 정하거든요.
한 가지 손봐줄 자리가 Medium 구간입니다. 600~840dp 사이인데요. 600dp가 감이 안 오신다면, 작은 태블릿을 세로로 든 정도라고 보시면 됩니다. 이 폭에서 두 창을 억지로 쪼개면 목록도 상세도 둘 다 좁아져서 오히려 불편해져요. 그래서 Medium까지는 한 창으로 두고 Expanded부터 두 창으로 가르는 분기가 쓸 만합니다.
폴더블을 더 정교하게 잡고 싶다면 기기 자세(posture) 정보로 화면 접히는 경첩 위치에 맞춰 창을 나눌 수도 있어요. 다만 거기까지는 처음부터 욕심낼 필요는 없다고 봅니다.

내비게이션 상태 유지와 뒤로가기 충돌 주의
rememberListDetailPaneScaffoldNavigator가 고마운 이유는 화면 회전이나 폴더블 접었다 펼 때도 "지금 보던 상세 아이템"을 기억해준다는 점이에요. 이게 앱 전역 NavHost와는 따로 노는 독립 내비게이터입니다.
뒤로가기는 navigateBack에 PopUntilScaffoldValueChange를 넘기면, 두 창일 땐 상세만 닫고 한 창일 땐 목록으로 돌아가는 동작이 자연스럽게 잡힙니다. 위 코드처럼 NavigableListDetailPaneScaffold를 쓰면 이 뒤로가기 처리가 기본으로 묶여 들어가고, 예측형 뒤로가기도 기본으로 제공됩니다. 매니페스트에서 enableOnBackInvokedCallback을 켜고 targetSdk 34+를 권장하며, 시스템 애니메이션은 Android 15부터 기본으로 적용돼요.
조심할 건 전역 NavHost랑 같이 쓸 때입니다. 둘 다 뒤로가기를 가로채려 들어서 우선순위가 꼬일 수 있어요. "화면 간 이동은 NavHost, 페인 안쪽 전환은 Scaffold 내비게이터" 이렇게 경계를 처음부터 그어두셔야 나중에 안 헤맵니다.
기존 NavHost 코드를 Pane Scaffold로 옮기는 순서
이미 NavHost로 짜둔 화면을 옮기실 거라면 순서가 있습니다.
- 단일 화면 코드를
ListContent,DetailContent처럼 역할별 컴포저블로 먼저 쪼갭니다. - NavHost로 화면 넘기던 부분을
navigator.navigateTo(...)호출로 바꿉니다. - 폰·폴더블·태블릿 세 폼팩터에서 의도대로 도는지 확인합니다.
개인적으로는 3번 검증을 에뮬레이터 리사이즈로만 때우지 마시고, 폴더블 실기기를 하나 놓고 접었다 펴보시길 권합니다. 회전이나 멀티윈도우 같은 엣지 상황은 실기기에서 손으로 펼쳐봐야 잡히는 게 있어요.
저는 처음에 화면 너비를 if로 직접 갈라보다가 폴더블 경첩 상태까지 조건이 불어나서 결국 다 갈아엎었어요. 분기를 직접 짜는 길은 폼팩터가 하나 늘 때마다 조건문이 한 겹씩 더 쌓이고, Scaffold에 맡기는 길은 창 개수 계산을 통째로 떠넘기는 대신 라이브러리 버전을 따라가야 하는 부담이 남습니다. 그 한 바퀴를 돌고 나서 저는 후자를 택했어요. 넓은 화면을 제대로 살릴 생각이라면 if 미로를 직접 파는 것보다, 창 개수 계산을 Pane Scaffold에 넘기고 제 손은 콘텐츠에 두는 쪽이 훨씬 멀리 가더라고요.
