같은 Compose Multiplatform 화면이 안드로이드 폰에서는 문제없이 보이는데, 데스크톱 창을 넓게 펼치거나 태블릿에서 실행하면 리스트 하나만 화면 가운데 좁게 떠 있는 경우가 있습니다. androidx.compose.material3.adaptive 같은 구글의 어댁티브 레이아웃 라이브러리는 아직 안드로이드 전용이라 commonMain에서 바로 쓸 수 없습니다. 별도 라이브러리 없이도 BoxWithConstraints만으로 화면 폭에 따라 레이아웃을 바꾸는 방법을 정리합니다.
왜 필요한가
화면 크기를 다루는 가장 흔한 실수는 Modifier.fillMaxWidth()와 고정된 dp 값만으로 레이아웃을 짜는 것입니다. 폰 화면에서는 자연스럽지만, 데스크톱이나 태블릿의 넓은 화면에서는 콘텐츠가 한쪽에 몰리거나 리스트-상세 화면을 나란히 보여줄 수 있는 여유 공간을 그냥 낭비하게 됩니다.
안드로이드 전용 프로젝트라면 WindowSizeClass를 액티비티 단위로 계산해서 내려주면 되지만, commonMain에는 Activity도 Configuration도 없습니다. Compose Foundation에 포함된 BoxWithConstraints는 플랫폼과 무관하게 커스텀 레이아웃이 실제로 그려질 영역의 폭·높이를 Dp 단위로 알려주기 때문에, 이 값을 기준으로 직접 브레이크포인트를 나누면 됩니다.
핵심 개념
Material Design의 창 크기 분류 기준(600dp, 840dp)을 그대로 가져와서 3단계 열거형으로 정의합니다.
// commonMain/ui/WindowSizeClass.kt
enum class WindowSizeClass {
Compact, // 폰 세로 모드 등, 너비 600dp 미만
Medium, // 폰 가로 모드, 작은 태블릿, 600~840dp
Expanded, // 태블릿 가로 모드, 데스크톱, 840dp 이상
}
fun windowSizeClassOf(widthDp: Dp): WindowSizeClass = when {
widthDp < 600.dp -> WindowSizeClass.Compact
widthDp < 840.dp -> WindowSizeClass.Medium
else -> WindowSizeClass.Expanded
}
BoxWithConstraints는 스코프 안에서 maxWidth/maxHeight를 제공하는 컴포저블입니다. 이 스코프 안에서 위 함수로 크기 등급을 계산하고, 등급에 맞는 레이아웃을 골라 그리면 됩니다.
// commonMain/ui/AdaptiveContainer.kt
@Composable
fun AdaptiveContainer(
content: @Composable (WindowSizeClass) -> Unit,
) {
BoxWithConstraints {
val sizeClass = windowSizeClassOf(maxWidth)
content(sizeClass)
}
}
실전 예시
리스트-상세 화면에 적용해봅니다. Compact에서는 리스트만 보여주고 항목을 누르면 상세 화면으로 전환하고, Expanded에서는 리스트와 상세를 나란히 배치합니다.
// commonMain/ui/ItemListDetailScreen.kt
@Composable
fun ItemListDetailScreen(items: List<Item>) {
var selectedItem by remember { mutableStateOf<Item?>(null) }
AdaptiveContainer { sizeClass ->
when (sizeClass) {
WindowSizeClass.Compact -> {
if (selectedItem == null) {
ItemList(
items = items,
onItemClick = { selectedItem = it },
)
} else {
ItemDetail(
item = selectedItem!!,
onBack = { selectedItem = null },
)
}
}
WindowSizeClass.Medium, WindowSizeClass.Expanded -> {
Row(Modifier.fillMaxSize()) {
ItemList(
items = items,
onItemClick = { selectedItem = it },
modifier = Modifier.width(320.dp),
)
Divider(
modifier = Modifier.fillMaxHeight().width(1.dp),
)
Box(Modifier.weight(1f)) {
selectedItem?.let { ItemDetail(item = it, onBack = { selectedItem = null }) }
?: EmptyDetailPlaceholder()
}
}
}
}
}
}
리스트 폭을 320.dp로 고정한 이유는, 넓은 화면일수록 상세 화면에 여유 공간을 더 주기 위해서입니다. 반대로 리스트도 weight로 비율 분배하면 데스크톱 초광폭 창에서는 리스트 칸이 지나치게 넓어집니다.
BoxWithConstraints는 리컴포지션 비용이 있어서, 화면 전체를 감싸는 최상위 컨테이너 한 곳에만 두고 그 안쪽 컴포저블들은 일반 Column/Row로 구성하는 편이 낫습니다. 여러 곳에 중첩해서 쓰면 크기가 바뀔 때마다 측정이 여러 번 일어나서 리사이즈 애니메이션이 끊기는 걸 체감할 수 있습니다.
장단점 정리
장점
- 추가 의존성 없이 Compose Foundation만으로 구현 가능
- 브레이크포인트 값과 분기 로직을 직접 관리하므로, 프로젝트 특성에 맞게 자유롭게 조정 가능
- 데스크톱 창 크기 조절, 폴더블 펼침·접힘처럼 런타임에 크기가 바뀌는 상황에도 그대로 대응(별도 리스너 등록 불필요)
단점
- Material3 Adaptive 라이브러리가 제공하는
ListDetailPaneScaffold같은 애니메이션·백스택 처리를 직접 구현해야 함 - 브레이크포인트 상수를 화면마다 흩어서 정의하면 화면 간 기준이 어긋나기 쉬워서, 공통 모듈로 모아두는 관리가 필요
BoxWithConstraints를 여러 레벨에 중첩하면 리컴포지션 비용이 누적되므로 배치 위치를 신경 써야 함
세밀한 모션이나 페인 간 전환 애니메이션까지 필요하면 결국 안드로이드 전용 어댁티브 라이브러리를 걷어내고 직접 구현하는 부분이 늘어납니다. 다만 화면 크기에 따라 레이아웃 구조만 바꾸는 정도라면 BoxWithConstraints 하나로 충분한 경우가 많습니다.