fornewid / ConfDocs

Conference 내용 끄적끄적
4 stars 0 forks source link

Deep dive into Jetpack Compose layouts #1

Closed fornewid closed 2 years ago

fornewid commented 2 years ago

zMKMwh9gZuI-HD

본 세션에서는 Jetpack Compose에 대한 기본적인 내용을 다루지 않습니다. Compose에 아직 익숙하지 않다면 문서를 먼저 읽어보세요. goo.gle/compose-docs

목차

fornewid commented 2 years ago
스크린샷 2021-10-31 오후 4 26 22

Jetpack Compose는 UI를 선언적으로 구성하는 방법을 제공합니다. (Composable, Modifier)

이 세션의 목표

스크린샷 2021-10-31 오후 4 26 42

Compose 레이아웃 시스템의 목표

fornewid commented 2 years ago

Layout Model

스크린샷 2021-10-31 오후 4 27 25

Jetpack Compose는 구성, 레이아웃, 그리기의 3단계 프로세스를 거쳐, 상태를 UI로 변환합니다.

스크린샷 2021-10-31 오후 4 27 50

구성(Composition) 단계에서는 UI 트리를 생성하는 composable 함수를 호출합니다. 예를 들어, SearchResult composable을 실행하면 위와 같은 UI 트리가 생성됩니다.

스크린샷 2021-10-31 오후 4 28 00

레이아웃(Layout) 단계에서는 트리를 순회하며 UI의 각 부분을 측정하고(measure), 2D 공간의 화면에 배치합니다(place). 즉, 각 노드는 너비와 높이, x, y 좌표를 결정합니다.

흥미로운 점은 View 시스템의 measure, layout과 거의 동일하지만, Compose에서는 단계가 서로 얽혀 있습니다.

UI 트리에서 각 노드를 배치하는 과정은 3단계 프로세스입니다. 각 노드는 자식을 측정하고(measure), 자신의 크기를 결정한 다음(size), 자식을 배치해야 합니다(place).

스크린샷 2021-10-31 오후 4 28 27

이를 예제에 적용하면 위 그림과 같습니다. UI 트리는 단일 패스(single pass)로 배치됩니다.

그리기(Drawing) 단계에서는 UI 트리를 다시 순회하며 모든 요소를 그립니다.


각 단계를 이해했으니, 어떻게 구현되어 있는지 다시 살펴보겠습니다.

Overview Details

구성(Composition) 단계로 돌아가 봅시다. 행, 열, 텍스트 등의 higher-level composable을 사용하여 UI 트리를 표현했습니다.

실제로는 각각의 composable은 lower-level composable로 구성되어 있습니다. 그래서 오른쪽 UI 트리를 살펴보면, 화면에 요소를 배치하는 모든 composable에 하나 이상의 Layout composable이 포함되어 있음을 알 수 있습니다.

스크린샷 2021-10-31 오후 4 28 56

Layout composable은 Compose UI의 기본 요소로, 레이아웃 노드를 내보냅니다. 즉, Compose에서 UI 트리는 Layout Node의 트리입니다.

fornewid commented 2 years ago

Layout composable

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

Layout composable이 어떻게 작동하는지 살펴보겠습니다.

일반적으로 레이아웃의 동작을 맞춤형으로 구현하려면 이런 인터페이스를 구현해야 합니다. (Column, Row, Box 처럼)

@Composable
fun MyCustomLayout(
  modifier: Modifier = Modifier,
  content: @Composable () -> Unit
) {
  Layout(
  ) { measurables: List<Measurable>,
      constraints: Constraints ->
    val placeables = measurables.map { measurable ->
      measurable.measure(constraints)
    }
    val width =  // calculate from placeables
    val height = // calculate from placeables
    layout(width, height) {
      placeables.forEach { placeable ->
        placeable.place( // or placeRelative
          x = ...
          y = ...
        )
      }
    }
  }
}

위의 MyCustomLayout composable은 Layout을 구현하는 간단한 예제입니다. 여기서 MeasurePolicy는 lambdat로 구현되어 있습니다.

코드 보기 ```kotlin class Constraints { val minWidth: Int val maxWidth: Int val minHeight: Int val maxHeight: Int ... } ``` ```kt val bigAsYouLike = Constraints( minWidth = 0, maxWidth = Constraints.Infinity, minHeight = 0, maxHeight = Constraints.Infininty, ) val exact = Constraints( minWidth = 50, maxWidth = 50, minHeight = 50, maxHeight = 50, ) ```

1) Constraints는 레이아웃의 최소, 최대 너비와 높이를 모델링하는 클래스입니다. 예를 들어, Constraints는 레이아웃 크기에 제한을 두지 않거나, 정확한 크기를 표현할 수 있습니다.

2) 다음으로 Measurable은 전달된 자식 요소를 표현하고, 측정할 수 있습니다. 가장 간단한 방법으로 measurables과 constraints을 이용하여 Placeable 목록을 생성할 수 있습니다.

3) Placeable은 측정이 된 자식으로 크기를 갖습니다. 따라서 placeables를 사용하여 레이아웃 크기를 계산할 수 있습니다.

4) 마지막으로 layout()을 호출하여, 원하는 레이아웃 크기를 보고합니다. 이 때, 각 항목을 원하는 위치에 배치할 수 있습니다. (=place()) 참고로 RTL을 고려한 placeRelative() 함수도 있습니다.

fornewid commented 2 years ago

Layout Examples

처음에는 Layout composable이 다소 복잡해 보일 수 있지만, 일단 익숙해지면 매우 강력하고 사용하기 쉽습니다. 이를 설명하기 위해 몇 가지 레이아웃을 구축하는 과정을 살펴보겠습니다.

스크린샷 2021-10-31 오후 4 37 02
@Composable
fun MyColumn(
  modifier: Modifier = Modifier,
  content: @Composable () -> Unit
) {
  Layout(
    modifier = modifier,
    content = content
  ) { measurables, 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
      }
    }
  }
}

위 코드는 Column을 구현하는 간단한 예제입니다. 참고로 실제 Column composable은 weight, alignment 등 더 많은 기능을 지원합니다.


스크린샷 2021-10-31 오후 4 37 37
@Composable
fun VerticalGrid(
  modifier: Modifier = Modifier,
  columns: Int = 2,
  content: @Composable () -> Unit
) {
  Layout(
    modifier = modifier,
    content = content
  ) { measurables, constraints ->
    val itemWidth = constraints.maxWidth / columns
    // Keep given height constraints, but set an exact width
    val itemConstraints = constraints.copy(
      minWidth = itemWidth,
      maxWidth = itemWidth
    )
    // Measure each item with these constraints
    val placeables = measurables.map { measurable ->
      measurable.measure(itemConstraints)
    }
    ...
  }
}

위는 그리드를 구현하는 간단한 예제 코드입니다. 자식 content에 대한 새로운 Constraints를 만드는 방식으로, MeasurePolicy를 구현하고 있습니다.


스크린샷 2021-10-31 오후 4 37 54

자식을 측정(measure)할 때, 다양한 Constraints를 생성할 수 있는 기능이 Compose 레이아웃 모델의 핵심입니다. 자식은 부모가 허용한 크기 범위 내에서 크기를 선택해야 하고, 부모는 자식이 선택한 크기를 그대로 처리해야 합니다.

스크린샷 2021-10-31 오후 4 38 08

이 디자인에는 몇 가지 좋은 점이 있습니다. 단일 패스(single pass)로 전체 UI 트리를 측정할 수 있습니다. (즉, multiple pass를 금지할 수 있습니다.) View 시스템에서는 multiple pass를 수행하는 레이아웃이 중첩될 때마다 Leaf View의 측정이 2배수씩 늘어나는 문제가 있었습니다.

Compose는 이를 방지하도록 설계되어, measure를 두번 시도하면 exception이 발생합니다. 즉, 강력한 성능 보장으로 인해 레이아웃 애니메이션과 같은 새로운 가능성이 열립니다.


앱 디자인에서 요구하는 특수한 레이아웃을 만드는 데에도 Layout composable을 사용할 수 있습니다.

예를 들어, Jetsnack 샘플 앱에서 커스텀 하단 탐색바 디자인을 구현했습니다.

레이아웃 변경 애니메이션을 정밀하게 다루고 싶어서, 커스텀 레이아웃으로 만들었습니다.

@Composable
fun BottomNavItem(
  icon: @Composable BoxScope.() -> Unit,
  text: @Composable BoxScope.() -> Unit,
  @FloatRange(from = 0.0, to = 1.0) animationProgress: Float
) {
  Layout(
    content = {
      Box(
        modifier = Modifier.layoutId("icon"),
        content = icon
      )
      Box(
        modifier = Modifier.layoutId("text"),
        content = text
      )
    }
  ) { measurables, constraints ->
    val iconPlaceable = measurables.first { it.layoutId == "icon" }.measure(constraints)
    val textPlaceable = measurables.first { it.layoutId == "text" }.measure(constraints)

    placeTextAndIcon(
      textPlaceable,
      iconPlaceable,
      constraints.maxWidth,
      constraints.maxHeight,
      animationProgress
    )
  }
}

fun MeasureScope.placeTextAndIcon(
  textPlaceable: Placeable,
  iconPlaceable: Placeable,
  width: Int,
  height: Int,
  @FloatRange(from = 0.0, to = 1.0) animationProgress: Float
): MeasureResult {
  val iconY = (height - iconPlaceable.height) / 2
  val textY = (height - textPlaceable.height) / 2

  val textWidth = textPlaceable.width * animationProgress
  val iconX = (width - textWidth - iconPlaceable.width) / 2
  val textX = iconX + iconPlaceable.width

  return layout(width, height) {
    iconPlaceable.placeRelative(iconX.toInt(), iconY)
    if (animationProgress != 0f) {
      textPlaceable.placeRelative(textX.toInt(), textY)
    }
  }
}

여기서 참고할 만한 것은 measure block에서 measurables을 식별할 수 있도록 Modifier.layoutId()를 적용한 부분입니다. (measurables의 순서에 의존하는 것보다 나은 방법입니다.)

이 예제는 Compose의 레이아웃 성능이 매우 좋아서, measurement 또는 placement를 애니메이션 처리하거나 제스처로 구동할 수 있기 때문에 가능합니다. View 시스템에서는 성능 문제로 인해 이런 방법이 권장되지 않았습니다.

아래와 같은 경우에는 커스텀 레이아웃 구현을 제안합니다

정리하면, 기본으로 제공되는 레이아웃은 강력하고 유연하지만 때로는 커스텀 레이아웃을 구현하는 것이 더 적합할 수도 있습니다.

fornewid commented 2 years ago

Modifiers

스크린샷 2021-10-31 오후 4 40 30

Modifier는 레이아웃, 크기 및 위치 구성에서 중요한 역할을 합니다.

스크린샷 2021-10-31 오후 4 40 37

Modifier는 레이아웃 자체의 측정 및 배치 전에, 측정 및 배치에 관여할 수 있습니다.

스크린샷 2021-10-31 오후 4 40 40

그리기 Modifier, 포인터 입력 Modifier 및 포커스 Modifier와 같은 다양한 Modifier들이 있는데요. 그 중에는 Layout composable과 거의 동일한 측정 방법을 제공하는, LayoutModifier가 있습니다.

단일 항목에만 레이아웃 작업을 수행해도 된다면, 커스텀 레이아웃을 작성하는 것 대신에 Modifier를 사용할 수도 있습니다.

예제: PaddingModifier 스크린샷 2021-10-31 오후 4 40 52 스크린샷 2021-10-31 오후 4 40 58 `LayoutModifier`의 구현체 중 `PaddingModifier`는 다음과 같이 작동합니다. 1) padding 크기만큼 외부 Constraints를 축소하여 content를 측정합니다. 2) 그런 다음 원하는 padding만큼 offset된 content를 배치합니다.
예제: Modifier.layout() 스크린샷 2021-10-31 오후 4 41 13 또는 `Modifier.layout` 를 사용하여, 직접 구현할 수도 있습니다.

스크린샷 2021-10-31 오후 4 41 28
선언된 Modifier에 따라 다르게 표시되는 UI 더보기 스크린샷 2021-10-31 오후 4 41 36 스크린샷 2021-10-31 오후 4 41 45 스크린샷 2021-10-31 오후 4 42 05 스크린샷 2021-10-31 오후 4 42 09
단계 별 변경사항 스크린샷 2021-10-31 오후 4 42 57 스크린샷 2021-10-31 오후 4 43 10 스크린샷 2021-10-31 오후 4 43 17 스크린샷 2021-10-31 오후 4 43 22 스크린샷 2021-10-31 오후 4 43 28 스크린샷 2021-10-31 오후 4 43 31 스크린샷 2021-10-31 오후 4 43 35 스크린샷 2021-10-31 오후 4 43 39

레이아웃은 Modifier 체인을 순서대로 모델링하기 때문에, Modifier의 순서는 중요합니다.

각 Modifier에 체인의 다음 요소인 단일 자식이 있다는 점을 제외하면, Layout 트리와 동일하게 작동합니다. Constraints는 Modifier 체인의 아래로 전달되며, 후속 요소가 자체 측정하는데 사용됩니다. 그런 다음 결정된 크기가 다시 올라오고, 배치에 대한 지침이 만들어집니다.

fornewid commented 2 years ago

Advanced features

레이아웃 모델의 기본사항 외에도, 알아두면 좋을 여러가지 고급 기능이 있습니다.

Instrinsic Measurement

스크린샷 2021-10-31 오후 4 44 44

Compose는 단일 패스(single pass) 레이아웃 시스템을 사용하지만, 항상 그런 것은 아닙니다. 메뉴 팝업처럼 Constraints를 완성하기 전에 자식 크기를 먼저 알아야 하는 경우가 있습니다.

슬라이드 더보기 스크린샷 2021-10-31 오후 4 44 52 스크린샷 2021-10-31 오후 4 45 06 스크린샷 2021-10-31 오후 4 45 14 스크린샷 2021-10-31 오후 4 45 51

이런 경우에는 InstrinsicSize를 이용하여, 너비를 적절하게 설정할 수 있습니다.


ParentData

스크린샷 2021-10-31 오후 4 47 59
슬라이드 더보기 스크린샷 2021-10-31 오후 4 47 34 스크린샷 2021-10-31 오후 4 47 50 스크린샷 2021-10-31 오후 4 48 13

지금까지 살펴본 Modifier는 범용으로, 모든 composable에 적용할 수 있었습니다.

하지만 때로는 레이아웃에서 자식으로부터 추가정보를 받아야 할 수도 있습니다. 이럴때 ParentDataModifier가 유용합니다.

Box를 예로 들면, BoxScope는 Box 내에서만 사용할 수 있는 Modifier가 정의되어 있습니다. (ex. align modifier) → Box.kt

스크린샷 2021-10-31 오후 4 48 26

물론 커스텀 레이아웃에 대해 고유한 ParentDataModifier를 작성할 수도 있습니다.


Alignment Lines

스크린샷 2021-10-31 오후 4 48 38

Alignment Line을 사용하면 (레이아웃의 상단, 하단, 중앙이 아닌) 다른 항목을 기준으로 정렬할 수 있습니다. 가장 일반적으로 사용되는 것은 text baseline입니다.

슬라이드 더보기 스크린샷 2021-10-31 오후 4 50 23 스크린샷 2021-10-31 오후 4 50 42 스크린샷 2021-10-31 오후 4 50 46 스크린샷 2021-10-31 오후 4 51 01
Before After

Alignment는 부모를 통과하므로, 중첩된 자식과도 정렬할 수 있습니다.


BoxWithConstraints

@Composable
fun MyApp(...) {
  if (someCondition) {
    CompatLayout()
  } else {
    LargeLayout()
  }
}

Compose에서는 표시할 항목을 조건에 따라 정할 수 있습니다.

@Composable
fun BoxWithConstraints(
  ...
  content: @Composable BoxWithConstraintsScope.() -> Unit
)

interface BoxWithConstraintsScope : BoxScope {
  val constraints: Constraints
  val minWidth: Dp
  val maxWidth: Dp
  val minHeight: Dp
  val maxHeight: Dp
}
스크린샷 2021-10-31 오후 4 51 36

하지만 때로는 큰 화면을 대응할 때처럼 사용가능한 공간에 따라 결정을 내리고 싶을 수도 있습니다.

일반적인 레이아웃 구성에서는 layout 단계 이전에 크기 정보를 알 수 없습니다. 이런 경우에 BoxWithConstraints을 사용할 수 있습니다.

BoxWithConstraints는 Box와 유사하지만, layout 단계가 될 때까지 composition을 연기합니다.

fornewid commented 2 years ago

Performance

Layout (Measure + Place) Place only

레이아웃 단계는 측정(measure)과 배치(place)라는 하위 단계로 나눌 수 있습니다. 그리고 측정(measure)이 아닌 배치(place)에만 영향을 주는 변경사항을 별도로 실행할 수도 있습니다.

Jetsnack 샘플 앱에는 스크롤에 따라 여러 요소가 이동하거나 크기가 조정되는 효과가 있습니다. 여기서 제목 영역은 메인 콘텐츠와 함께 스크롤되어 화면 상단에 고정됩니다.

스크린샷 2021-10-31 오후 4 54 02

간단하게는 이를 구현하려면, 스크롤 상태를 Composable에 전달하여 값에 따라 UI를 변경하면 됩니다.

스크린샷 2021-10-31 오후 4 54 12

이 때 상태를 어떻게 관찰하느냐가 성능에 중요합니다. 단순히 offset에 값을 넣으면, 스크롤 상태가 변경될 때마다 recomposition이 발생하게 됩니다.

Composition Recomposition Place only

즉, 스크롤 할때마다 Composable의 구성, 레이아웃, 그리기 3단계를 모두 수행하게 됩니다.

스크린샷 2021-10-31 오후 4 54 29

offset을 람다 형식으로 사용하면, 배치(place) 및 그리기(drawing)만 실행하도록 개선할 수 있습니다. (SnackDetail.kt#L249)

예제: 하단 탐색바 Before | After -- | -- | 하단 탐색바 예제로 돌아가보면, 동일한 문제가 나타납니다. 이 부분도 동일한 방법으로 개선할 수 있습니다.

정리하면, composition이 과도하게 발생되는 원인이 될 수 있기 때문에 Composable 또는 Modifier의 매개 변수가 자주 변경되는 것은 아닌지 의심해봐야 합니다. 즉, 표시되는 내용이 변경될 때에만 재구성되도록 하는 것이 좋습니다.


스크린샷 2021-10-31 오후 4 54 55

BoxWithConstraints는 layout 중에 sub-composition하는 방식으로, layout에 따른 composition을 허용하고 있습니다.

성능 상 layout 중에는 composition을 최대한 피하는 것이 좋으므로, BoxWithConstraints를 사용하는 것보다는 크기에 따라 변경되는 레이아웃을 사용하는 것이 좋습니다.


레이아웃 성능을 향상하는 것에 대해 이야기해 보겠습니다.

스크린샷 2021-10-31 오후 4 55 08

아이콘 크기와 제목 높이는 고정입니다.

스크린샷 2021-10-31 오후 4 55 14

그래서 시스템이 레이아웃의 크기를 결정하는 데 중요한 것은 Body 뿐입니다. 즉, 카드를 측정할 때 Body만 측정하면 됩니다.

스크린샷 2021-10-31 오후 4 55 23

카드의 크기는 Body 크기에 48dp의 높이를 더한 것과 같습니다. 시스템은 Body만 측정되었음을 인식하므로, 레이아웃 크기를 결정하는 데 중요한 유일한 자식입니다.

스크린샷 2021-10-31 오후 4 55 35

아직 측정되지 않은 아이콘과 텍스트는 배치(place) block에서 측정하면 됩니다.

Before After

여기서 제목이 레이아웃이라고 가정해 보겠습니다. 제목이 "Layout"에서 "Layout in Compose Is Great"로 변경되어도, 시스템은 다시 측정할 실행할 필요가 없습니다. 따라서 Body는 다시 측정되지 않아 불필요한 작업을 절약할 수 있습니다.

fornewid commented 2 years ago
스크린샷 2021-11-01 오전 2 47 13

Easy Jetpack Compose에서 커스텀 레이아웃을 구현하는 것은, 하나의 함수를 작성하는 것처럼 간단합니다. 또한 Modifier를 사용하여 필요한 기능을 훨씬 더 쉽게 얻을 수 있습니다.

Powerful 레이아웃 시스템은 Alignment Line, ParentDataModifier, RTL, sub-composition과 같은 여러가지 고급 기능을 지원합니다.

Performant 그리고 single pass 레이아웃 모델 또는 재측정을 건너뛰고 교체만 실행할 수 있는 방법을 통해, 애니메이션이나 제스처 동작에서도 고성능 레이아웃을 작성할 수 있는 방법을 살펴봤습니다.

Resources

Compose에 대해 더 자세한 내용을 알고 싶으면, 아래 개발 문서를 참고해보세요.

fornewid commented 2 years ago

요약