Jetpack Compose에서 Custom Layout 구현해보기
안녕하세요! 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)
)
)
)
}
}
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