Compose Deep Dive — 2.Layout

hongbeom
hongbeomi dev
Published in
15 min readApr 3, 2022

Jetpack Compose Layout의 동작 원리를 Deep Dive into Jetpack Compose Layout(Android Dev Summit21) 영상과 함께 살펴봅니다.

Photo by Hal Gatewood on Unsplash

이 글은 Android Dev Summit21 영상 중 하나인 Deep Dive into Jetpack Compose Layout의 번역 & 정리 글입니다.

1편 Composition 링크

Layout

Composition 단계에서는 UI를 그리는 데 필요한 여러 가지 트리를 만들었습니다. 이어지는 Layout 단계에서는 이 트리를 작동하고 각각의 UI 노드를 측정하여 2D 영역의 화면에 배치하는 작업을 진행하게 됩니다. 즉, 각각의 UI 노드의 높이와 너비를 결정하고 x, y 좌표를 알아냅니다.

Layout 단계는 측정(Measure)과 배치(Place)로 나뉩니다. View의 onMeasure, onLayout과 유사한 단계라고 볼 수 있습니다. 하지만 Compose에선 이 두 단계가 결합되어 있습니다.

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

전에 Composition 단계의 SearchResult 예시에 이 과정이 적용되면 UI 트리가 Single Pass로 배치됩니다.

숫자 번호 순서대로 먼저 1. 루트 레이아웃인 Row를 측정합니다. 그리고 2. 첫 번째 하위 노드인 Image를 측정하고, 이 Image는 하위 노드가 없는 leaf 노드라고 스스로 측정하여 3. 크기를 보고합니다. 또한 자신의 하위 노드를 배치하는 방법을 리턴합니다.

일반적으로 leaf 노드는 비어있는데, 모든 레이아웃은 크기를 설정하는 동시에 이런 배치 방법에 대한 값을 리턴합니다.

4. 이제 Row가 두 번째 하위 요소인 Column을 측정합니다. 5. Column은 자신의 하위 노드를 측정합니다. 5, 6. 첫 번째 Text는 크기를 측정해서 배치 방법과 함께 이를 리턴합니다. 7, 8. 두 번째 Text도 마찬가지입니다. 9. Column에서 하위 노드를 측정하고 나면, 자신의 크기와 배치 로직을 알아낼 수 있습니다. 10. 마지막으로 모든 하위 노드의 크기를 알아냈으므로 루트 레이아웃인 Row에서 크기와 배치 방법을 알아낼 수 있습니다.

모든 노드의 크기 측정이 끝나면 다시 트리가 작동하는데, 배치 단계에서 모든 노드의 배치가 진행됩니다.

다시 Composition으로 돌아가보면 UI 트리가 완성되고 사실, 각각의 Composable은 하위 Composable에서 자동으로 생성됩니다.

UI 트리를 살펴보면 화면에 노드를 배치하는 모든 Composable에 하나 이상의 Layout Composable이 존재합니다.

Layout Composable은 Layout Node를 리턴하고 있습니다. 실제로 1편 Composition에서 코드를 살펴봤을 때, Text에 있는 Layout Composable은 결국 ReusableComposeNode를 리턴하는 것을 우리는 확인했었습니다.

Layout의 함수 시그니처를 보면 content 파라미터가 첫 번째로 위치하는데, 이는 하위 레이아웃을 담고 있습니다. 그리고 Layout에 적용하기 위한 Modifier가 두 번째 파라미터로 위치하며, 아이템을 측정하고 배치하기 위한 MeasurePolicy가 마지막 파라미터로 위치하고 있습니다.

일반적으로 MeasurePolicy(인터페이스)는 커스텀 Layout의 동작을 구현하는데 사용합니다.

위 그림에서 MyCustomLayout에서 Layout 함수를 호출하고 MeasurePolicy를 마지막 람다로 제공하여 필요한 측정 함수를 구현합니다.

이 람다는Constraints를 제공하여 레이아웃에 크기를 알려줍니다. Constraints는 일반 클래스이며 레이아웃의 높이와 너비의 최댓값과 최솟값을 모델링 합니다.

예를 들어 Constraints는 레이아웃에 제한을 걸지 않고 원하는 크기로 두거나,

레이아웃에 정확한 크기를 지정할 수 있습니다.

measure 함수는 배치 방법을 measurables을 통해 리스트로 전달받으며, Measureable 타입은 아이템을 측정하기 위한 함수를 가지고 있습니다.

각 노드는 하위 노드를 측정하고 자신의 크기를 결정하여 하위 노드를 배치해야 합니다.

이제 구현 방법에 대해 알아보겠습니다.

먼저 하위 노드를 측정하는데, measurable은 이를 실행하기 위해 크기에 대한 제약을 constraints를 파라미터로 전달받은 후 measure 메서드를 호출합니다.

커스텀 측정 로직을 적용하지 않는 단순한 예시의 경우(위 그림처럼), measurable 리스트를 매핑하여 각각을 측정하기만 하면 됩니다.

그럼 placeable이 생성되는데 placeable은 측정된 하위 노드들이고, 크기가 결정되어 있습니다.

실제로 measurePlaceable을 리턴하고, 여기에 너비와 높이 등의 정보가 들어있습니다.

placeable을 이용해서 높이와 너비를 계산한 후,

레이아웃의 크기가 얼마인지 layout 메서드를 호출하여 값을 보고합니다. 이때 layout 메서드는 placementBlock이 필요해지는데, 일반적으로는 람다로 구현해서 각 아이템을 원하는 곳에 배치합니다.

placeable에는 placeRelative 메서드도 존재하는데, 이 메서드는 내부적으로 placeAutoMirrored 메서드를 호출하여 레이아웃 방향이 일반적인 Left-to-Right가 아니라 Right-to-Left인 경우 자동으로 위치를 조정해줍니다.

현재 parent의 방향에 따라 자동으로 미러 배치

API 설계상 측정되지 않은 노드는 배치할 수 없습니다. 또한 place 메소드는 measure 메서드에서 리턴된 placeable에서만 사용이 가능합니다.

기존의 View에서 onMeasureonLayout을 호출할 경우에는 순서가 엄격하지 않아서 미묘한 버그와 동작의 차이가 발생했었습니다.

직접 ColumnLayout을 통해 구성하며 자세하게 원리를 알아보겠습니다.

우선 기본적으로 레이아웃에 제공할 Modifier와 수직으로 배치할 content를 파라미터로 가지는 Composable 함수를 작성해줍니다.

Layout Composable을 사용하여 이전 그림과 마찬가지로 크기를 제한하지 않고 그대로 측정해두도록 작성했습니다.

모든 아이템의 높이가 총 높이가 되고, 가장 긴 하위 아이템의 너비가 총 너비가 될 것입니다.

layout을 호출하여 크기를 보고한 다음, y 포지션을 추적하여 각 아이템을 배치하고, y 포지션을 더하며 점차 늘려줍니다.

물론 실제 Column Composable은 훨씬 더 효과적인 방식으로 이를 계산합니다. 가중치, 정렬 등을 모두 지원하기 때문입니다.

이제 기본적인 열 배치를 지원하는 커스텀 Column을 사용할 수 있습니다!

이번엔 규칙적인 그리드를 구현해보겠습니다.

시그니처는 Column과 비슷하나, columns 파라미터를 통해 default로 열의 개수를 2로 지정했습니다.

이번에도 Layout Composable로 구현하며, 각 열은 레이아웃의 최대 너비를 일정한 비율로 나눕니다.

새로운 Constraint 객체를 각각의 아이템에 대해 생성하는데 copy 메서드를 사용해서 높이 제약은 유지하되, 정확한 너비를 지정해줍니다.

그리고 이 constraint를 적용하여 각 아이템을 측정하고 그리드에 배치합니다.

하위 노드를 측정하는 다양한 contraint를 만들어내는 것이 이 모델의 핵심입니다. 상위 노드는 허용 가능한 크기를 전달하고 이를 constraint로 표현합니다.

하위 노드가 그 중에서 크기를 선택하면 상위 노드는 이를 받아서 처리해야 합니다.

이런 디자인에는 몇 가지 장점이 존재합니다.

한 번의 과정으로 UI 트리 전체를 측정하고 여러 번의 측정 사이클을 거치지 않아도 되는데, 기존의 View에서는 이게 문제였습니다.

여러 번 측정 값을 입력하는 중첩된 계층 구조에서는 leaf 뷰(자식이 없는 뷰)에서 측정 값에 대한 2차적인 값이 발생하기도 했습니다.

사실 Compose에서는 2번의 측정을 시도하면 에러를 발생시킵니다. 더욱 효과적인 성능을 제공하기 때문에 레이아웃 애니메이션 등의 새로운 기능을 활용할 수 있습니다.

Compose의 레이아웃은 높은 성능을 제공하기 때문에 측정이나 배치에 애니메이션을 적용하거나 제스처로 실행할 수도 있습니다.

기존의 View 시스템에선 성능 문제로 인해 레이아웃 애니메이션을 권장하지 않아서 구현이 어렵습니다.

Modifier

이번엔 Layout에서 중요한 역할을 제공하는 Modifier에 대해 알아보겠습니다. Modifier를 사용하면 크기와 위치를 구성하는 부분에 옵션을 제공할 수 있습니다.

우리는 앞서서 Layout Composable이 Modifier를 파라미터로 받는 것을 확인했습니다.

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

Modifier는 여러가지 종류로 존재합니다. LayoutModifier, DrawModifier, GraphicsLayerModifier 등등 여러가지가 존재하는데 위 그림에 있는 Modifier.Element 또한 Modifier를 구현하는 인터페이스 입니다.

그림에 있는 LayoutModifiermeasure 메서드를 제공하는데, Layout Composable과 거의 동일하지만 Measureable이 한 개에만 적용된다는 차이가 있습니다. 이는 Modifier가 하나의 아이템에만 적용되기 때문입니다.

measure 메서드에서 Modifier는 제약을 변경하거나 layout 등의 커스텀 배치 로직 등을 변경할 수 있습니다. 하나의 아이템에만 적용이 필요한 경우, 커스텀 레이아웃을 구현할 필요 없이 Modifier를 대신 사용해도 됩니다.

예를 들어 PaddingModifier가 어떻게 적용되는지 살펴보겠습니다. then 메서드에 원하는 패딩 값을 캡처하는 PaddingModifier 객체를 생성하고 있습니다.

실제로 then 메서드는 else 부분에서 두 개의 Modifier를 모두 가지는 CombineModifier를 리턴하는 것을 볼 수 있습니다.

PaddingModifier 클래스는 LayoutModifier를 구현하고 있습니다.

내부 measure를 살펴보면 constraintoffset에 패딩 값을 차감하여 측정값을 변경하고 그 content를 측정합니다.

그리고 콘텐츠를 배치합니다.

물론 레이아웃 Modifier를 직접 구현하거나 Modifier.layout을 사용할 수도 있습니다. 커스텀 Modifier나 배치 로직을 Modifier 체인(메소드 체이닝)에서 Composable에 직접 추가할 수 있고 커스텀 레이아웃이 필요하지 않습니다.

위의 그림처럼 Modifier 체인에서 커스텀 측정과 배치를 구현할 수 있습니다.

Modifier를 사용할 때는 순서에 따라 UI의 모습이 달라질 수 있는데 예시와 함께 이유를 살펴보겠습니다.

크기가 고정된 파란 Box를 만들고 상위 노드의 가운데에 배치하는 예시입니다. 이는 Modifier 체인으로 구현이 가능한데 어째서 위 코드로 이런 모습의 UI가 구성되는지 알아보겠습니다.

위 코드에서 쉽게 크기를 작성하고 Box를 배치했지만, 상위 노드의 왼쪽 상단 모서리에 배치되었습니다.

Box를 가운데로 옮기려면 배치에 영향을 주는 WrapContentModifier (LayoutModifier의 구현체) 클래스를 만들어내는 wrapContentSize 메서드를 사용해야 합니다.

기본값이 Alignment.Center이기에 파라미터를 생략할 수 있습니다. 하지만 Box는 아직 왼쪽 상단에 머물러 있습니다.

대부분 레이아웃은 콘텐츠를 래핑하기 때문에 모든 공간을 차지하도록 측정값을 지정해야 상자가 가운데 정렬됩니다.

fillMaxSize 메서드를 wrapContentSize 앞에 작성해주면 이 문제가 해결됩니다.

이제 이 이유를 알아보겠습니다. Box가 너비가 200, 높이가 300인 컨테이너 안에 위치하고 있다고 가정해봅시다.

이 제약은 체인의 첫 Modifier에 입력됩니다. 기본적으로 fillMaxSize는 새로운 Constraints 세트를 생성하고 너비와 높이의 최댓값과 최솟값이 모두 최댓값으로 지정됩니다. 즉 200 X 300 사이즈의 크기를 가지게 되는 것입니다.

이제 wrapContentSize가 이를 수신하여 새로운 Consraints 세트를 만들어냅니다. 여기서 0~200 X 0~300 사이즈의 크기를 가지도록 수정됩니다.

fillMaxSize 단계를 되돌리는 것처럼 보일 수 있지만 이 Modifier는 크기 때문이 아닌 가운데 정렬 때문에 사용하는 차이가 있습니다.

이제 size에서 이 제약을 수신하여 정확한 크기로 지정되도록 새로운 Consraints 세트를 생성하여 크기가 50 X 50이 됩니다.

마지막으로 이 크기를 Box에 전달합니다.

이제 값을 측정하고 50 X 50의 크기를 Modifier 체인 위로 리턴합니다.

이제 size Modifier도 50 X 50으로 설정되고 배치 방법(placement)이 생성됩니다.

wrapContentSize가 크기를 확인한 후 콘텐츠를 가운데 배치하는 배치 방법을 생합니다. 이 Modifier는 자신의 크기가 200 X 300이고 다음 노드의 크기가 50 X 50 인 것을 알고 가운데 정렬 배치 방법을 생성하여 가운데에 배치합니다.

그리고 마지막으로 fillMaxSize가 크기와 배치 방법을 생성합니다.

이렇게 Modifier의 동작 방식을 알아보았는데, Modifier는 레이아웃 트리와 동작은 똑같지만 각 Modifier에 하위 노드가 하나뿐이라는 차이가 있습니다.

제약(Constraint)를 아래로 전달하면 이후의 노드가 이를 사용하여 크기를 측정합니다. 그리고 확인된 크기를 다시 위로 리턴해서 배치 방법을 생성합니다.

이런 설계 덕분에 우리는 Modifier를 통해 다양한 측정값과 레이아웃 정책을 결합하고 기능을 구현할 수 있는 것입니다.

읽어주셔서 감사합니다! 🙌

3부 Advanced Features에서 이어 작성하겠습니다.

--

--