읽기 전
- 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
- 개인적으로 사용해보면서 배운 점을 정리한 글입니다.
Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 레이아웃 Modifier를 사용하여 Modifier 확장함수를 어떻게 정의하는 지 알아봅니다. 레이아웃 컴포저블을 사용하여 Column을 직접 구현해봅니다.. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.
커스텀 레이아웃 만들기
Jetpack Compose는 Column, Row, Box 등의 라이브러리 내장 컴포저블들을 결합하여 커스텀 컴포저블을 구성하여 재사용성을 높인다. 그러나 하위 컴포넌트를 수동으로 측정하고 배치해야 한다면 앱만의 고유한 동작 로직을 정의해야 할 수도 있다. 이 경우 Layout 컴포저블을 사용한다. Column, Row 등의 모든 상위 수준 레이아웃은 Layout 컴포저블을 사용하여 빌드된 것을 확인할 수 있다.
Compose의 레이아웃 원칙
컴포저블 함수 중에는 호출될 때 UI 요소를 리턴하여 화면에 렌더링될 UI 트리에 추가한다. 각 요소에는 상위 요소가 하나 있고 하위 요소가 여러 개(혹은 하나) 존재한다. 추가로 상위 요소 내의 위치(x, y)와 크기(width, height)도 포함된다.
UI 요소는 자체적으로 측정되며 제약조건을 충족해야 한다고 설명한다. 제약 조건은 최소/최대 width, height이다. 만약 하위 UI 요소가 존재한다면 각 하위 요소들을 측정하여 자체 크기를 파악한 뒤 리턴된 결과를 기준으로 자신의 조건에 맞춰 하위 요소를 배치할 수 있다.
가장 유념해야 할 점은 Compose UI는 다중 패스 측정을 허용하지 않는다는 점이다. 레이아웃 요소가 다른 측정 구성을 시도하기 위해 하위 요소를 두 번 이상 측정할 수 없다. 단일 패스 측정은 성능 측면에서 유리하기에 도입된 개념으로 Compose UI 트리의 효율적 처리에 목적을 둔다. 따라서, 추가 정보가 필요한 경우에는 별도의 방법을 사용해야 한다.
레이아웃 Modifier 사용
layout Modifier를 사용하여 요소를 측정하고 배치하는 과정을 수동으로 제어해보자. 일반적인 laout Modifier 구조는 다음과 같다.
fun Modifier.customLayoutModifier(...) = Modifier.layout {
measurable, constraints ->
...
})
layout Modifier 사용 시 람다 매개변수 measurable, constraints가 있다.
- measurable : 측정하기 배치할 하위 요소
- constraints : 하위 요소의 너비와 높이 최솟값과 최댓값
화면에 Text 컴포저블이 배치될 때 Text 컴포저블의 상단에서 기준선까지의 거리를 제어해보자. 해당 기능을 구현하기 위해선 layout Modifier를 사용하여 화면에 컴포저블을 수동으로 배치해야 한다. 상단에서 첫 번째 기준선까지의 거리가 24.dp로 설정되도록 작성하자.
fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp
) = this.then(
layout { measurable, constraints ->
...
}
)
Modifier의 확장함수로 firstBaselineToTop이라는 함수를 정의하고 파라미터로 Dp 값을 입력받도록 하였다.
이제 컴포저블을 측정해야 한다. 앞서 작성하였듯이 하위 요소는 원칙적으로 한 번만 측정할 수 있음을 기억하자. measurable.measure(constraints)를 호출하여 컴포저블을 측정하였다.
fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp
) = this.then(
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
...
}
)
measure(constraints)를 호출하여 컴포저블을 측정할 때 constraints 람다 매개변수로부터 사용할 수 있는 컴포저블의 제약 조건을 전달하거나 직접 만들 수 있다. measure()를 호출한 결과는 placeRelative(x, y)를 호출하여 컴포저블을 배치할 수 있는 Placeable이다. 해당 함수를 사용하여 컴포저블을 배치한다. 우선 위 코드에서 작성한 대로 별도의 측정 제한 없이 주어진 제약 조건만 넘겨준다.
측정한 뒤 baseline을 갖고 있는지 체크하여 변수에 저장한다.
fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp
) = this.then (
layout { measurable, constraint ->
val placeable = measurable.measure(constraint)
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
// 최상단이 0이므로 firstBaseline은 baseline까지의 길이를 값으로 가짐 = fontSize
// placeableY는 padding값 - baseline까지의 길이
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
// 전체 출력할 높이는 하위 컴포저블의 높이 + padding값 - baseline까지의 길이
// padding 값이 0이면 baseline과 하단까지의 간격만 출력
val height = placeable.height + placeableY
layout(placeable.width, height) {
placeable.placeRelative(0, placeableY)
}
}
)
높이를 측정하는 코드가 있다. 우선 하위 컴포즈의 최상단 y값은 0이고 아래로 내려갈수록 증가하는 값임을 알아야 한다. 따라서 firstBaseline은 baseline까지의 높이로 여기서는 텍스트 사이즈와 같은 값이다. 그리고 firstBaselineToTop은 우리가 컴포저블의 최상단과 baseline 사이의 간격으로 지정할 값이므로 placeableY의 값은 padding 값 - 텍스트 사이즈가 되어 만약 텍스트 크기보다 매개변수 값이 더 작다면 글자가 잘린 채로 출력될 것임을 추측할 수 있다.
height 변수는 전체 컴포저블을 출력할 높이로 설정하였다. placeable.height는 하위 컴포저블의 높이이고 placeableY값을 더하면 결국 하위 컴포저블 높이 - 텍스트 사이즈가 되어 baseline과 컴포저블 하단까지의 빈 공간과 매개변수로 입력한 padding값에 해당한다.
마지막으로 layout 함수를 호출하여 width, height를 매개변수로 받고 placeable.placeRelative 함수를 호출하여 컴포저블을 배치한다. x값이 0이므로 좌측 끝에서부터 x값이 증가하는 방향으로 출력된다. y값은 placeableY기준으로 출력하므로 만약 매개변수가 텍스트 사이즈보다 작아 placeableY의 값이 음수라면 글자가 레이아웃 기준 음수높이에서부터 출력되어 상단부분이 가려져서 출력될 것이다.
@Preview
@Composable
fun FirstBaselineToTopPreview() {
Text("fooooo", Modifier.firstBaselineToTop(24.dp))
}
@Preview
@Composable
fun TopPaddingPreview() {
Text("baaaar", Modifier.padding(top=24.dp))
}
확장함수를 사용하여 간격을 부여한 경우와 Padding을 상단에 부여한 경우 조금 다르게 출력됨을 확인하였다.
레이아웃 컴포저블 사용
방금까지 layout Modifier를 사용하여 단일 컴포저블을 측정하고 화면에 배치되는 방식을 제어하는 커스텀 확장함수를 구현하였으니 이번에는 단일 컴포저블이 아니라 하위 요소들을 측정하여 배치하기 위해 컴포저블에서 Layout을 사용해보자. Layout을 사용하는 컴포저블의 일반적인 구조는 다음과 같다.
@Composable
fun CustomLayout(
modifier: Modifier = Modifier,
// custom layout attributes
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// measure and position children given constraints logic here
}
}
적어도 커스텀 레이아웃을 정의한다면 스타일을 지정할 modifier 매개변수와 어떤 하위 컴포저블을 담을 것인지 입력받는 content 매개변수는 필수적으로 선언해야 한다. 해당 매개변수들은 내부에 선언된 Layout 컴포저블로 전달된다. Layout 컴포저블 선언 후 뒤따라오는 후행 람다에서 layout Modifier를 사용했을 때와 같은 람다 매개변수가 주어진다. 조금 다른 점은 하위 요소가 여러 개일 수도 있으므로 변수 이름을 복수형으로 변경하였다.
해당 기본 구조를 토대로 Column을 구현해보자. 편의상 레이아웃은 최대한의 공간을 차지한다고 가정하자.(fillMax!)
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 부여된 제약조건 로직에 따라 하위 요소들을 측정하고 배치한다.
}
}
앞서 layout Modifier에서도 그러하였듯이 하위 요소들을 측정해야 한다.
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 주어진 제약조건으로만 측정하되 추가적인 제약을 자식 뷰에 적용하지 않는다.
// 측정된 자식들이 담긴 리스트를 받는다.
val placeables = measurables.map { measurable ->
// 각 자식들에 대해 측정 수행
measurable.measure(constraints)
}
}
}
각 하위 요소별로 측정을 수행해야 하므로 람다 변수로 받은 measurables에 대해 map을 호출한 뒤 람다 매개변수로 자식 뷰를 측정할 수 있는 람다 매개변수를 선언하고 제약조건에 대해 자식뷰 측정을 수행한다. 이로써 측정 가능한 모든 content를 확인하였다.
각 요소들을 배치하기 위해 전체 레이아웃의 사이즈를 정의하자.
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 자식 요소 측정 - 앞서 작성했기에 생략
...
// layout의 크기를 최대 사이즈로 설정(fillMax!)
layout(constraints.maxWidth, constraints.maxHeight) {
// 자식 요소 배치
}
}
}
하위 요소 배치를 수행하기 전 Column의 크기를 계산한다. 상위 요소만큼 크게 만들기로 했으므로 상위 요소로부터 전달된 제약조건 사이즈를 입력한다. 하위 요소 배치에 사용되는 Layout의 람다 함수가 제공하는 layout(width, height) 함수를 호출하여 자체적으로 Column의 크기를 지정한다.
Column 레이아웃의 사이즈를 지정했으니 하위 요소들을 어떻게 배치할 지 작성하자.
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
// 이전 자식 요소가 어느 y좌표에 배치되었는지 추적하기 위한 변수
var yPosition = 0
layout(constraints.maxWidth, constraints.maxHeight) {
// 자식들을 부모 레이아웃에 배치
placeables.forEach { placeable ->
// 화면에서의 아이템 위치를 지정하여 배치
placeable.placeRelative(x = 0, y = yPosition)
// 하위요소를 배치한 y위치를 추적하여 이후 자식 배치에 활용
yPosition += placeable.height
}
}
}
}
Column은 상단으로부터 수직적으로 자식 요소를 배치하는 레이아웃이다. layout Modifier에서 다뤘듯이 (0, 0)은 우측 최상단임을 확인했었다. 따라서 최초 자식이 배치될 y좌표는 0좌표임을 알 수 있고 각 자식 요소가 배치될 때마다 이전 자식뷰의 하단에 배치되어야 하므로 자식 요소가 배치될 y좌표를 기록해야 한다. 우선 0값을 저장하는 변수를 선언한 뒤 palceable.placeRelative(x, y) 함수를 호출하여 자식 요소들을 배치한다. 자식 요소의 위치를 지정한 뒤 이후 자식 요소 배치를 위해 yPosition 변수에 배치한 자식 요소의 높이를 더해준다. 해당 로직을 통해 이후 자식 요소는 이전 자식 요소 하단에 배치될 수 있다.
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
MyOwnColumn(modifier.padding(8.dp)) {
Text("MyOwnColumn")
Text("places items")
Text("vertically.")
Text("We've done it by hand!")
}
}
작성한 코드대로 상하좌우 8dp씩 padding이 부여되었고 하위 컴포저블 요소로 부여한 4개의 Text 컴포저블이 차례대로 수직으로 배치되었다.
참고자료
- Google Codelab - Jetpack Compose Layout #7 맞춤 레이아웃 만들기