-
Jetpack Compose에서 Custom Layout 구현해보기Android 2025. 4. 29. 13:30
안녕하세요! Mash-Up 15기 Android팀 멤버 백다연입니다.
최근 기본 컴포즈 라이브러리에서 제공하는 컴포넌트만으로는 구현의 한계가 있는 UI를 개발하게 되었고 어떻게 개발하면 좋을지 고민하다가 Custom Layout을 만들게 되었습니다.이 글에서는 Jetpack Compose에서 Custom Layout을 만들기 위해 어떤 걸 사용하고 만들어야 할지 알아보고자 합니다.
기본 레이아웃 컴포저블로 많이 사용하는 Row와 Column 내부를 살펴보면,
@Composable inline fun Column( modifier: Modifier = Modifier, verticalArrangement: Arrangement.Vertical = Arrangement.Top, horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit ) { val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment) Layout( content = { ColumnScopeInstance.content() }, measurePolicy = measurePolicy, modifier = modifier ) } @Composable inline fun Row( modifier: Modifier = Modifier, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, verticalAlignment: Alignment.Vertical = Alignment.Top, content: @Composable RowScope.() -> Unit ) { val measurePolicy = rowMeasurePolicy(horizontalArrangement, verticalAlignment) Layout( content = { RowScopeInstance.content() }, measurePolicy = measurePolicy, modifier = modifier ) }
공통적으로 Layout Composable을 사용하여 구현되어 있는 것을 확인할 수 있으며, Column과 Row 모두 각각의 MeasurePolicy를 만들어서 제공하는 방식으로 구현이 되어있습니다.
기본적으로 많이 사용하는 컴포저블이 모두 Layout으로 구현이 되어 있는 것을 확인했으니… 우리가 만들고자 하는 모든 화면들을 이 Layout Composable을 활용하여 구현할 수 있다는 것을 알 수 있습니다!
그렇다면 Layout Composable을 어떻게 사용하는지 자세히 알아보도록 하겠습니다.
Composable 함수는 Composer를 통해 Composition 과정을 거칩니다. 이 과정에서 UI 트리가 만들어지고, 이후 Layout과 Drawing 단계를 거쳐 실제 화면에 UI가 렌더링됩니다.
이 중 Layout 단계는 다음을 담당합니다:
- UI 트리를 탐색하며 각 컴포넌트를 측정(Measure) 하고
- 컴포넌트의 크기(Width, Height)를 결정하고
- x, y 좌표를 계산하여 화면에 배치(Layout) 합니다.
좀 더 자세히 살펴보면, Layout 단계는 측정과 배치로 나뉩니다. 이는 기존 XML 기반 Android에서 View의 onMeasure와 onLayout 메서드가 각각 담당했던 역할과 유사하며, Jetpack Compose에서는 이 두 과정을 하나의 measure 블록 안에서 자연스럽게 이어서 처리합니다.
즉 Compose의 Layout는 세부적으로 3단계를 가지게 됩니다.
- 각 노드는 각 하위 요소를 측정하고
- 자신의 크기를 결정한 뒤
- 하위요소를 적절한 위치에 배치해야 합니다.
Layout composable
fun Layout( content: @Composable @UiComposable () -> Unit, modifier: Modifier = Modifier, measurePolicy: MeasurePolicy ) {}
- content : 모든 하위 Composable에 대한 슬롯이며 레이아웃을 위해 content에는 자식 Layout이 포함됩니다.
- Modifier : 레이아웃에 Modifier를 적용하기 위한 파라미터를 받습니다.
- measurePolicy : 레이아웃이 Node를 측정하고 배치하는 방법인 측정 정책입니다.
MeasurePolicy를 이용해 항목을 측정하고 배치하기 때문에 가장 핵심 개념이며 더 자세히 알아보도록 합시다.
MeasurePolicy
fun MeasureScope.measure( measurables: List<Measurable>, constraints: Constraints ): MeasureResult
MeasurePolicy 인터페이스는 measure 메서드를 구현하도록 만들어졌으며 위와 같은 형태를 가지고 있습니다.
measurable과 constraints를 가지고 있는 것을 확인할 수 있고
- constraints: 레이아웃의 크기를 알려주며 레이아웃의 높이와 너비의 최댓값과 최솟값을 모델링합니다.
- measurable: 측정 가능한 레이아웃의 자식 컴포넌트 목록을 나타냅니다.
앞에서 각 노드는 각 하위 요소를 측정하고 자신의 크기를 결정하여 하위 요소를 배치해야 한다고 하였는데요,
간단히 하위 요소를 측정하는 코드부터 살펴보도로 하겠습니다.
1. 하위 요소 측정
val placeables = measurables.map { measurable -> measurable.measure(constraints)
- measurable 유형은 이를 실행할 measure 메서드를 표시하고 크기 제약을 받습니다. 해당 메서드를 호출하면 Placeable 객체가 반환됩니다.
- placeable은 측정된 하위 요소이고 크기가 결정되어 측정이 완료됩니다.
여기서 주의할 점은 항목을 두 번 측정하면 Compose에서 예외를 발생시키다는 점입니다.
2. 크기 결정 및 하위 요소 배치
val width = // caculate from placeables layout(widht, height) { placeables.forEach { placeable -> placeable.place( x = ... y = ... ) } }
- placeable을 사용하여 레이아웃 크기를 계산한 다음 레이아웃의 크기가 얼마인지 layout 메서드를 호출하여 값을 보고합니다.
- layout 메서드는 placement Block이 필요한데 일반적으로 후행람다로 구현해서 각 항목을 원하는 곳에 배치하게 됩니다.
사용해 보기
핀터레스트 뷰처럼 높이가 불규칙한 카드들이 배치되어 있는 그리드 레이아웃을 만들어보도록 하겠습니다.
1. 우선 레이아웃 content인 Box를 만들어주도록 하겠습니다.
Box(modifier = Modifier.padding(4.dp) .size(width = 160.dp, height = Random.nextInt(100, 250).dp) .background( color = Color( red = Random.nextInt(100, 255), green = Random.nextInt(100, 255), blue = Random.nextInt(100, 255) ) ) )
색상과 높이는 랜덤으로 지정하여 각 카드별로 다른 높이를 가질 수 있도록 구성하였습니다. 이제 만들어준 카드들을 각 카드들 높이의 맞추어 배치하는 작업이 필요합니다.
2. Layout 사용하기
아래와 같이 코드를 작성하면 우리가 원하는 그리드 형태를 만들 수 있고 이제 코드 하나씩 다시 살펴보도록 하겠습니다.
@Composable fun MasonryLayout( modifier: Modifier = Modifier, columns: Int = 2, content: @Composable () -> Unit ) { Layout( modifier = modifier, content = content ) { measurables, constraints -> // 각 컬럼의 너비 val columnWidth = constraints.maxWidth / columns // 각 컬럼별 현재 쌓인 높이 val columnHeights = IntArray(columns) { 0 } // placeables 저장 val placeablesWithPositions = measurables.map { measurable -> val placeable = measurable.measure( constraints.copy( minWidth = columnWidth, maxWidth = columnWidth ) ) // 가장 높이가 낮은 컬럼을 찾기 val shortestColumn = columnHeights.withIndex().minByOrNull { it.value }!!.index val positionX = shortestColumn * columnWidth val positionY = columnHeights[shortestColumn] columnHeights[shortestColumn] += placeable.height Triple(placeable, positionX, positionY) } val height = columnHeights.maxOrNull()?.coerceAtLeast(constraints.minHeight) ?: constraints.minHeight layout(constraints.maxWidth, height) { placeablesWithPositions.forEach { (placeable, x, y) -> placeable.placeRelative(x = x, y = y) } } } }
먼저, 하위요소를 측정하고 각 크기를 결저하는 과정이 필요하기 때문에 해당 부분 먼저 코드를 작성해 보도록 하겠습니다.
val columnWidth = constraints.maxWidth / columns val columnHeights = IntArray(columns) { 0 }
constraints은 부모 레이아웃이 자식에게 부여하는 최소, 최대 크기 제약 조건으로 이 값을 통해 자식 요소들의 크기를 결정할 수 있습니다.
- columnWidth: 열의 너비를 계산합니다. 부모 레이아웃의 최대 너비를 열의 개수로 나누어서 각 카드의 너비를 계산합니다.
- columnHeights: 각 열에 배치된 아이템들의 누적 높이를 저장하는 배열을 만들어주고 처음에는 0으로 초기화합니다.
그다음은 아이템을 측정하여 크기를 결정하도록 하겠습니다.
// placeables 저장 val placeablesWithPositions = measurables.map { measurable -> val placeable = measurable.measure( constraints.copy( minWidth = columnWidth, maxWidth = columnWidth ) ) // 가장 높이가 낮은 컬럼을 찾기 val shortestColumn = columnHeights.withIndex().minByOrNull { it.value }?.index ?: 0 val positionX = shortestColumn * columnWidth val positionY = columnHeights[shortestColumn] columnHeights[shortestColumn] += placeable.height Triple(placeable, positionX, positionY) }
measurable.measure()로 각 아이템을 측정하고 각 아이템은 가장 낮은 열이 있는 곳에 다음 아이템이 오기 때문에 가장 낮은 열을 찾습니다.
그 후 좌표를 구해보면 X좌표는 앞에서 측정한 고정된 width * 현재 낮은 칼럼을 가진 행을 통해 구할 수 있고 Y좌표는 가장 낮은 열이 있는 곳의 누적된 높이의 값을 가지게 됩니다.
각 아이템의 위치와 크기를 Triple로 저장합니다. 이 Triple에는 placeable(아이템 크기와 위치), positionX, positionY가 포함됩니다.
3. 최종 레이아웃 크기 결정 및 배치
val height = columnHeights.maxOrNull()?.coerceAtLeast(constraints.minHeight) ?: constraints.minHeight layout(constraints.maxWidth, height) { placeablesWithPositions.forEach { (placeable, x, y) -> placeable.placeRelative(x = x, y = y) } }
- 레이아웃의 최종 높이는 각 열에서 가장 높은 값을 기준으로 설정되며, 앞서 구한 크기를 통해 배치하게 됩니다.
- placeable.placeRelative()로 아이템을 실제 화면에 배치하는데, x와 y는 각각 열과 해당 열의 높이에 맞게 설정됩니다.
이제 만들어진 Layout을 호출하면 우리가 원하는 MasonryLayout을 구현할 수 있습니다.
MasonryLayout( modifier = Modifier .fillMaxWidth() .padding(8.dp), columns = 2 ) { repeat(20) { Box( modifier = Modifier .padding(4.dp) .size(width = 160.dp, height = Random.nextInt(100, 250).dp) .background( color = Color( red = Random.nextInt(100, 255), green = Random.nextInt(100, 255), blue = Random.nextInt(100, 255) ) ) ) } }
Masonry Layout
Jetpack Compose에서 커스텀 레이아웃을 구현하는 방법에 대해 간단하게 알아보았습니다. Compose에서 제공하는 기본 컴포넌트로 대부분의 UI를 만들 수 있지만, 복잡한 뷰가 주어졌을 때 어떻게 구현해야 할지 감이 안 온다면
Layout Composable를 떠올리고 사용해 보길 바랍니다!Reference
Jetpack Compose 커스텀 레이아웃 만들기
Jetpack Compose는 상당히 잘 설계되어 기존 방식의 뷰로는 구현하기 복잡하거나 귀찮은 UI를 손쉽게 개발할 수 있게 되었습니다. 대부분의 UI는 기본 라이브러리에 포함 된 컴포넌트를 조합하는 것
cheonjaeung.medium.com
https://developer.android.com/develop/ui/compose/layouts/custom?hl=ko
'Android' 카테고리의 다른 글
안드로이드 미래 먹거리 XR (3) 2025.07.11 Modifier composed{} 이대로 괜찮은가 (1) 2025.04.30 navigateUp과 popBackStack 비교 (뒤로가기 클릭 금지) (0) 2025.04.05 lifecycleScope 와 viewModelScope 는 어떤 원리로 생명주기에 맞춰 코루틴을 취소하고 있을까? (0) 2025.04.05 Compose PreviewParameterProvider 알아보기 (0) 2024.06.27