블로그에 비교적 자세히 채용 진행과정을 작성해서 그런지 검색 알고리즘을 타고 들어오시는 분들이 많다. 사실 합격했을 땐 나정도면 꽤 괜찮게 준비된 사람이라고 생각했으나 막상 부서 들어와서 사수, 직원들과 일해보니 그 분들과 비교하면 그저 코드몽키였다는 인상을 받는다.

이런 생각들을 하던 와중 블로그에 작성한 글들을 보고 외부강연이나 수기 공유 등의 오퍼가 종종 들어온다. 종종이란 표현도 과분하게 생각보다 자주 받는다. 하지만 이제껏 단 한번도 수락한 적은 없다. '내가 과연 회사 이름 걸고 나와서 강연을 진행할만한 인재인가'라는 질문에 아직은 "그렇다"라고 자신있게 답을 내릴 수 없기 때문이다. 당장 매일 소통하며 코드를 함께 작성하는 옆의 직원으로부터 압도적인 실력차를 경험하고 있는데 무슨 자신감으로 얼굴을 걸 수 있겠는가. 물론 그 분은 시니어고 나는 주니어니까 기준이 좀 다르다만 외부활동의 특성 상어디 절벽에 숨겨져 있는 무공비급 공개하듯이 광고를 할텐데 그 정도의 수치심을 견뎌낼만큼 뻔뻔하질 못해서 생긴 고민이지 싶다.

나는 굉장히 속성으로 1년 반 정도 준비하고 직군을 전환해서 그런지 남들보다 지식의 수준이 그리 깊진 못하다. 새로운 지식을 잘 정리하고 학습할 수 있다는 점만 보여줌으로써 합격했는데 과연 어느 정도의 준비를 갖춰야 당당히 얼굴을 걸고 외부활동을 할 수 있을지 궁금하다. 세상엔 실력자가 너무 많아 매일매일 강제로 겸손을 주입당하는 중이다. 사람들이 좀 덜 노력했으면 좋겠는데...

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. Compose가 레이아웃 렌더링 시 크기 측정을 단 한번만 수행하는데 필요에 의해 미리 1회 이상 측정해야 하는 경우를 위한 측정 도구를 실습합니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

  1. 내장 기능(Intrinsic Measure)

Jetpack Compose를 사용하여 개발함에 있어 항상 유념해야 하는 점은 하위 요소(Child Element)를 한 번만 측정할 수 있다는 점이다. 하위 요소를 두 번 측정하도록 작성하면 Runtime Exception이 발생한다. 다만, 하위 요소를 측정하기 앞서 하위 요소에 대한 정보가 필요한 경우도 있다.

Intrinsic 기능을 사용하면 하위 요소가 실제로 measure되기 전에 하위 요소를 쿼리할 수 있다.

  • (min|max)InstrinsicWidth : width가 주어졌을 때 컴포저블을 그리기 위해 필요한 최소/최대 width 반환
  • (min|max)IntrinsicHeight : height가 주어졌을 때 컴포저블을 그리기 위해 필요한 최소/최대 height 반환

만약 width가 무한대인 Text 컴포저블에 대해 minIntrinsicHeight를 요청하면 single line으로 Text를 배치했을 때 필요한 height를 반환한다.

Intrinsic Measure 실제 사례

다음 그림처럼 화면에 구분선으로 구분된 두 개의 Text 컴포저블을 만들어보자.

Codelab_Jetpack_Compose_layout_034

Text 컴포저블을 클릭할 수 있게 하고자 함이 아니니 weight를 동일하게 부여하고 중앙에 구분선으로 작용할 Divider 컴포저블을 선언한다. 구분선의 높이는 텍스트의 높이만큼, 너비는 1.dp를 부여한다.

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),

            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    Surface {
        TwoTexts(text1 = "Hi", text2 = "there")
    }
}

그러나 preview로 본 화면은 의도대로 동작하지 않는다. 구분선이 화면 전체 높이를 차지하여 비정상적으로 길어졌다.

Codelab_Jetpack_Compose_layout_035

Row 컴포저블이 각 하위 요소들을 측정하기 때문에 Text의 높이를 사용하여 Divider의 높이에 제약을 설정하지 못했다. 따라서, 텍스트의 높이에 맞추려고 부여했던 Divider의 Modifier에 입력한 fillMaxHeight로 인해 전체 화면 높이만큼 차지하였다. 의도대로 구현을 위해 Row 컴포저블의 Modifier에 height(Intrinsic.Min)을 부여하여 해결할 수 있다.

height(Intrinsic.Min)은 하위 요소의 크기를 결정할 때 minIntrinsicHeight를 요청한다. 추가로, 하위 요소들이 하위 요소를 갖는다면 재귀적으로 요청한다. 그리고 하위 요소들이 반환한 minIntrinsicHeight 값들 중 가장 큰 값이 Row 컴포저블의 minIntrinsicHeight 값이 된다.

Divider 컴포저블은 별도의 컨텐츠가 없어 Row 컴포저블의 하위 요소들인 Text 컴포저블들의 minIntrinsicHeight 중 큰 값이 Row의 minIntrinsicHeight가 된다. 따라서, Divider는 Row의 height에 대해 fillMaxHeight하기로 했으므로 Row 컴포저블로부터 전달받은 height 값만큼만 늘어나게 된다.

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),
            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

Codelab_Jetpack_Compose_layout_036

Intrinsic Measure Customizing

대부분의 경우 기본적으로 제공되는 기능만으로도 충분하다. 그러나, Intrinsic Measure 방식을 목적에 맞게 커스터마이징하고자 하는 경우가 있을 수 있다. 이에 대해 Custom Layout 정의 시 MeasurePolicy 인터페이스를 구현하거나 Custom Modifier 작성 시 Density 인터페이스를 구현하는 경우로 나눌 수 있겠다.

  1. Custom Layout 정의 시 MeasurePolicy 구현
@Composable
fun MyCustomComposable(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    return object : MeasurePolicy {
        override fun MeasureScope.measure(
            measurables: List<Measurable>,
            constraints: Constraints
        ): MeasureResult {
            // Measure and layout here
        }

        override fun IntrinsicMeasureScope.minIntrinsicWidth(
            measurables: List<IntrinsicMeasurable>,
            height: Int
        ) = {
            // Logic here
        }

        // Other intrinsics related methods have a default value,
        // you can override only the methods that you need.
        // Ex. (min|max)Intrinsic(Width|Height)
    }
}

주어진 레이아웃이 아니라 특정 목적에 맞게끔 레이아웃을 커스터마이징하고자 할 때, Intrinsic Measure 방식도 수정하고 싶다면 MeasurePolicy 인터페이스를 override하여 재정의할 수 있다. 예를 들자면, (min|max)Intrinsic(Width|Height) 함수들을 overriding할 수 있다.

  1. custom Modifier 정의 시 LayoutModifier 인터페이스 구현
fun Modifier.myCustomModifier(/* ... */) = this.then(object : LayoutModifier {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        // Measure and layout here
    }

    override fun IntrinsicMeasureScope.minIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ): Int = {
        // Logic here
    }

    // Other intrinsics related methods have a default value,
    // you can override only the methods that you need.
})

Modifier 인터페이스 커스터마이징 시에도 Density.(min|max)Intrinsic(Width|Height)Of 함수를 override하여 Intrinsic Measure 방식을 커스터마이징 할 수 있다고 설명한다. 그러나 예제 코드에서는 Density 타입을 발견할 수가 없는데 IntrinsicMeasureScopeDensity 타입을 상속받기에 Density.(min|max)Intrinsic(Width|Height)Of 함수를 override한다고 설명을 첨부한 듯하다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #10 내장 기능

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 해당 챕터에서 다루는 ConstraintLayout은 XML 기반 뷰 정의할 때 많이 쓰던 ConstraintLayout을 Compose 버전으로 바꿔서 도입된 라이브러리입니다. 따라서, 기존 뷰 정의 시 사용했던 레이아웃과 유사한 점이 있습니다. 컴포저블 간 Constraint를 적용하고 가이드라인 등 Helper 클래스를 사용하여 컴포저블 배치를 다른 컴포저블에 의존(연결)하게끔 정의합니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

ConstraintLayout

Compose 도입으로 UI 트리를 사용해 화면을 구성함에 따라 공식적으로 ConstraintLayout은 가급적 권장하고 있지 않다. 오히려 복잡한 정렬이 필요한 경우 하위 요소들을 제어할 수 있는 커스텀 레이아웃 구성함이 바람직하다.

내장 레이아웃이 아니므로 별도의 라이브러리 import가 필요하다.

// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"

ConstraintLayout 사용을 위해 기억해야 할 점들이 있다.

  • ConstraintLayout의 각 컴포저블에는 참조가 연결되어야 하며 참조는 createRefs(), createRef()를 사용하여 생성한다.
  • Constraint는 constraintAs Modifier를 사용하여 연결한다. 해당 Modifier는 참조를 매개변수로 사용하며 본문 람다에 제약조건을 지정할 수 있다.
  • 제약 조건은 linkTo 혹은 다른 함수 등을 사용하여 지정한다.
  • parent는 ConstraintLayout 컴포저블 자체를 의미한다.
@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {

        // 컴포저블에 대해 레퍼런스를 생성한다.
        val (button, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            // Button 컴포저블에 "button" 참조를 할당한다.
            // ConstraintLayout의 top 부분에 연결한다.
            modifier = Modifier.constrainAs(button) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button")
        }

        // Text 컴포저블에 text 참조를 할당한다.
        // Button 컴포저블 botton에 연결한다.
        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
        })
    }
}

@Preview
@Composable
fun ConstraintLayoutContentPreview() {
    ConstraintLayoutContent()
}

Button과 Text 컴포저블을 선언하고 참조를 생성한 뒤 할당하였다. 각 컴포저블에 대해 어디에 연결되어야 하는지 linkTo 함수를 사용하여 연결하였다. margin을 부여하여 간격도 띄울 수 있다.

Codelab_Jetpack_Compose_layout_029

만약 버튼 하단의 텍스트를 레이아웃의 정중앙에 오도록 하려면 start와 end를 parent의 가장자리로 설정하는 centerHorizontallyTo 함수를 사용하면 된다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        ... // 참조 생성과 버튼은 이전과 동일

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
            // ConstraintLayout의 수평 중앙으로 오게끔 설정
            centerHorizontallyTo(parent)
        })
    }
}

마치 뷰 기반 xml에서 ConstraitnLayout을 사용했을 때와 활용법이 비슷하다. 다만, UI 요소끼리 연결하는 방식이 좀 더 programmatic하다.

Codelab_Jetpack_Compose_layout_030

Helper

ConstraintLayout의 더 나은 활용을 위해 사용하는 가이드라인, 배리어, 체인 등을 지원한다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        val (button1, button2, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button1) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button 1")
        }

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button1.bottom, margin = 16.dp)
            centerAround(button1.end)
        })
        // 배리어를 생성함
        val barrier = createEndBarrier(button1, text)
        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button2) {
                top.linkTo(parent.top, margin = 16.dp)
                start.linkTo(barrier)
            }
        ) {
            Text("Button 2")
        }
    }
}

버튼 두 개를 선언하여 상단에 링크를 연결한다. 텍스트를 선언하여 좌측 버튼과 연결을 수행하되 중앙점을 좌측 버튼의 끝부분에 위치하도록 설정한다. 이후 텍스트와 버튼을 묶어 배리어를 생성한 뒤 우측 버튼의 시작점을 배리어로 설정하면 아래와 같이 출력된다.

Codelab_Jetpack_Compose_layout_031

참고할 점으로 배리어 등 다른 Helper들은 constraintAs 함수 내부에서 선언할 수 없으며 ConstraintLayout 본문에서만 선언할 수 있다. linkTo 함수를 사용하여 마치 레이아웃에 연결하는 것처럼 Helper와 컴포저블을 연결할 수 있다.

측정 기준 커스텀

컴포저블에 할당된 텍스트 길이가 지나치게 긴 경우 화면 밖으로 빠져나와 보이지 않는 경우가 있다. 이 문제를 해결하기 위해 콘텐츠 출력 범위를 래핑하는 데 필요한 크기를 조정하도록 기능을 제공한다.

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(fraction = 0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(start = guideline, end = parent.end)
            }
        )
    }
}

@Preview
@Composable
fun LargeConstraintLayoutPreview() {
    LayoutsCodelabTheme {
        LargeConstraintLayout()
    }
}

Codelab_Jetpack_Compose_layout_032

위 그림처럼 화면의 절반을 분할하는 가이드라인을 생성한 뒤 가이드라인 우측부터 출력하도록 작성하였다. 그 결과, 텍스트가 너무 길어 화면 밖으로 나가버렸다. 화면 밖으로 출력될 텍스트들을 줄바꿈하여 화면 안으로 집어넣고자 한다. 해당 동작을 위해 Text 컴포저블의 Modifier 중 width 동작을 다음과 같이 변경하자.

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(guideline, parent.end)
                width = Dimension.preferredWrapContent
            }
        )
    }
}

Codelab_Jetpack_Compose_layout_033

Dimension을 사용하여 그림과 같이 줄바꿈을 수행하였다. Dimension 동작에 대해 정리해보자.

  • preferredWrapContent : 레이아웃이 해당 레이아웃 측정 기준의 constraint를 적용하는 wrap 컨텐츠라고 설명한다. 즉, 레이아웃을 내부 공간에 맞춰서 wrap content로 설정함을 의미한다.
  • wrapContent : 제약 조건(constriant)에서 허용하지 않더라도 내부 공간과는 무관하게 컨텐츠를 감쌀 수 있는만큼의 크기를 갖는다.
  • fillToConstraints : constraint에 선언된 값만큼을 채운다.
  • preferredValue : 레이아웃이 constraint 내부 공간에서 선언된 값(dp)만큼의 크기를 갖는다.
  • value : 레이아웃에 constraint와는 상관없이 고정 값(dp)을 적용한다.

특정 Dimension 동작은 강제 적용될 수 있다.

width = Dimension.preferredWrapContent.atLeast(100.dp)

분리된(Decoupled) API

앞에서는 ContraintLayout 내부의 Composable들에 대해 각기 다른 컴포저블과의 관계를 직접 자신들의 Modifier로 정의하였다. 좀 더 나아가서 컴포저블에 참조 정도만 입력하고 참조의 생성 및 constraint 설정은 다른 곳에서 이루어지게끔 작성할 수 있다.

@Composable
fun DecoupledConstraintLayout() {
    BoxWithConstraints {
        val constraints = if (maxWidth < maxHeight) {
            decoupledConstraints(margin = 16.dp) // Portrait constraints
        } else {
            decoupledConstraints(margin = 32.dp) // Landscape constraints
        }

        ConstraintLayout(constraints) {
            Button(
                onClick = { /* Do something */ },
                modifier = Modifier.layoutId("button")
            ) {
                Text("Button")
            }

            Text("Text", Modifier.layoutId("text"))
        }
    }
}

private fun decoupledConstraints(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")

        constrain(button) {
            top.linkTo(parent.top, margin= margin)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
        }
    }
}

DecoupledConstraintLayout 컴포저블 함수에서 decoupledConstraints함수를 사용하여 레퍼런스를 생성하고 각 레퍼런스에 할당될 constraint들을 정의한다. 이후 ConstraintLayout는 앞서 생성했던 constraint를 매개변수로 받고 내부에 정의한 컴포저블에 레퍼런스를 할당하여 컴포저블에 constraint가 적용될 수 있도록 한다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #10 제약 조건 레이아웃

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 레이아웃 컴포저블을 사용하여 좀 더 고도화된 커스텀 레이아웃을 정의합니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

고도화된 커스텀 레이아웃 - StaggeredGrid

Layout 컴포저블을 사용하여 기본적인 컴포저블을 재현해봤다. 좀 더 발전시켜서 복잡한 커스텀 레이아웃을 작성해보자. 코드랩에서 제시하는 커스텀 레이아웃은 Material Design 예제 프로젝트 - Owl의 지그재그형 그리드다.

Codelab_Jetpack_Compose_layout_026

가운데 배치된 지그재그형 그리드는 통상적인 Column과 Row로 구현하기에는 조금 번잡한 면이 있다. 받아오는 데이터 구조가 Column 단위로 쪼개서 Row로 나열할 수 있도록 구성되어 있으면 가능하겠으나 가급적 한 번에 모든 구획을 채워지게끔 만들고 싶다.

커스텀 레이아웃을 정의하면 데이터 구조에 대해 고민할 필요 없이 모든 항목의 높이를 측정하고 제한하여 배치할 수 있다. 재사용성을 위해 row는 커스텀할 수 있도록 매개변수로 전달하자.

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 주어진 제약조건에 따라 자식 요소를 측정하고 배치
    }
}

커스텀 레이아웃이지만 앞서 작성했듯이 Layout 컴포저블 선언 - 자식 요소 측정 - 자식 요소 배치의 구조를 띌 것이다.

이제껏 그래왔듯이, 추가적인 제약조건 정의는 하지 않고 하위 요소는 한 번만 측정할 수 있음을 유념하자.

Layout(
    modifier = modifier,
    content = content
) { measurables, constraints ->

    // 각 행의 너비를 추적 - 초기값은 0
    val rowWidths = IntArray(rows) { 0 }

    // 각 열의 최대 높이를 추적 - 초기값은 0
    val rowHeights = IntArray(rows) { 0 }

    // 자식 요소에 추가 제약 없이 주어진 제약조건대로 측정
    // 측정된 자식들이 담긴 리스트 반환
    val placeables = measurables.mapIndexed { index, measurable ->

        // 각 자식 요소 측정
        val placeable = measurable.measure(constraints)

        // 각 행에 대해 너비와 최대 높이 추적
        // item이 배치될 행 좌표를 index % 최대 행 개수로 계산하여 균등하게 배분
        val row = index % rows
        // 배치 후 너비 반영
        rowWidths[row] += placeable.width
        // 배치 후 높이 반영(현재 높이와 배치한 아이템의 높이 비교하여 최대값 반영)
        rowHeights[row] = Math.max(rowHeights[row], placeable.height)
        // 자식 요소 배치
        placeable
    }
    ...
}

위 코드에서 주석 처리한 설명처럼 각 행의 너비와 높이를 추적하여 반영한다.

하위 요소의 배치 로직을 작성하였으므로 화면에 배치하기 전 전체 그리드의 너비와 높이를 결정해야 한다.

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // 그리드의 너비는 가장 넓은 너비의 행과 동일하다.
    val width = rowWidths.maxOrNull()
        ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

    // 그리드의 높이는 각 행의 가장 기다란 요소의 높이들을 더한 값이다.
    // 높이 제약 조건으로 강제하였음
    val height = rowHeights.sumOf { it }
        .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

    // 각 행의 Y값은 이전 행의 높이 누적값이다.
    val rowY = IntArray(rows) { 0 }
    for (i in 1 until rows) {
        rowY[i] = rowY[i-1] + rowHeights[i-1]
    }

    ...
}

하위 요소의 배치 로직에 따라 각 행의 너비와 최대 높이는 확보하였으므로 해당 값들을 기반으로 그리드 레이아웃의 전체 너비와 높이를 결정한다.

마지막으로 하위 요소들을 placeable.placeRelative(x, y)를 호출하여 화면에 하위 요소를 배치한다.

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // 부모 레이아웃의 사이즈 지정
    layout(width, height) {
        // row마다 아이템이 배치될 X좌표
        val rowX = IntArray(rows) { 0 }

        placeables.forEachIndexed { index, placeable ->
            val row = index % rows
            placeable.placeRelative(
                x = rowX[row],
                y = rowY[row]
            )
            rowX[row] += placeable.width
        }
    }
}

rowX 변수는 하위 아이템을 배치하면서 변경되기에 layout 함수 scope에 선언되었다. 이로써 rowX, rowY 변수를 사용하여 x, y 변수를 추적한다.

StaggeredGrid 아이템 정의

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier.size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

앞서 정의한 커스텀 레이아웃인 StaggeredGrid 레이아웃에 넣을 아이템 레이아웃을 정의해야 한다. 좌측에 placeholder를 정의하고 우측에 파라미터로 받은 텍스트를 표시한다. 코드대로 작성한 경우 미리보기는 다음과 같다.

Codelab_Jetpack_Compose_layout_027

StaggeredGrid의 전체 코드는 다음과 같다.

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 각 행의 너비를 추적 - 초기값은 0
        val rowWidths = IntArray(rows) { 0 }

        // 각 열의 최대 높이를 추적 - 초기값은 0
        val rowHeights = IntArray(rows) { 0 }

        // 자식 요소에 추가 제약 없이 주어진 제약조건대로 측정
        // 측정된 자식들이 담긴 리스트 반환
        val placeables = measurables.mapIndexed { index, measurable ->

            // 각 자식 요소 측정
            val placeable = measurable.measure(constraints)

            // 각 행에 대해 너비와 최대 높이 추적
            // item이 배치될 행 좌표를 index % 최대 행 개수로 계산하여 균등하게 배분
            val row = index % rows
            // 배치 후 너비 반영
            rowWidths[row] += placeable.width
            // 배치 후 높이 반영(현재 높이와 배치한 아이템의 높이 비교하여 최대값 반영)
            rowHeights[row] = Math.max(rowHeights[row], placeable.height)
            // 자식 요소 배치
            placeable
        }
        // 그리드의 너비는 가장 넓은 너비의 행과 동일하다.
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

        // 그리드의 높이는 각 행의 가장 기다란 요소의 높이들을 더한 값이다.
        // 높이 제약 조건으로 강제하였음
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))


        // 각 행의 Y값은 이전 행의 높이 누적값이다.
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i-1] + rowHeights[i-1]
        }

        // 부모 레이아웃의 사이즈 지정
        layout(width, height) {

            // row마다 아이템이 배치될 X좌표
            val rowX = IntArray(rows) { 0 }

            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    x = rowX[row],
                    y = rowY[row]
                )
                rowX[row] += placeable.width
            }
        }
    }
}

StaggeredGrid에 들어갈 텍스트 아이템들을 하드코딩된 배열로 작성하고 실제로 호출해보자.

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Preview
@Composable
fun LayoutCodelabPreview() {
    LayoutsCodelab()
}

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent()
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

Codelab_Jetpack_Compose_layout_028

허나 이렇게 사용하면 preview에서 볼 수 있듯이 화면 너비를 넘어가는 경우 가로로 스크롤할 수 없다. 가로 스크롤 속성을 부여할 수 있는 Row로 래핑하고 BodyContent 컴포저블 함수의 Modifier 파라미터를 StaggeredGrid가 아니라 Row 컴포저블에 전달하여 스크롤 가능하게 구현할 수 있다.

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier.horizontalScroll(rememberScrollState())) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

참고자료

  1. Google Codelab - Jetpack Compose Layout #8 복잡한 맞춤 레이아웃

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 레이아웃 Modifier를 사용하여 Modifier 확장함수를 어떻게 정의하는 지 알아봅니다. 레이아웃 컴포저블을 사용하여 Column을 직접 구현해봅니다.. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

커스텀 레이아웃 만들기

Jetpack Compose는 Column, Row, Box 등의 라이브러리 내장 컴포저블들을 결합하여 커스텀 컴포저블을 구성하여 재사용성을 높인다. 그러나 하위 컴포넌트를 수동으로 측정하고 배치해야 한다면 앱만의 고유한 동작 로직을 정의해야 할 수도 있다. 이 경우 Layout 컴포저블을 사용한다. Column, Row 등의 모든 상위 수준 레이아웃은 Layout 컴포저블을 사용하여 빌드된 것을 확인할 수 있다.

Compose의 레이아웃 원칙

컴포저블 함수 중에는 호출될 때 UI 요소를 리턴하여 화면에 렌더링될 UI 트리에 추가한다. 각 요소에는 상위 요소가 하나 있고 하위 요소가 여러 개(혹은 하나) 존재한다. 추가로 상위 요소 내의 위치(x, y)와 크기(width, height)도 포함된다.

UI 요소는 자체적으로 측정되며 제약조건을 충족해야 한다고 설명한다. 제약 조건은 최소/최대 width, height이다. 만약 하위 UI 요소가 존재한다면 각 하위 요소들을 측정하여 자체 크기를 파악한 뒤 리턴된 결과를 기준으로 자신의 조건에 맞춰 하위 요소를 배치할 수 있다.

가장 유념해야 할 점은 Compose UI는 다중 패스 측정을 허용하지 않는다는 점이다. 레이아웃 요소가 다른 측정 구성을 시도하기 위해 하위 요소를 두 번 이상 측정할 수 없다. 단일 패스 측정은 성능 측면에서 유리하기에 도입된 개념으로 Compose UI 트리의 효율적 처리에 목적을 둔다. 따라서, 추가 정보가 필요한 경우에는 별도의 방법을 사용해야 한다.

레이아웃 Modifier 사용

layout Modifier를 사용하여 요소를 측정하고 배치하는 과정을 수동으로 제어해보자. 일반적인 laout Modifier 구조는 다음과 같다.

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

layout Modifier 사용 시 람다 매개변수 measurable, constraints가 있다.

  • measurable : 측정하기 배치할 하위 요소
  • constraints : 하위 요소의 너비와 높이 최솟값과 최댓값

화면에 Text 컴포저블이 배치될 때 Text 컴포저블의 상단에서 기준선까지의 거리를 제어해보자. 해당 기능을 구현하기 위해선 layout Modifier를 사용하여 화면에 컴포저블을 수동으로 배치해야 한다. 상단에서 첫 번째 기준선까지의 거리가 24.dp로 설정되도록 작성하자.

Codelab_Jetpack_Compose_layout_023

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

Modifier의 확장함수로 firstBaselineToTop이라는 함수를 정의하고 파라미터로 Dp 값을 입력받도록 하였다.

이제 컴포저블을 측정해야 한다. 앞서 작성하였듯이 하위 요소는 원칙적으로 한 번만 측정할 수 있음을 기억하자. measurable.measure(constraints)를 호출하여 컴포저블을 측정하였다.

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

        ...
    }
)

measure(constraints)를 호출하여 컴포저블을 측정할 때 constraints 람다 매개변수로부터 사용할 수 있는 컴포저블의 제약 조건을 전달하거나 직접 만들 수 있다. measure()를 호출한 결과는 placeRelative(x, y)를 호출하여 컴포저블을 배치할 수 있는 Placeable이다. 해당 함수를 사용하여 컴포저블을 배치한다. 우선 위 코드에서 작성한 대로 별도의 측정 제한 없이 주어진 제약 조건만 넘겨준다.

측정한 뒤 baseline을 갖고 있는지 체크하여 변수에 저장한다.

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then (
    layout { measurable, constraint ->
        val placeable = measurable.measure(constraint)
        check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
        val firstBaseline = placeable[FirstBaseline]

        // 최상단이 0이므로 firstBaseline은 baseline까지의 길이를 값으로 가짐 = fontSize
        // placeableY는 padding값 - baseline까지의 길이
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        // 전체 출력할 높이는 하위 컴포저블의 높이 + padding값 - baseline까지의 길이
        // padding 값이 0이면 baseline과 하단까지의 간격만 출력
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            placeable.placeRelative(0, placeableY)
        }
    }
)

높이를 측정하는 코드가 있다. 우선 하위 컴포즈의 최상단 y값은 0이고 아래로 내려갈수록 증가하는 값임을 알아야 한다. 따라서 firstBaseline은 baseline까지의 높이로 여기서는 텍스트 사이즈와 같은 값이다. 그리고 firstBaselineToTop은 우리가 컴포저블의 최상단과 baseline 사이의 간격으로 지정할 값이므로 placeableY의 값은 padding 값 - 텍스트 사이즈가 되어 만약 텍스트 크기보다 매개변수 값이 더 작다면 글자가 잘린 채로 출력될 것임을 추측할 수 있다.

height 변수는 전체 컴포저블을 출력할 높이로 설정하였다. placeable.height는 하위 컴포저블의 높이이고 placeableY값을 더하면 결국 하위 컴포저블 높이 - 텍스트 사이즈가 되어 baseline과 컴포저블 하단까지의 빈 공간과 매개변수로 입력한 padding값에 해당한다.

마지막으로 layout 함수를 호출하여 width, height를 매개변수로 받고 placeable.placeRelative 함수를 호출하여 컴포저블을 배치한다. x값이 0이므로 좌측 끝에서부터 x값이 증가하는 방향으로 출력된다. y값은 placeableY기준으로 출력하므로 만약 매개변수가 텍스트 사이즈보다 작아 placeableY의 값이 음수라면 글자가 레이아웃 기준 음수높이에서부터 출력되어 상단부분이 가려져서 출력될 것이다.

@Preview
@Composable
fun FirstBaselineToTopPreview() {
    Text("fooooo", Modifier.firstBaselineToTop(24.dp))
}

@Preview
@Composable
fun TopPaddingPreview() {
    Text("baaaar", Modifier.padding(top=24.dp))
}

Codelab_Jetpack_Compose_layout_024

확장함수를 사용하여 간격을 부여한 경우와 Padding을 상단에 부여한 경우 조금 다르게 출력됨을 확인하였다.

레이아웃 컴포저블 사용

방금까지 layout Modifier를 사용하여 단일 컴포저블을 측정하고 화면에 배치되는 방식을 제어하는 커스텀 확장함수를 구현하였으니 이번에는 단일 컴포저블이 아니라 하위 요소들을 측정하여 배치하기 위해 컴포저블에서 Layout을 사용해보자. Layout을 사용하는 컴포저블의 일반적인 구조는 다음과 같다.

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

적어도 커스텀 레이아웃을 정의한다면 스타일을 지정할 modifier 매개변수와 어떤 하위 컴포저블을 담을 것인지 입력받는 content 매개변수는 필수적으로 선언해야 한다. 해당 매개변수들은 내부에 선언된 Layout 컴포저블로 전달된다. Layout 컴포저블 선언 후 뒤따라오는 후행 람다에서 layout Modifier를 사용했을 때와 같은 람다 매개변수가 주어진다. 조금 다른 점은 하위 요소가 여러 개일 수도 있으므로 변수 이름을 복수형으로 변경하였다.

해당 기본 구조를 토대로 Column을 구현해보자. 편의상 레이아웃은 최대한의 공간을 차지한다고 가정하자.(fillMax!)

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

앞서 layout Modifier에서도 그러하였듯이 하위 요소들을 측정해야 한다.

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 주어진 제약조건으로만 측정하되 추가적인 제약을 자식 뷰에 적용하지 않는다.
        // 측정된 자식들이 담긴 리스트를 받는다.
        val placeables = measurables.map { measurable ->
            // 각 자식들에 대해 측정 수행
            measurable.measure(constraints)
        }
    }
}

각 하위 요소별로 측정을 수행해야 하므로 람다 변수로 받은 measurables에 대해 map을 호출한 뒤 람다 매개변수로 자식 뷰를 측정할 수 있는 람다 매개변수를 선언하고 제약조건에 대해 자식뷰 측정을 수행한다. 이로써 측정 가능한 모든 content를 확인하였다.

각 요소들을 배치하기 위해 전체 레이아웃의 사이즈를 정의하자.

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 자식 요소 측정 - 앞서 작성했기에 생략
        ...

        // layout의 크기를 최대 사이즈로 설정(fillMax!)
        layout(constraints.maxWidth, constraints.maxHeight) {
            // 자식 요소 배치
        }
    }
}

하위 요소 배치를 수행하기 전 Column의 크기를 계산한다. 상위 요소만큼 크게 만들기로 했으므로 상위 요소로부터 전달된 제약조건 사이즈를 입력한다. 하위 요소 배치에 사용되는 Layout의 람다 함수가 제공하는 layout(width, height) 함수를 호출하여 자체적으로 Column의 크기를 지정한다.

Column 레이아웃의 사이즈를 지정했으니 하위 요소들을 어떻게 배치할 지 작성하자.

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        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
            }
        }
    }
}

Column은 상단으로부터 수직적으로 자식 요소를 배치하는 레이아웃이다. layout Modifier에서 다뤘듯이 (0, 0)은 우측 최상단임을 확인했었다. 따라서 최초 자식이 배치될 y좌표는 0좌표임을 알 수 있고 각 자식 요소가 배치될 때마다 이전 자식뷰의 하단에 배치되어야 하므로 자식 요소가 배치될 y좌표를 기록해야 한다. 우선 0값을 저장하는 변수를 선언한 뒤 palceable.placeRelative(x, y) 함수를 호출하여 자식 요소들을 배치한다. 자식 요소의 위치를 지정한 뒤 이후 자식 요소 배치를 위해 yPosition 변수에 배치한 자식 요소의 높이를 더해준다. 해당 로직을 통해 이후 자식 요소는 이전 자식 요소 하단에 배치될 수 있다.

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

작성한 코드대로 상하좌우 8dp씩 padding이 부여되었고 하위 컴포저블 요소로 부여한 4개의 Text 컴포저블이 차례대로 수직으로 배치되었다.

Codelab_Jetpack_Compose_layout_025

참고자료

  1. Google Codelab - Jetpack Compose Layout #7 맞춤 레이아웃 만들기

+ Recent posts