Android/Jetpack Compose

[Compose] Flexbox Layout를 구현해보자.

Heeg's 2023. 11. 4. 18:31
728x90

필자가 실무를 경험하면서 유용하게 사용했던 UI Component 중 Flexbox Layout이라는 것이 있다.

FlexBox Layout은 UI에서 아이템의 개수, 크기가 다를 때 디바이스 크기에 맞춰 한 라인에 보여줄 아이템의 개수를 정해주고 라인을 바꾸어 아이템을 보여주는 것이다.

이러한 특성을 띄고 있기 때문에 주로 해시태그 와 같은 UI를 그려줄 때 많이 사용한다.

 

 

이런식으로 말이다.

 

해시태그라고 하여 텍스트만 생각할 수 있지만 이미지 같은 것들도 많이 사용하곤 한다.

 


위와 같이 그려주기 위해서는 어떻게 해야 할까?

코드는 생각하지 않고, 단순하게 생각해 보면 다음과 같다.

 

  1. 추가할 아이템의 크기 (width, height)를 구한다.
  2. 아이템을 추가했을 때 그려줄 영역을 초과하는지 계산한다.
  3. 초과하지 않으면 그대로 그려준다.
  4. 영역을 초과하면 다음 라인으로 이동시킨 후 그려준다.

이 4가지를 순서대로 진행하면 간단하게 그려줄 수 있다.

 

그려줄 방법을 알았으니, 다음으로는 코드로 생각을 해보자.

 

위에서 선언했던 2~4번 순서를 위해서,

 

fun interface MeasurePolicy

 

다음과 같은 인터페이스를 사용하도록 한다.

 

해당 인터페이스의 주석을 확인해 보면 다음과 같은 설명이 있는 것을 볼 수 있다.

 

Defines the measure and layout behavior of a Layout. Layout and MeasurePolicy are the way Compose layouts (such as Box, Column, etc.) are built, and they can also be used to achieve custom layouts.

 

레이아웃을 측정하고, 커스텀 레이아웃을 만드는 데 사용할 수 있다고 한다.

 

즉, 해당 인터페이스의 내용을 구현할 때 1. 아이템의 크기를 구하고, 2. 그려줄 영역을 초과하는지 판단하여 그려주면 된다.

따라서, measurePolicy를 사용하기 위해 다음과 같이 Layout Composable 함수를 선언한다.

 

@Composable
fun FlexBoxLayout(
    modifier: Modifier = Modifier,
    ...
    content: @Composable () -> Unit,
) {
    // layout 정책을 커스텀하기 위해 measurePolicy를 선언하여 구현한다.
    val measurePolicy = flexBoxLayoutMeasurePolicy(...)

    Layout(
        measurePolicy = measurePolicy,
        content = content, // 커스텀한 measurePolicy 가 적용된 상태로 해당 content를 그린다.
        modifier = modifier
    )
}

 

measurePolicy를 구현하고, Layout 안에 넣어서 원하는 조건으로 UI를 그릴 수 있게 되는 것이다.

 

그렇다면 구현한 flexBoxLayoutMeasurePolicy 전체를 확인하고, 내부 코드를 해석해 보도록 하자.

 

fun flexBoxLayoutMeasurePolicy(...) =
    MeasurePolicy { measurables, constraints ->
        layout(constraints.maxWidth, constraints.maxHeight) {
            ...

            val placeables = measurables.map { measurable ->
                measurable.measure(constraints)
            }
            var yPosition = 0
            var xPosition = 0
            var itemHeightSize = 0
            placeables.forEach { placeable ->
                // 아이템을 추가했을 때 최대 너비를 넘어가면
                if (xPosition + placeable.width >
                    constraints.maxWidth
                ) {
                    xPosition = 0
                    yPosition += itemHeightSize // y 포지션 값을 더한다.
                    itemHeightSize = 0
                }

                // 해당 x,y 값에 아이템을 추가한다.
                placeable.placeRelative(
                    x = xPosition,
                    y = yPosition
                )

                // 아이템을 추가했으면, 아이템의 크기만큼 x 좌표를 옮긴다
                xPosition += placeable.width

                // 아이템의 크기가 추가할 아이템의 높이보다 작으면, 아이템의 크기로 설정해준다.
                if (itemHeightSize < placeable.height) {
                    itemHeightSize = placeable.height
                }
            }

                ...
        }
    }

 

중요한 부분은 주석으로 작성을 해두었다.

주석과 함께 코드를 읽어보면 우선 해당 정책사항이 어떤 것을 하고 싶은지 쉽게 파악할 수 있을 것이다.

 

그려줄 layout에서 포지션을 계산하여, 영역을 초과하면 위치를 바꿔주고, 초과하지 않으면 그대로 그려준다.

 

처음에 언급했던 순서의 2~4번 항목과 완전히 동일하다.

 

그럼 한 줄씩 코드를 분석해 보자.

 

MeasurePolicy { measurables, constraints ->

 

MeasurePolicy 인터페이스를 구현하는 부분이다.

원하는 규칙으로 Custom 하여 Layout을 그리기 위해서 사용하는 것으로, 

measurables는 그려줄(측정할) 아이템을 가져오는 부분이고,  constraints는 레이아웃 측정을 위한 불변의 조건을 의미한다.

 

layout(constraints.maxWidth, constraints.maxHeight) {

 

따라서, 다음 줄에서 사용하는 constraints.max Width, Height 부분은 이하 layout을 커스텀할 때 적용되는 불변의 조건으로, 지정한 레이아웃의 전체 너비와 높이가 되는 것이다.

 

val placeables = measurables.map { measurable ->
    measurable.measure(constraints)
}

 

measure을 통해 자식 컴포넌트를 측정할 수 있고, 결과적으로 placeables를 반환하게 된다.

다시 말해서, measurables을 통해 가져온 자식 컴포넌트 (그려줄 레이아웃)을 measure 함수를 통해 측정하여 placeables 객체로 반환해 준다.

 

placeables.forEach { placeable ->
    ...
}

 

가져온 placeables는 위의 과정을 통해 x, y 좌표를 구해 원하는대로 그려주면 되는 것이다.

 

placeables.forEach { placeable ->
    // 아이템을 추가했을 때 최대 너비를 넘어가면
    if (xPosition + placeable.width >
        constraints.maxWidth
    ) {
        xPosition = 0
        yPosition += itemHeightSize // y 포지션 값을 더한다.
        itemHeightSize = 0
    }

    // 해당 x,y 값에 아이템을 추가한다.
    placeable.placeRelative(
        x = xPosition,
        y = yPosition
    )

    // 아이템을 추가했으면, 아이템의 크기만큼 x 좌표를 옮긴다
    xPosition += placeable.width

    // 아이템의 크기가 추가할 아이템의 높이보다 작으면, 아이템의 크기로 설정해준다.
    if (itemHeightSize < placeable.height) {
        itemHeightSize = placeable.height
    }
}

 

forEach 안에서는 다음과 같은 순서로 x,y 좌표 값을 구하게 되는데,

가로로 아이템이 추가가 되고 너비를 넘어서면 다음 라인에 그려지기 때문에, 처음에는 그려줄 아이템이 너비를 초과하는지 확인한다.

 

x 위치에 아이템의 너비를 더했을 때 전체 너비를 넘어가면 x를 0으로, y를 아이템의 높이만큼 추가하여 다음 라인으로 넘기도록 한다.

너비를 초과하지 않는다면, 해당 x,y 위치에 아이템을 그려주고 x 위치를 아이템의 크기만큼 추가해 준다.

 

마지막에, 아이템의 높이를 비교하는 이유는 그려줄 아이템 (자식 컴포넌트)의 높이가 일정하지 않을 때, 가장 큰 높이를 가진 컴포넌트를 기준점으로 잡기 위해서이다.

 

이렇게 구현을 해주었으면, FlexBoxLayout을 선언하여 사용하기만 하면 된다.

 

...

Box(
    modifier = Modifier
        .wrapContentHeight()
        .fillMaxWidth()
        .background(color = Color.Blue)
) {
    FlexBoxLayout(
        modifier = Modifier
            .heightIn(min = minHeight.dp, max = contentHeight.value)
            .background(color = Color.Yellow),
        contentHeight = contentHeight,
        minHeight = minHeight,
    ) {
        for (item in itemList) {
            Row(modifier = Modifier.padding(vertical = 5.dp)) {
                Spacer(modifier = Modifier.width(widthMargin))
                Text(
                    modifier = Modifier
                        .height(20.dp),
                    text = "#$item"
                )
                Spacer(modifier = Modifier.width(widthMargin))
            }
        }
    }
}

...

 

여기서 필자는 heightIn을 사용하여 아이템이 없을 때 혹은 아이템의 크기가 작을 때 보여줄 최소의 높이를 지정해 주었다.

 

FlexBoxLayout의 매개변수 중 하나인 content에는 Flexable 하게 그려줄 Content. 즉, 그려줄 아이템인 자식 컴포넌트를 넣어주면 되는 것이다.

 

위에서 구현한 flexBoxLayoutMeasurePolicy 안에서 자식 컴포넌트는 Row(..) { ... } 아이템이 되는 것이고,

height는 약 30.dp, 너비는 들어가는 텍스트에 따라서 달라지는 아이템이 되는 것이다.


코드는 생각보다 짧고, 별 것 없는 것으로 보이는 데 사용되는 인터페이스와 함수들이 Layout의 코어 부분이기 때문에 이해하고 사용하는 것이 상당히 오래 걸렸다.

사실, 이 완벽하게 이해를 하진 못한 것 같고, 이 글을 작성하기 위해 더 디테일하게 찾아보고 알아보면서 공부를 했지만 아직까지 완벽하게 이해하지는 못한 것 같다.

 

MeasurePolicy를 통해 layout을 custom 하고, 거기에서 어떤 변수를 사용하여 설정하는 것인지는 알겠으나,

왜 특성 함수를 사용해야 하고, 그 반환 값을 가지고 어떤 작업이 가능한지. 이런 것들에 대한 디테일은 더 공부를 해야 할 것으로 보인다.

 

우선, 작업에 사용하기 위해서 작성해 둔 컴포넌트이기도 하고, 글을 작성하며 더 많은 이해를 하긴 했으니..

추후에 다시 사용하게 되면 조금 더 공부하고 파악한 후에 사용하면 되지 않을까 한다.


위에 코드를 작성하면서, 불필요한 부분 혹은 필자가 마음대로 커스텀한 부분은 제외하고 코어 로직만 작성했다 보니, 코드를 그대로 가져다 사용하면 정상적으로 빌드가 되지 않을 것이다.

 

따라서, 코드 전체는 ComposeSample Project에 코드를 올려두었으니 확인하길 바란다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample

Contribute to HeeGyeong/ComposeSample development by creating an account on GitHub.

github.com

 

728x90