Developing Myself Everyday
article thumbnail
Published 2023. 10. 24. 15:47
Compose의 Layout 단계 Android/Compose
 

JetPack Compose에 대한 이해

안드로이드 개발을 하면서 Compose를 사용하지 않는 것은 이제는 많이 뒤져치는 것 같습니다. 그렇기에 Compose를 제대로 공부해보고 있습니다. 다만 누가 "Compose에 대해서 이해하고 있느냐?" 라고

everyday-develop-myself.tistory.com

 

위의 게시글에서 Compose 상태(State)를 UI로 변환하는데 3단계로 진행된다고 설명했습니다.

 

 

이번 게시글에서는 저번 게시글에서 설명했던 Composition 단계에 이어서 Layout 단계에 대해 설명하고자 합니다.

 

 

이 게시글의 내용은 아래의 영상에서 참고하였습니다.


 

 

Layout 단계


Composition 단계에서는 composable 함수를 실행하고 다양한 상태의 여러 가지 UI 트리를 만들 수 있습니다.

 

Layout 단계에서는 이 트리를 작동하고 UI의 각 부분을 측정하여 2D 영역의 화면에 배치합니다. 즉, 각 노드가 너비와 높이를 결정하고 x, y 좌표를 알아냅니다.

 

 

 

측정과 배치 단계

Layout 단계는 세부적으로 Measure(측정)Place(배치)로 나뉩니다.

 

 

이는 View를 그릴 때의 onMeasure()onLayout()과 비슷하다고 할 수 있습니다. 다만, Compose에서는 이 두 단계가 결합되어 있다는 것이 다른 부분입니다.

 

그렇기에 하나의 Layout 단계라고 간주하겠습니다.

 

 

 

Preset 단계

가장 먼저 진행할 것은 UI 트리에서 각 노드를 배치하는 것입니다. 이 단계를 Preset 단계라고 하겠습니다. 

 

 

Preset 단계에서 각 노드는 하위 요소를 측정하고 자신의 크기를 결정하여 하위 요소를 배치해야 합니다.

 

 

아래와 같은 composable 함수가 있을 때, Preset 단계를 다음과 같이 진행합니다.

 

 

가장 먼저 하위 요소를 측정합니다. 만약 하위 요소가 없는 말단 노드라면, 자신의 크기를 결정하여 Place 명령을 반환합니다. 모든 레이아웃은 크기를 결정하는 동시에 Place 명령을 반환합니다. 마지막으로 모든 하위 요소의 크기를 알아냈으므로 루트 Row의 크기와 Place 명령을 알아낼 수 있습니다.

 

 

모든 요소의 크기가 측정되면 Place 단계에 모든  Place 명령이 실행됩니다.

 

 

이러한 Compose의 측정과 배치 방식은 단일 패스로 이뤄집니다. 이는 View 시스템과 비교했을 때 측정이나 배치 시간을 상당히 절약했습니다. View 시스템에서는 성능 문제로 인해 레이아웃 애니메이션을 권장하지만 Compose는 이를 가능하게 합니다.

 

이것에 대해서는 아래에서 좀 더 자세하게 알아보겠습니다.

 

 

 

실제로 이뤄지는 방식


다시 조금 전으로 돌아가 보겠습니다. 우리가 얻을 수 있는 UI 트리는 상위 컴포저블로 표현됩니다. 예를 들면 아래와 같은 Row, Column, Text 같은 것들이 상위 컴포저블입니다.

 

 사실, 각 컴포저블은 하위 컴포저블에 자동으로 생성됩니다. 예를 들어 Text 기능은 여러 가지 하위 요소에서 자동 구축됩니다.

 

 

 

 

UI 트리를 자세히 살펴보면 화면에 요소를 배치하는 모든 컴포저블에는 Layout 컴포저블이 존재한다는 것을 확인할 수 있습니다.

 

 Layout 컴포저블Compose UI의 기본 요소입니다.

 

 

 

각각의 Layout 컴포저블LayoutNode를 내보내고 있습니다. 즉, UI 트리는 사실 LayoutNode 트리라는 것을 알 수 있습니다.

 

 

 

 

Layout 컴포저블


Layout 컴포저블을 좀 더 자세히 보겠습니다.

@Composable 
inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    ...
}

 

`Content`는 하위 컴포저블의 Slot입니다. `Layout`에서 `Content`는 하위 레이아웃을 담습니다.

`Modifier`는 `Modifier`를 `Layout`에 적용하기 위해 받고 있습니다. `Modifier`에 대해서는 나중에 더 자세하게 설명합니다.

다음은 `MeasurePolicy`입니다. `Layout`은 이걸 사용해서 항목을 측정하고 배치합니다.

 

동작을 더 자세하게 설명하기 위해 사용자 정의 레이아웃의 측정 함수 동작을 구현해 보겠습니다.

 

 

MyColumn 구현 

이제부터는 Layout 컴포저블로 직접 아래와 같은 Column을 구현해서 알아보겠습니다.

 

 

 

가장 먼저 `modifier`와 `content 블록`이 들어간 MyColumn 컴포저블 함수를 생성합니다. content에는 수직으로 배치할 항목이 들어갑니다.

@Composable
fun MyColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
	...
}

 

 

다음으로는 Layout 컴포저블을 호출하고 람다를 사용해 필요한 측정 함수를 구현합니다. 이 함수는 `Constraints` 객체를 받아서 레이아웃에 크기를 알려줍니다. 여기서 `Constraints` 객체는 우리가 View 시스템에서 사용했던 `ConstraintsLayout`의 제약조건과 유사합니다.

@Composable
fun MyColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables: List<Measurable>,
        constraints: Constraints ->
	...
    }
}

`measurables` content를 통해 입력된 하위 요소를 나타냅니다. `mesurable` 유형은 항목을 측정하기 위한 함수를 나타내고 위에서 말했듯이 아래 3단계로 진행됩니다.

 


 

 

3단계에서 먼저 할 것은 하위 요소를 측정하는 것입니다. `measurable` 유형은 이를 실행할 `measure` 메서드를 표시하고 크기 제약을 받습니다.

Layout(
    modifier = modifier,
    content = content
) { measurables: List<Measurable>,
    constraints: Constraints ->
    // 먼저 하위 요소를 측정, measurable 목록에 매핑해서 각각을 측정하면 된다.
    val placeables = measurables.map { measurable ->
        measurable.measure(constraints)
    }
	...
    }
}

이 과정을 거치면 `placeable` 리스트를 얻을 수 있습니다. `placeables`은 측정된 하위 요소이고 사이즈가 결정되어 있습니다.


 

 

이제는 `placeable`을 사용해서 전체 레이아웃의 크기를 계산합니다. Column의 높이는 모든 측정된 하위 요소의 높이의 합과 같고, Column의 넓이는 모든 측정된 하위 요소들 중의 가장 큰 값과 같습니다.

Layout(
    modifier = modifier,
    content = content
) { measurables: List<Measurable>,
    constraints: Constraints ->
    val placeables = measurables.map { measurable ->
        measurable.measure(constraints)
    } 
    val height = placeables.sumOf { it.height }
    val width = placeables.maxOf { it.width }
    	...
    }
}

 

 

다음은 레이아웃의 크기가 얼마인지 `Layout` 메서드를 호출해 값을 보고해야 합니다. `Layout` 메서드는 `placementBlock`이 필요한데 이는 후행 람다로 구현해서 각 항목을 원하는 곳에 배치합니다.

layout(width, height) {
    placeables.forEach { placeable ->
        placeable.place(
            x = ...,
            y = ...
        )
    }
}

 

 

Column을 만들기 위해서는 크기를 보고한 다음 y의 위치를 추적해 각 항목을 배치하고 각각의 배치된 항목 높이로 점차 늘립니다.

layout(width, height) {
    var y = 0
    placeables.forEach { placeable ->
        placeable.placeRelative(
            x = 0,
            y = y
        )
        y += placeable.height
    }
}

API 설계상 측정되지 않은 요소는 배치할 수 없습니다. `place()` 메서드는 오직 `measure()` 메서드에서 반환한 `placeable`에서만 사용할 수 있습니다.


 

 

API 설계상 측정되지 않은 요소는 배치할 수 없습니다. `place()` 메서드는 오직 `measure()` 메서드에서 반환한 `placeable`에서만 사용할 수 있습니다.

 

하위 요소를 측정하는 다양한 제약을 만들어내는 것이 바로 이러한 모델의 핵심입니다. 상위 요소는 허용 가능한 크기를 전달하고 이를 제약으로 표현합니다. 하위 요소가 그 안에서 크기를 선택하면 상위 요소는 이를 받아서 처리해야 합니다.

 

이러한 디자인은 단일 패스로 UI 트리 전체를 측정하기 때문에 여러 번의 측정 사이클을 울리지 않아도 된다는 장점이 있습니다.

 

View 시스템에 문제였던 여러 번 측정값을 입력하는 중첩된 계층 구조에서는 측정값의 2차적 값이 발생하기도 했습니다. Compose는 이런 단점을 설계에서부터 차단했습니다.

 

@Composable
fun MyColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables: List<Measurable>,
        constraints: Constraints ->
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }
        val height = placeables.sumOf { it.height }
        val width = placeables.maxOf { it.width }
        layout(width, height) {
            var y = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(
                    x = 0,
                    y = y
                )
                y += placeable.height
            }
        }
    }
}

 

 

 

 

Layout에서의 Modifier


앞에서 레이아웃 컴포저블이 Modifier를 매개변수로 받는 것을 확인했습니다.

 

 

Modifier는 레아아웃 자체에서 측정, 배치를 실행하기 전에 측정과 배치 과정에 참여할 수 있습니다.

 

 

LayoutModifier

각 동작에 영향을 미치는 Modifier는 여러 종류가 있습니다. 그 중에서도 살펴볼 Modifier는 LayoutModifier입니다.

interface LayoutModifier : Modifier.Element {

    fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult
    
    ...
}

LayoutModifier는 `measure()` 메서드를 제공하는데 이는 위에서 배웠던 것과 거의 동일하지만 레이아웃 컴포저블의 `measure()` 메서드는 여러개의 measurable인 List<measurable>에서 작동했지만 LayoutModifier는 `measure()` 메서드는 하나의 measurable에만 작동한다는 점에서 차이가 있습니다.

 

그 이유는 Modifier는 하나의 항목에만 적용되기 때문입니다.

 

 

LayoutModifier의 `measure()` 메서드에서 Modifier는 `constraints`를 변경하거나 레이아웃 등의 사용자 정의 배치 로직을 구현할 수 있습니다.

 

Modifier를 통해 위에서 직접 구현했던 MyColumn과 같은 사용자 정의 레이아웃을 구현할 필요 없이 하나의 항목에만 적용하려면 Modifier를 사용하면 됩니다. 

 

 

 

Modifier의 동작

 

위의 왼쪽 그림의 파랑색 Box를 만들기 위해서는 오른쪽과 같이 Modifier를 사용하여 체인을 형성해야 합니다.

 

 

 

 

Box의 크기가 200 * 300 픽셀의 Contraints 를 가진다고 했을 때, 이 Contraints는 체인의 첫 Modifier에 입력됩니다. 

 

fillMaxSize()는 새로운 제약 세트를 생성하고 너비와 높이의 최대값과 최솟값이 입력된 너비의 높이의 최대값과 같도록 설정해서 내부를 채웁니다. 

 

 

즉, fillMaxSize()는 200 * 300 픽셀이 됩니다. 이젠 체인의 다음 단계로 Contraints를 전달해서 다음 요소를 측정하고 

 

 

마지막으로는 size에서 50 * 50 픽셀로 설정하고 이 Contraints를 Box 레이아웃으로 전달합니다. 그 다음 값을 측정하고  50 * 50 픽셀의 크기를 돌려보내고 배치 명령을 생성합니다.

 

 

 

배치 명령은 체인의 아래에서부터 위로 올라가면서 크기와 배치를 확인합니다.

 


 

Modifier의 동작은 레이아웃 트리와 똑같습니다. 다만 각 Modifier에 하위 요소가 하나뿐이라는 차이가 있습니다. 즉, 체인에 있는 다음 요소를 말합니다. Contraints를 아래로 전달하면 이후의 요소가 이를 사용하여 크기를 측정합니다. 

 

마지막으로는 확인된 크기를 다시 위로 반환해서 배치 명령을 생성합니다.

 

profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!