Jetpack Compose 커스텀 레이아웃 만들기

Jaeung Cheon
16 min readSep 8, 2023
Image by Author

Jetpack Compose는 상당히 잘 설계되어 기존 방식의 뷰로는 구현하기 복잡하거나 귀찮은 UI를 손쉽게 개발할 수 있게 되었습니다. 대부분의 UI는 기본 라이브러리에 포함 된 컴포넌트를 조합하는 것 만으로도 쉽게 만들어 낼 수 있습니다. 하지만 가끔은 커스텀 레이아웃을 만들어야 할 때도 있습니다. 이 글에서는 Jetpack Compose에서 커스텀 레이아웃을 어떻게 만들어야 하는지 기본적인 내용을 다루어 보고자 합니다.

Layout Composable

기본 레이아웃 컴포저블인 BoxRow, Column 등을 살펴보면 공통점을 발견할 수 있습니다.

@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
) {
val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
Layout(
content = { BoxScopeInstance.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
)
}

@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
)
}

세 레이아웃 모두 Layout 컴포저블에 MeasurePolicy를 제공하는 방식으로 구현되어 있는 것을 볼 수 있습니다. 차이점은 MeasurePolicy를 어떻게 구현했는지와 몇 가지 옵션 정도입니다.

그렇다면 Layout에 대해서 알아봅시다. Layout은 조금 특별한 컴포저블입니다. 기본적으로 모든 컴포저블이 받는 Modifier, 레이아웃의 자식 컴포저블이 담긴 Content 람다 그리고 자식 컴포저블의 크기와 위치를 지정하는 방식이 담긴 MeasurePolicy를 매개변수로 받습니다. 대부분의 경우 modifiercontentLayout으로 넘겨주면 됩니다. 하지만 MeasurePolicy는 레이아웃의 특성에 맞게 직접 구현해 주어야 합니다.

MeasurePolicy

Layout의 구현에서 MeasurePolicy는 핵심 부분이라고 할 수 있습니다. 자식 UI의 크기를 바탕으로 레이아웃이 가져야 할 크기 측정, 자식의 최대, 최소 크기 제약 지정, 자식의 위치를 지정하는 역할을 수행합니다. 이 작업들을 하기 위해서 반드시 사용해야 할 메소드들이 있습니다.

MeasurePolicymeasure라는 메소드를 반드시 구현하도록 만들어진 인터페이스입니다. measure 메소드는 아래와 같이 생겼습니다.

fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult

이 메소드에는 measurablesconstraints라는 2개의 매개변수가 전달됩니다. 각 매개변수의 역할은 아래와 같습니다.

  • measurables: 측정 가능한 레이아웃의 자식 컴포넌트 목록.
  • constraints: 레이아웃의 크기 제약사항이 담긴 객체.

커스텀 레이아웃을 만들기 위해서는 이 두가지 정보를 사용해 크기를 재고 배치하는 로직을 구현해야 합니다.

Measure & Layout

커스텀 레이아웃이 수행해야 할 2가지 단계

레이아웃은 크게 2가지 단계로 자식 UI를 배치합니다. 첫 번째는 측정 (measure) 단계로 크기를 재고 최대, 최소 크기 제약 등을 결정합니다. 두 번째 단계는 배치 (layout) 단계로 측정이 완료 된 자식 UI들을 실제로 배치합니다.

Measure

아래의 코드는 아주 간단한 측정 (measure) 단계의 예시입니다.

val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}

Measurable.measure 메소드를 호출 해 각 자식의 Constraint를 결정합니다. 메소드를 호출하면 결과로 Placeable 객체가 반환됩니다.

Placeable은 측정이 완료 된 레이아웃의 자식 컴포넌트입니다.

measure는 각 자식 당 단 한 번만 가능합니다. 두 번 이상 측정하려 할 시 예외가 발생합니다.

Layout

측정이 완료 된 PlaceableMeasureScope.layout 메소드 안에서 Placeable.place를 통해서 배치됩니다.

layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.place(x, y)
}
}

layout은 레이아웃의 크기를 결정하고 자식을 실제로 배치하는 용도로 사용하는 메소드입니다. layout은 커스텀 레이아웃의 가로, 세로 크기와 자식 배치를 위한 람다 매개변수를 받습니다.

layout 메소드의 람다 매개변수 안에서 place 메소드는 x, y 좌표 매개변수를 받고 해당 좌표에 자식 UI를 배치합니다.

좌표는 기준은 화면 전체가 아닌 레이아웃 내부의 좌표입니다.

정리해 보자면 모든 자식 컴포넌트는 Measurable 객체로 넘어오고 이들을 measure 메소드를 통해 Placeable로 만든 뒤 layoutplace 메소드로 배치해야 합니다.

만들어보기

직접 커스텀 레이아웃을 하나 만들어 보며 이해해 봅시다.

많은 SNS 서비스에서 위와 같은 UI로 여러 사용자의 프로필을 표시합니다. 간단하게 이 디자인을 구현할 수 있는 커스텀 레이아웃을 만들어 봅시다.

아래 코드들은 이해를 돕기 위한 코드입니다. 쉬운 이해를 위해 기본 기능 외의 부가적인 기능은 구현하지 않았습니다. 실제 프로덕션 코드에서 사용하려면 더 정밀하게 구현하고 테스트해야 할 것입니다.

우선 기본적인 API를 정해 봅시다.

@Composable
inline fun StackRow(
modifier: Modifier = Modifier,
overwrapFactor: Float = 0.5f,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
content: @Composable () -> Unit,
) { /* TODO */ }
  • modifier: 모든 컴포저블 함수는 반드시 Modifier를 매개변수로 가져야 합니다.
  • overwrapFactor: 자식 컴포넌트가 겹쳐지는 정도를 정하기 위한 매개변수입니다. 1을 자식 컴포넌트의 너비라고 정의하고 기본값을 0.5로 지정했습니다. 기본 값 (0.5) 사용 시 두 번째 자식 컴포넌트는 첫 번째 자식 너비 중간을 시작점으로 배치할 것입니다.
  • horizontalArrangement: 우리는 Row와 유사하게 가로 방향 레이아웃을 만들어야 합니다. Arrangement.Horizontal은 자식 UI의 가로 방향 배치를 도와줄 수 있는 기능을 가지고 있습니다.
  • content: 자식 컴포저블들이 담길 람다 매개변수입니다.

이제 커스텀 레이아웃의 핵심부인 MeasurePolicy를 구현해 봅시다.

internal fun stackRowMeasurePolicy(
overwrapFactor: Float,
arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit,
): MeasurePolicy {
return object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = arrayOfNulls<Placeable?>(measurables.size)

var placedWidth = 0
var placedHeight = 0
for (i in measurables.indices) {
// 1
val measurable = measurables[i]
val placeable = measurable.measure(
Constraints(
minWidth = 0,
maxWidth = if (constraints.maxWidth == Constraints.Infinity) {
Constraints.Infinity
} else {
constraints.maxWidth - placedWidth
},
minHeight = 0,
maxHeight = constraints.maxHeight,
)
)
// 2
placeables[i] = placeable
placedWidth += if (i == measurables.lastIndex) {
placeable.width
} else {
(placeable.width * overwrapFactor).roundToInt()
}
placedHeight = max(placedHeight, placeable.height)
}

// 3
val layoutWidth = max(placedWidth, constraints.minWidth)
val layoutHeight = placedHeight

// 4
val childrenWidthSizes = IntArray(placeables.size) { index ->
if (index == placeables.lastIndex) {
placeables[index]!!.width
} else {
(placeables[index]!!.width * overwrapFactor).roundToInt()
}
}
val placeablePositions = IntArray(placeables.size) { 0 }
arrangement(
layoutWidth,
childrenWidthSizes,
this.layoutDirection,
this,
placeablePositions,
)

// 5
return layout(layoutWidth, layoutHeight) {
for (i in placeables.indices) {
val placeable = placeables[i]!!
val position = placeablePositions[i]
placeable.place(position, 0)
}
}
}
}
}

커스텀 레이아웃을 구현하기 위해서는 Measure와 Layout 단계가 필요하다는 것을 위에서 설명했습니다. 단계별 동작은 아래와 같습니다.

  1. Measurable의 형태인 자식 컴포넌트들을 measure 함수를 통해 Placeable 형태로 만드는 부분입니다.
    measure 함수에는 Constraints를 전달하는데, 이는 컴포넌트가 가져야 할 최대, 최소 너비와 높이를 정하는 객체입니다. 레이아웃의 Constraints의 최대 너비에서 이미 배치 된 영역을 제외한 값을 최대 너비로 지정하고 있습니다.
  2. measure 함수가 반환 한 Placeable을 저장하고, Constraints 지정을 위한 값을 계산합니다.
    예시의 커스텀 레이아웃은 자식이 겹치는 Row 레이아웃이니 실제 자식의 너비에 overwrapFactor를 곱한 값을 배치된 너비로 사용합니다.
  3. 레이아웃이 가져야 할 너비와 높이를 정합니다. 레이아웃 Constraints의 최소 너비와 자식이 배치된 총 너비 중 큰 값을 사용합니다.
    높이의 경우 자식 컴포넌트 높이 중 가장 큰 값을 사용합니다.
  4. Arrangement.arrange메소드를 통해 자식 컴포넌트들이 배치 되어야 할 좌표를 계산합니다.
  5. 마지막으로 layout 함수를 통해 레이아웃의 최종 너비와 높이를 결정하고 자식 컴포넌트를 place 함수를 통해 배치합니다.

그 다음으로 만들어진 MeasurePolicy를 아래와 같이 레이아웃 컴포저블과 연결 해 줍니다.

@Composable
inline fun StackRow(
modifier: Modifier = Modifier,
overwrapFactor: Float = 0.5f,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
content: @Composable () -> Unit,
) {
val measurePolicy = rememberStackRowMeasurePolicy(overwrapFactor, horizontalArrangement)
Layout(
modifier = modifier,
measurePolicy = measurePolicy,
content = content,
)
}

@PublishedApi
@Composable
internal fun rememberStackRowMeasurePolicy(
overwrapFactor: Float,
horizontalArrangement: Arrangement.Horizontal,
) {
return remember(overwrapFactor, horizontalArrangement) {
stackRowMeasurePolicy(
overwrapFactor = overwrapFactor,
arrangement = { totalSize, sizes, layoutDirection, density, outPosition ->
with(horizontalArrangement) {
density.arrange(totalSize, sizes, layoutDirection, outPosition)
}
}
)
}
}

이제 아래와 같은 코드로 손쉽게 배치할 수 있습니다.

StackRow(
overwrapFactor = 0.75f,
modifier = Modifier.fillMaxWidth(),
) {
for (profile in profiles) {
ProfileImage(
profileImage = profile.image,
modifier = Modifier.size(36.dp)
)
}
}

이 글에서는 Jetpack Compose에서 커스텀 레이아웃을 구현하는 방법에 대해 알아보았습니다. Compose를 사용하면 대부분의 UI는 커스텀 레이아웃을 만들지 않아도 구현할 수 있습니다. 하지만 디자인 시스템 컴포넌트나 라이브러리, 또는 복잡한 동작을 쓰기 쉽게 풀어내기 위해서라면 종종 커스텀 레이아웃을 만들 필요도 있습니다.

추가적으로 Jetpack Compose 기본 라이브러리에서 제공하지 않는 Grid 레아이웃을 구현한 GridLayout for Compose 라이브러리도 커스텀 레이아웃으로 구현되었습니다. 조금 더 실전적인 코드를 확인해 보고 싶다면 확인해보세요.

관련 링크

--

--