취업 이후 첫 글인 것 같네요.
개발 실력이 모자라 배우느라 바쁘기도 했지만, 개발 외적으로 회사의 프로세스를 적응하고 이해하는데 많은 시간을 쏟고 있는 요즘입니다.
최근엔 서서히 적응을 하고 있고, 마인드맵 프로젝트부터 관심이 있었던 Custom Layout을 Jetpack Compose에선 어떻게 구현하고 있을지 궁금했고, 그에 관한 내용을 적어볼까 합니다.
Jetpack Compose Layout을 설명하기 전 CustomLayout을 만들기 위해서 View(Xml)에선 어떻게 하고 있었을까요?
과거(Xml)
class CustomLayout(context: Context) : ViewGroup(context) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 자식 뷰를 측정
var maxWidth = 0
var maxHeight = 0
children.forEach { child ->
measureChild(child, widthMeasureSpec, heightMeasureSpec)
maxWidth = maxOf(maxWidth, child.measuredWidth)
maxHeight = maxOf(maxHeight, child.measuredHeight)
}
setMeasuredDimension(
resolveSize(maxWidth, widthMeasureSpec),
resolveSize(maxHeight, heightMeasureSpec)
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 자식 뷰 배치
for (i in 0 until childCount) {
val child = getChildAt(i)
child.layout(left, top, right, bottom)
}
}
}
과거 XML로 CustomLayout을 구현하기 위해선 View 또는 ViewGroup을 상속받아서 구현했으며,
측정과 배치를 위해 onMeasure()와 onLayout()을 오버라이드 하여 크기를 측정하고 배치했습니다.
사실 여기까지는 Compose의 Layout과 큰 차이점은 없다고 생각합니다.
하지만 Compose와 XML의 차이점으로 생기는 미리 보기 기능 지원 X, XML 레이아웃과 Kotlin 로직이 분리가 단점으로 다가왔습니다.
(사실 Compose를 경험해보지 않았다면 생각하지도 못할 단점 이긴 합니다)
그렇다면 Compose에선 어떻게 CustomLayout을 만들고 있을까요?
Compose
Jetpack Compose의 Layout 시스템은 아래 3가지를 목표로 하고 있습니다.

- Easy : 커스텀 레이아웃을 쉽게
- Powerful : 레이아웃 시스템이 효과적으로 작동해 성능의 우수성을 통해
- Performant : 구현할 수 있다.
Compose phase

Compose는 크게 Composition, Layout, Drawing 3단계로 구성됩니다.
각 단계는 간단하게 설명하자면,
Composition
Composable을 실행해 UI 트리를 만듭니다.
개발자가 구현한 Composable 함수를 읽어 아래와 같이 UI 트리를 만듭니다.

Layout
Composition 단계에서 생성된 UI 트리의 각 요소를 측정하여 배치하는 역할을 합니다.
즉 자식 노드의 너비와 높이를 결정하고 x와 y좌표를 알아내는 단계입니다.
Drawing
Composable 요소를 렌더링, 즉 그리는 단계입니다.
3단계를 간단하게 요약하면

Composition 단계에선 무엇을 표시할 지 정의하고 있으며, Layout 단계에선 어디에 배치할 지 계산합니다.
마지막 Drawing 단계에선 어떻게 화면에 렌더링할지 실행한다고 요약할 수 있습니다.
CustomLayout을 만들기 위해선 위 3단계 중 Layout 단계에 집중할 필요성이 있습니다.
Layout Phase
레이아웃 단계에선 자식의 크기를 측정하고 어디에 배치할지 계산하는 단계입니다.

따라서 레이아웃 단계는 Measurement(측정), Placement(배치)로 구성됩니다.
위 구현한 CustomLayout의 onMeasure, onLayout과 유사합니다.
View는 해당 단계를 명시적으로 분리했지만, Compose는 Layout 단계로 합쳤고 각각의 Scope만 명시해주고 있습니다.

UI트리에선 자식의 크기를 측정 -> 자신의 사이즈를 결정 -> 자식을 배치하는 3단계가 있습니다.

자식이 더 이상 없는 리프 노드인 Image, Text 등은 자신의 콘텐츠에 따라 직접 측정 후 크기를 결정하고,
Row, Column처럼 자식을 가지는 Composable은 모든 자식의 크기가 결정된 이후에야 자신의 크기를 결정합니다.
하지만 어떤 자식의 크기를 다른 자식의 크기에 따라 결정해야 하는 복잡한 상황에서는,
일반적인 Compose의 흐름만으로는 한계가 있습니다.
이러한 상황에서 활용할 수 있는 도구가 바로 SubComposeLayout입니다.
해당 개념은 조금 더 깊이 있는 주제이므로, 다음 글에서 자세히 다뤄보겠습니다.
위처럼 잘 구현되어 있는 Layout Phase지만, 복잡한 레이아웃을 만들 경우 한계가 있습니다.
CustomLayout을 구현하기 위해선 각 단계에서 어떤 작업을 하는지 이해할 필요성이 있습니다.
Compose Layout

Compose에서 제공하는 Layout 함수입니다.
Row, Column과 같은 모든 상위 레벨 레이아웃은 해당 Composable을 사용합니다.
해당 함수를 통해 CustomLayout을 구현하는 스켈레톤 코드는 다음과 같습니다.

마지막 파라미터인 MeasurePolicy를 후행 람다로 제공하여 MeasurePolicy를 구현하면 됩니다.

MeasureScope의 measure은 measurables, constraints를 사용하고 있습니다.

constraints : 레이아웃에 크기를 알려주는 클래스로, 레이아웃 높이와 너비의 최댓값과 최솟값을 모델링합니다.
max와 min 값의 조절을 통해 크기의 제약이 없는 레이아웃을 만들거나, 정확한 값을 조절해 원하는 레이아웃의 크기를 만들 수 있습니다.

위 Docs에 대한 설명을 해석하자면, 측정 가능한 Composable 요소를 의미한다고 적혀있습니다.
Layout의 단계는 Measure -> Place로 구성되어 있기에 아직 배치되지 않았지만, 측정 가능한 Composable을 의미한다는 뜻으로 해석할 수 있습니다.
따라서 measure 함수의 결괏값으로 Placeable 객체를 반환하고, 배치가 가능하게됩니다.

Placeable은 Measure이 완료된 결괏값으로, 부모는 이 Placeable을 place 함수를 통해 원하는 위치에 배치할 수 있습니다.
Docs에서는 "A Placeable should never be stored between measure calls"라고 명시하고 있는데요,
이는 Placeable을 변수에 저장하지 말라는 의미입니다.
Compose는 상태 변화가 발생할 때마다 레이아웃을 다시 측정하며, 이 과정에서 measure → place 사이클이 반복됩니다.
이때 Placeable은 측정 시점에서만 유효한 객체이며, measure가 다시 호출되면 새로운 측정 결과가 생성되기 때문에 이전에 저장해 둔 Placeable은 더 이상 유효하지 않게 됩니다.
따라서 Placeable은 측정 시점에만 유효한 값이고, 저장할 필요가 없습니다.

Layout은 Measure -> Place 단계로 진행되는데요, 코드적으로도 명시적으로 분리하고 있진 않습니다.
편의상으로 MeasureScope와 PlaceScope로 구분하겠습니다.
MeasureScope

MeasureScope는 컴포넌트의 크기를 측정하는 역할을 하며, PlaceScope는 측정 완료된 Placeable을 배치하는 역할을 합니다.
measure의 결괏값은 Placeable이기 때문에 measurable을 List<Measurable> -> List<Placeable>로 변환합니다.
스켈레톤 코드기 때문에 일반적인 제약조건을 설정했지만, 요구사항에 맞는 constraints를 설정할 수 있습니다.
예를 들어 첫 번째 자식은 원래 크기대로, 나머지는 50% 크기로 축소해야 하는 요구사항이 있다면 아래와 같이 작성할 수 있을 것입니다.
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.mapIndexed { index, measurable ->
if (index == 0) {
measurable.measure(constraints)
} else {
val halfConstraints = Constraints(
minWidth = constraints.minWidth / 2,
maxWidth = constraints.maxWidth / 2,
minHeight = constraints.minHeight / 2,
maxHeight = constraints.maxHeight / 2
)
measurable.measure(halfConstraints)
}
}
}
PlaceScope

MeasureScoep의 layout 함수는 width, height, placementBlock을 파라미터로 받습니다.
layout 함수의 전달할 값은 MeasureScope에서 계산한 placeable의 width와 height의 결괏값이 될 수도 있고, 고정 값을 줄 수도 있습니다.
예를 들어 가장 큰 자식의 크기를 기준으로 Layout 크기를 결정할 경우 아래와 같이 작성할 수 있습니다.
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val maxWidth = placeables.maxOfOrNull { it.width } ?: 0
val maxHeight = placeables.maxOfOrNull { it.height } ?: 0
layout(maxWidth, maxHeight) {
placeables.forEach {
it.place(0, 0)
}
}
}
다른 예로는 자식들을 한 줄로 배치하고 총 너비와 자식의 최대 높이로 크기를 결정하는 요구사항의 경우 아래와 같이 작성할 수 있습니다.
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val totalWidth = placeables.sumOf { it.width }
val maxHeight = placeables.maxOfOrNull { it.height } ?: 0
layout(totalWidth, maxHeight) {
var xPosition = 0
placeables.forEach {
it.place(xPosition, 0)
xPosition += it.width
}
}
}
Layout Phase에서 Measure -> Place 단계에서 어떠한 작업을 해야 할지 명확하게 파악했다면 복잡한 레이아웃도 구현할 수 있습니다.
저는 FlowLayout을 Layout 함수를 통해 구현했는데요,
Slot 뿐만 아니라 horizontalPadding, verticalPadding을 받아서 실제 동작과 유사하게 구현해 봤습니다.
@Composable
fun FlowRowLayout(
modifier: Modifier = Modifier,
horizontalPadding: Dp = 5.dp,
verticalPadding: Dp = 5.dp,
content: @Composable () -> Unit,
) {
Layout(
modifier = modifier,
content = content,
) { measurables, constraints ->
val placeables =
measurables.map { measurable ->
measurable.measure(constraints)
}
var yPosition = 0
var xPosition = 0
var totalHeight =
if (placeables.isNotEmpty()) {
verticalPadding.value.toInt() * 2 + placeables.first().height
} else {
verticalPadding.value.toInt() * 2
}
placeables.forEach { placeable ->
if (xPosition + placeable.width >= constraints.maxWidth) {
xPosition = 0
yPosition += placeable.height
totalHeight += placeable.height + verticalPadding.value.toInt()
}
xPosition += placeable.width
}
xPosition = 0
yPosition = verticalPadding.value.toInt()
layout(constraints.maxWidth, totalHeight) {
for (placeable in placeables) {
if (xPosition + placeable.width >= constraints.maxWidth) {
xPosition = 0
yPosition += placeable.height + verticalPadding.value.toInt()
}
placeable.placeRelative(x = xPosition, y = yPosition)
xPosition += placeable.width + horizontalPadding.value.toInt()
}
}
}
}


지금까지 Jetpack Compose에서 Custom Layout을 구현하는 과정을 기존 View 시스템과 비교하며 정리해 보았습니다.
XML 기반 ViewGroup에서의 onMeasure, onLayout 오버라이드 방식과 달리, Compose에서는 Layout 함수와 MeasurePolicy를 통해 훨씬 더 직관적이고 유연하게 커스텀 레이아웃을 만들 수 있다는 점이 인상적이었습니다.
실제로 직접 FLowLayout을 구현해보면서, Compose의 레이아웃 시스템이 얼마나 강력하면서도 유연하게 동작하는지 경험할 수 있었습니다.
다음 포스팅에선 SubComposeLayout과 같은 고급 주제도 다뤄보겠습니다.
참고
https://www.youtube.com/watch?v=zMKMwh9gZuI
https://medium.com/hongbeomi-dev/compose-deep-dive-2-layout-204262dae5ae
Compose Deep Dive — 2.Layout
Jetpack Compose Layout의 동작 원리를 Deep Dive into Jetpack Compose Layout(Android Dev Summit21) 영상과 함께 살펴봅니다.
medium.com
'Skils > Android' 카테고리의 다른 글
| 복잡한 레이아웃을 아름답게 해결할 수 있는 방법(feat: SubComposeLayout) (0) | 2025.06.23 |
|---|---|
| Navigation 코드를 줄여보세요 (feat: Compose Destinations) (2) | 2025.06.11 |
| [Android] - Interceptor를 이용한 Retrofit 에러 핸들링 (1) | 2024.10.19 |
| [Android] - Authenticator를 활용해 JwtToken 갱신하기 (2) | 2024.10.12 |
| [Android] - Test 코드를 작성해보자(Compose UI Test) (3) | 2024.09.17 |