본문으로 바로가기

[Compose] 7.Custom Layout

category 개발이야기/Android 2021. 9. 30. 11:55
반응형

Photo by unsplash

이 글은 Android developer 공식사이트에서 제공하는 문서를 기반으로 의역, 번역 하였습니다.[1]

Compose는 여러개의 composable function이 모여서 하나의 UI (화면)을 구성합니다. 이러한 각각의 요소들이 Box, Column, Row 안에 배치되어 UI tree를 구성하고 render되어 화면에 그려집니다.

위에서 언급한 세가지의 Composable 이외에 custom 하게 배치하는 layout을 만들려면 Layout composable을 이용해야 합니다. 실제로 Column의 코드를 따라가 보면 아래와 같이 표현되어 있는 걸 알 수 있습니다.

// 실제 Column의 구현 코드
@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
    )
}

Principles of layouts in compose

Composable functions은 작은단위의 UI 조각을 반환하고 이런 조각들이 합쳐져서 하나의 화면을 구성합니다. 하나의 composable function (또는 elements)은 하나의 부모를 가지며, 여러 자식들을 가질 수 있습니다. 또한 각 element들은 부모 composable안에서 (x, y) 좌표에 의해서 배치되며 width, height를 갖습니다.

각각의 element는 부모가 넘겨주는 제약조건안에서 자신의 size를 정하도록 요청받습니다. 여기서 제약조건(constraints)는 elements가 가질 수 있는  width, height에 대한 min/max 범위입니다. 만약 해당 element가 자식 요소 (child elements)를 가지고 있다면 자신의 크기를 정하기 위해 child element들의 크기를 측정해야 합니다. 그다음으로 element가 자신의 size를 확정하고 나면 자기가 가지고 있는 child elements를 배치할 수 있는 기회가 생깁니다.

Layout 수행시 구글의 가이드에서 아래와 같은 표현을 사용합니다.

Compose UI does not permit multi-pass measurement

간단하게 말해서 자식에 대한 size 측정 시 한 번만 해야 합니다. (두 번 측정 시 runtime error가 발생합니다.)이는 child element들의 size 측정을 여러 번 발생할 경우 성능면에서 문제가 생길 수 있기 때문입니다. child는 한개가 아닐수 있고 child는 또 다수의 child를 가질 수 있기에 여러번 측정할 경우 당연히 성능상에 문제가 발생하겠죠?

물론 child elements를 측정하기 전에 추가적인 정보가 필요한 경우 intrinsic measurement를 이용하는데, 이는 다음 포스팅에 따로 언급하겠습니다.

말보다는 순서대로 구성된 코드를 보는 게 더 빠릅니다. 따라서 modifier에서 layout을 써서 기능을 추가하는 방법과, Column 같은 ViewGroup의 역할을 하는 Layout을 만드는 방법에 기본적인 방법에 대해서 알아봅니다.

Using the layout modifier

Modifier는 compose의 배치나, padding을 넣는 기본적인 역할도 합니다. layout을 이용해서 Modifier에서 제공하는 paddingFromBaseline을 직접 구현해 보겠습니다.

fun Modifier.customLayoutModifier(...) = this.layout { measurable, constraints ->
  ...
})

기본적인 custom layout modifier는 위와 같이 생겼습니다.

이제 BaseLine을 기준으로 padding을 설정하는 firstBaselinToTop()을 만들어 보겠습니다. 이는 기본적인 padding top과는 아래와 같이 다릅니다.

출처: https://developer.android.com/images/jetpack/compose/layout-padding-baseline.png

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then(
    layout { measurable, constraints ->
        ...
    }
)

기본 적인 함수 구성입니다. Extension을 이용해서 Modifier에 firstBaselinToTop() 함수를 추가합니다. 그리고 layout을 사용하여 동작할 사항을 lambda block안에 구현해야 합니다. 예제에서는 Text()의 Modifier에서 사용할 예정이므로 layout의 param은 다음과 같은 의미를 가집니다.

  • measurable: 측정할 element = 여기서는 Text()
  • constratins: 부모로부터 받은 min/max의 width/height 범위

가장 먼저 Text()의 size를 측정하는 것으로 아래와 같이 measure()를 호출하여 배치 가능한 크기를 전달받습니다.

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then(
    layout { measurable, constraints ->
         // Composable을 측정
         val placeable = measurable.measure(constraints)
         
         // 측정값(pixel 3 기준 -  width:163px | height:59px
    }
)

measure()의 반환된 객체인 Placeable은 측정된 width와 height 값을 가집니다. 제가 테스트한 단말에서의 반환된 값은 주석에 넣었습니다.

그리고 FirstBaseline을 구해야 하는데 이를 지원하는지를 먼저 체크하고 값을 가져옵니다.

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then(
    layout { measurable, constraints ->
      ...
         
         // Check the composable has a first baseline
    	 check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
         val firstBaseline = placeable[FirstBaseline]
         
         // 측정값 - firstBaseline: 47px
    }
)

firstBaseline의 값이 측정되었으니, 실제로 Text()의 height로 사용할 값을 계산합니다.

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then(
    layout { measurable, constraints ->
         ...
         
         // Height of the composable with padding - first baseline
         val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
         val height = placeable.height + placeableY
         
         // 측정값 - firstBaselineToTop.roundToPx(): 88px, height:100
    }
)

마지막으로 layout(widith, height)를 이용하여 측정된 composable의 크기를 확정합니다. 그리고 추가되는 lambda안에 placeable.placeRelative(x, y)를 이용하여 배치합니다. placeRelative(x, y)를 호출하지 않으면 화면에 표시되지 않습니다.

전체 코드는 아래와 같습니다.

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = layout { measurable, constraints ->
    // Composable 측정
    val placeable = measurable.measure(constraints)

    // 측정값(pixel 3 기준 -  width:163px | height:59px

    // Check the composable has a first baseline
    check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
    val firstBaseline = placeable[FirstBaseline]

    // 측정값 - firstBaseline: 47px

    // Height of the composable with padding - first baseline
    val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
    val height = placeable.height + placeableY

    // 측정값 - firstBaselineToTop.roundToPx(): 88px, height:100

    layout(placeable.width, height) {
        // Where the composable gets placed
        placeable.placeRelative(0, placeableY)
    }
}

완성된 Modifier는 아래와 같이 호출할 수 있습니다.

@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
    MyApplicationTheme {
        Text("Hi there!", Modifier.firstBaselineToTop(24.dp))
    }
}

@Preview
@Composable
fun TextWithNormalPaddingPreview() {
    MyApplicationTheme {
        Text("Hi there!", Modifier.padding(top = 24.dp))
    }
}

Creating custom layouts

위에서는 Modifier를 하나의 composable을 measure 하고 laid out 했다면, 이번에는 Layout을 이용하여 group composable의 역할을 하는 기본적인 Column을 직접 구현해 보겠습니다. 기존 view system에서 ViewGroup을 상속받아 measure / layout을 구현해야 했지만 Compose에서는 Layout 하나로 구현할 수 있습니다.

먼저 Layout을 이용하는 공통적인 composable의 format은 아래와 같습니다.

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

Layout()에는 modifiercontent가 최소 기본 param으로 넘어가야 합니다. 따라서 CustomLayout 역시 최소한 두 개의 param을 받아서 Layout으로 넘겨줍니다. 또한 뒤따르는 lambda 블록은 위에서 사용했던 layout과 동일한 param (measurables, constraints)를 제공합니다. 여기서는 자식들을 배치하는 작업을 해야 하니 measurables이 포함된 자식들을 나타냅니다.

단순하게 vertical 하게 child element를 배치하는 custom column을 만들기 위해서 위 format에 따라 기본적인 composable 함수의 뼈대를 만듭니다.

@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(modifier = modifier, content = content) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

가장 먼저 해야 하는 일은 child elements들을 measure 하여 placeables를 구성하는 것이며, 이렇게 축적된 자식들의 width과 maxHeight를 이용하여 추후 화면에 배치합니다.

@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(modifier = modifier, content = content) { measurables, constraints ->
        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }
    }
}

자식들에 대한 측정이 끝났으면 MyOwnColumn이 사용할 크기와 높이를 확정해야 합니다. 이때 부모의 최대 width, 최대 height를 사용하도록 합니다.

@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(modifier = modifier, content = content) { measurables, constraints ->
         // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children
        }
    }
}

layout 호출 후 전달받은 lambda block안에서 측정해 놓은 자식들을 배치하도록 합니다.

@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(modifier = modifier, content = content) { measurables, constraints ->
        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }

        // Track the y co-ord we have placed children up to
        var yPosition = 0

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children in the parent layout
            placeables.forEach { placeable ->
                // Position item on the screen
                placeable.placeRelative(x = 0, y = yPosition)

                // Record the y co-ord placed up to
                yPosition += placeable.height
            }
        }
    }
}

최종적으로 작성된 전체 코드입니다. placeables를 for문으로 돌면서 placeRelative를 호출하여 화면에 배치되도록 합니다. 이때 child elements의 y 좌표는 각각의 높이를 누적하여 vertical 하게 배치될 수 있도록 합니다.

테스트를 위하여 아래와 같이 작성하면 원했던 대로 출력되는 걸 확인할 수 있습니다.

@Composable
fun CustomColumnSample() {
    MyOwnColumn {
        Text("가나다")
        Text("라마바")
        Text("사아자")
        Text("차카타")
    }
}

References

[1] https://developer.android.com/codelabs/jetpack-compose-layouts?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-layouts#6

[2] https://developer.android.com/jetpack/compose/layouts/custom

 

반응형