컴포즈는 Column, Row 또는 Box와 같은 기본 제공하는 컴포저블들을 통해 작은 단위의 커스텀 레이아웃으로 만들어 재사용성을 높일 수 있도록 한다.

하지만, 수동으로 하위 컴포저블의 사이즈를 측정하고 배치해야 하는 것처럼, 무언가 앱에서 특별한 것을 만들어야 할 때가 있다.

Note : View 시스템에서 커스텀 레이아웃을 만드려면, ViewGroup을 확장하고 onMeasure 및 onLayout과 같은 메서드들을 구현해야 한다. 컴포즈에서는 단순히 Layout 컴포저블을 사용하고 한가지 함수를 작성하면 된다.

커스텀 레이아웃을 어떻게 생성하는지 살펴보기 전, 컴포즈 레이아웃 원칙에 대해 좀더 알 필요가 있다.

컴포즈 레이아웃 원칙

몇몇 컴포저블 함수는 UI트리에 추가 하는 명령이 호출될 때 이를 화면상에 렌더링 하기 위해 UI조각을 발행한다. 각 발행(또는 요소)는 하나의 상위 요소와 잠재적으로 많은 하위 요소들을 갖는다. 또한, 상위 컴포저블내에서 (x,y) 같은 포지션 및 width, height 같은 사이즈를 갖는다.

각 UI요소들은 제약조건을 충족하는 사이즈를 측정한다. 제약조건들은 각 UI요소의 width 및 height 에 대한 최소치와 최대치를 제한한다. 만약 각 UI요소가 하위 요소들을 갖는다면, 자기 자신의 사이즈를 결정하기 위해 각 하위 요소에 대한 측정도 해야할 수 있다. 일단 사이즈가 결정되고 나면, 자신을 기준으로 하위 요소들을 배치할 수 있게 된다. 이 부분은 커스텀 레이아웃을 만들 때 더 알아보도록 하자.

Compose UI는 다중 패스 측정을 허용하지 않는다. 이는 레이아웃 요소가 다른 측정 구성을 시도하기 위해 하위 요소를 두 번 이상 측정하지 않음을 의미 한다. 단일 패스 측정은 성능에 좋고, 컴포즈가 효과적으로 깊은 UI 트리를 처리할 수 있게 한다. 만약 레이아웃 요소가 하위 요소들을 두번 측정하고, 그 하위 요소의 하위 요소를 또 두번 측정하고, 계속 그러다보면 전체 UI를 전개하기 위한 단일 시도는 많은 작업을 수행해야 하기 때문에 앱 성능을 계속 유지하기가 어렵다. 그러나 때로는 단일 하위 요소 측정이 알려 주는 것 외에 추가적인 정보가 필요한 경우가 있다. 이러한 경우를 위한 방법이 있으므로 나중에 살펴보자.

레이아웃 Modifier 사용하기

Modifier의 layout을 사용하여 수동으로 사이즈를 측정하고 요소의 위치를 제어할 수 있다. 일반적으로 Modifier의 layout을 사용하는 공통적인 구조는 다음과 같다.

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

layout을 사용해서 두가지 람다 매개변수를 얻을 수 있다.

  • measurable : 하위 요소가 측정되고 배치된다.
  • constraints : 하위 요소의 너비, 높이 최소 및 최대치

Text를 스크린상에 보여주고 상단으로부터 텍스트의 첫번째 줄 베이스라인의 거리를 제어하고 싶다고 가정해보자. 이걸 하려면, Modifier의 layout을 사용해서 수동으로 컴포저블을 화면상에 배치해야 한다. 다음 나오는 그림을 보면 원하는 동작을 확인할 수 있다. 상단으로부터 첫번째 베이스라인까지 24dp의 간격을 보여준다.

firstBaselineToTop 이라는 Modifier를 먼저 만들어보자.

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

첫번째 해야할 일은 컴포저블을 측정하는 것이다. 컴포즈 레이아웃 원칙에서 언급했듯이, 하위 요소들을 한번만 측정할 수 있다.

measurable.measure(constraints)를 호출하여 컴포저블을 측정하자. measure(constraints)를 호출할 때, 주어진 람다 매개변수 constraints(제약조건)을 사용하거나 직접 constraints를 생성하여 사용할 수 있다. Measurable의 measure() 함수 호출에 대한 결과로 Placeable를 반환한다. Placeable은 placeRelative(x, y)를 호출하여 위치를 지정할 수 있다. 이건 나중에 살펴보자.

우리가 만드려고 하는 커스텀 레이아웃의 경우, 주어진 제약조건을 사용하여 측정하자

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

        ...
    }
)

이제 컴포저블의 사이즈가 측정되었고, 람다를 사용하여 콘텐츠를 배치하는 layout(width, height)메서드를 호출하여 컴포저블 사이즈를 지정하자.

아래의 코드를 보면, 컴포저블의 너비는 측정된 컴포저블의 너비(placeable.width)가 되고, 상단 기준선 높이에서 첫 번째 베이스라인까지 뺀 값이 컴포저블의 높이가 된다. (height = placeable.height +placeableY)

Note : 아래 예제를 보면 layout내에 layout이 포함된다. 두 layout이 다르므로 혼동하지 말자.

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

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

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

이제 placeable.placeRelative(x, y)를 호출하여 해당 컴포저블을 스크린상에 배치할 수 있다. placeRelative를 호출하지 않는다면, 해당 컴포저블은 보이지 않을 것이다. placeRelative는 현재 배치가능한 layoutDirection을 기준으로 포지션을 자동으로 조정하게 된다.

Warning: 커스텀 Layout 또는 LayoutModifier를 생성할 때, 안드로이드 스튜디오는 layout 함수가 호출될 때까지 경고를 나타낸다.

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

        // 여백이 있는 컴포저블의 높이 - 첫번째 베이스라인
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            // 컴포저블이 자리잡을 위치
            placeable.placeRelative(0, placeableY)
        }
    }
)

기대한대로 동작하는지 확인하기 위해, Text상의 Modifier를 다음과 같이 설정하자.

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

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

미리보기로 보면 다음과 같다.

레이아웃 컴포저블 사용하기

단일 컴포저블이 화면상에 어떻게 사이즈를 측정하고 전개되는지 제어하는 대신, 컴포저블들이 포함된 그룹에 대한 필요성을 갖게 된다. 이걸 구현하려면, Layout 컴포저블을 사용하여 수동으로 측정하고 레이아웃내의 하위 요소들을 배치한다. 일반적으로 Layout을 사용하는 컴포저블의 공통 구조는 다음과 같다:

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // 커스텀 layout 속성
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 주어진 제약조건들로 하위 요소들을 측정하고, 배치한다.
    }
}

CustomLayout에 요구되는 최소한의 매개변수는 modifier와 content다. 이런 매개변수들이 Layout에 전달된다. (MeasurePolicy 타입인) Layout의 후행 람다식 내에서, layout Modifier를 얻은 것과 동일한 람다 매개변수를 얻는다.

실제 Layout이 작동하는 모습을 보여주고, 해당 API를 이해하기 위해 Layout을 사용하여 매우 기초적인 Column을 구현해보도록 하자. 나중에 Layout 컴포저블의 유연성을 보여주기 위해 더 복잡한 것도 만들 예정이다.

기본적인 Column 구현하기

우리가 만들 커스텀 Column 구현은 수직적으로 아이템들을 전개한다. 또한, 단순성을 위해, 상위 컴포저블내에서 우리 레이아웃은 상위 컴포저블내에서 가능한 많은 공간을 차지하도록 한다.

새로운 컴포저블을 만들고 MyOwnColumn이라 이름을 짓는다. 그리고 Layout 컴포저블 공통 구조를 추가한다.

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 주어진 제약조건들로 하위 요소들을 측정하고, 배치한다.
    }
}

이전과 같이, 처음으로 해야 할 일은 한번만 측정되는 하위 요소들에 대한 측정을 하는 것이다. 레이아웃 Modifier가 작동하는 방식과 유사하게도, 람다 매개변수인 measurable로 measurable.measure(constraints)를 호출하여 모든 content를 얻을 수 있다.

이러한 유즈케이스 때문에 하위 View들을 더이상 제한하지 않는다. 하위 요소들을 측정할 때, 너비 및 최대 높이를 신경써서 각 행(row)이 올바르게 스크린상에 배치 될 수 있는지 알아야 한다.

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // 하위 뷰들을 제한하지 말고, 주어진 constraints로 측정하자.
        // 측정된 하위 요소 목록들
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }
    }
}

이 로직내에서 지금 우리는 측정된 하위 요소 목록을 갖게 된다. 화면상에 이것들을 배치하기 전에 우리가 만든 Column 사이즈를 계산할 필요가 있다. 상위 컴포저블만큼 크게 만들기 위해, 상위 컴포저블이 전달한 constraints를 그대로 사용한다. 다음 나오는 코드와 같이 layout(너비, 높이) 메서드를 호출하여 우리가 만들 Column의 크기를 지정한다. layout 메서드는 하위 요소 배치에 사용되는 람다도 제공한다.

@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) {
            // 하위요소 배치
        }
    }
}

마침내 placeable.placeRelative(x, y)를 호출하여 하위 요소들을 스크린상에 배치한다. 하위요소들을 수직적으로 배치하기 위해, 배치한 하위요소들의 y 좌표값을 추적하도록 한다. MyOwnColumn의 최종코드는 다음과 같다

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 하위 View들을 제한하지 말고, 주어진 constraint로 측정
        // 측정된 하위 요소들
        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
            }
        }
    }
}

실전 MyOwnColumn

BodyContent 컴포저블 내에 MyOwnColumn을 삽입하여 화면상에 이를 나타내도록 해보자. BodyContent내에 내용을 교체한 코드는 다음과 같다.

@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!")
    }
}

미리보기로 보면 이렇게 나온다.

카테고리: Compose

0개의 댓글

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.