Jetpack Compose의 레이아웃

1. 소개

Jetpack Compose 기본사항 Codelab에서는 Text와 같은 컴포저블과 화면에 항목을 가로와 세로로 각각 배치할 수 있는 Column, Row와 같은 유연한 레이아웃 컴포저블을 사용하고 화면 내에서 요소의 정렬을 구성하여 Compose로 간단한 UI를 빌드하는 방법을 알아봤습니다. 반면 항목을 세로나 가로로 표시하지 않으려는 경우 Box를 사용하면 항목을 앞뒤로 배치할 수 있습니다.

fbd450e8eab10338.png

이러한 표준 레이아웃 구성요소를 사용하여 다음과 같은 UI를 빌드할 수 있습니다.

d2c39f3c2416c321.png

@Composable
fun PhotographerProfile(photographer: Photographer) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(...)
        Column {
            Text(photographer.name)
            Text(photographer.lastSeenOnline, ...)
        }
    }
}

Compose의 재사용성과 구성 가능성 덕분에 구성 가능한 새 함수에서 올바른 수준의 추상화에 필요한 여러 부분을 결합하여 자체 컴포저블을 빌드할 수 있습니다.

이 Codelab에서는 Compose의 최상위 수준 UI 추상화인 머티리얼 디자인과 화면에서 요소를 측정하고 배치할 수 있는 Layout과 같은 하위 수준 컴포저블을 사용하는 방법을 알아봅니다.

머티리얼 디자인 기반 UI를 만들려는 경우 Compose에 내장된 머티리얼 구성요소 컴포저블을 사용할 수 있으며, 이 컴포저블 사용 방법은 Codelab에서 다룰 예정입니다. 머티리얼 디자인을 사용하지 않거나 머티리얼 디자인 사양에 없는 것을 빌드하려는 경우에는 맞춤 레이아웃을 만드는 방법도 알아봅니다.

학습할 내용

이 Codelab에서는 다음에 관해 알아봅니다.

  • 머티리얼 구성요소 컴포저블을 사용하는 방법
  • 수정자의 정의와 레이아웃에서 수정자를 사용하는 방법
  • 맞춤 레이아웃을 만드는 방법
  • 내장 기능이 필요할 수 있는 시점

기본 요건

필요한 항목

2. 새 Compose 프로젝트 시작

새 Compose 프로젝트를 시작하려면 Android 스튜디오 Bumblebee를 열고 아래와 같이 Start a new Android Studio project를 선택합니다.

ec53715fe31913e6.jpeg

위의 화면이 표시되지 않으면 File > New > New Project로 이동합니다.

새 프로젝트를 만들 때 사용할 수 있는 템플릿에서 Empty Compose Activity를 선택합니다.

a67ba73a4f06b7ac.png

Next를 클릭하고 평소대로 프로젝트를 구성합니다. API 수준 21 이상의 minimumSdkVersion을 선택해야 합니다. 이는 API Compose가 지원하는 최소 버전입니다.

Empty Compose Activity 템플릿을 선택하면 프로젝트에 다음 코드가 생성됩니다.

  • 프로젝트가 이미 Compose를 사용하도록 구성되어 있습니다.
  • AndroidManifest.xml 파일이 만들어집니다.
  • app/build.gradle(또는 build.gradle (Module: YourApplicationName.app)) 파일이 Compose 종속 항목을 가져와서 Android 스튜디오가 buildFeatures { compose true } 플래그로 Compose와 호환될 수 있도록 합니다.
android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
    }
}

dependencies {
    ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation 'androidx.activity:activity-compose:1.4.0'
    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    ...
}

Codelab 솔루션

GitHub에서 이 Codelab의 솔루션 코드를 다운로드할 수 있습니다.

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

또는 저장소를 ZIP 파일로 다운로드할 수도 있습니다.

솔루션 코드는 LayoutsCodelab 프로젝트에서 확인할 수 있습니다. 자신의 속도에 맞게 Codelab을 단계별로 진행하고 필요한 경우 솔루션을 확인하는 것이 좋습니다. Codelab을 진행하는 중에 프로젝트에 추가해야 하는 코드 스니펫이 제공됩니다.

3. 수정자

수정자를 사용하면 컴포저블을 장식할 수 있습니다. 동작과 모양을 변경하거나 접근성 라벨과 같은 정보를 추가하거나 사용자 입력을 처리할 수 있으며 상위 수준 상호작용(예: 클릭 가능 또는 스크롤 가능, 드래그 가능, 확대/축소 가능하게 설정하기)도 추가할 수 있습니다. 수정자는 일반 Kotlin 객체입니다. 변수에 할당하고 재사용할 수 있습니다. 여러 수정자를 차례로 체이닝하여 구성할 수도 있습니다.

소개 섹션에서 확인한 프로필 레이아웃을 구현해 보겠습니다.

d2c39f3c2416c321.png

MainActivity.kt를 열고 다음을 추가합니다.

@Composable
fun PhotographerCard() {
    Column {
        Text("Alfred Sisley", fontWeight = FontWeight.Bold)
        // LocalContentAlpha is defining opacity level of its children
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text("3 minutes ago", style = MaterialTheme.typography.body2)
        }
    }
}

@Preview
@Composable
fun PhotographerCardPreview() {
    LayoutsCodelabTheme {
        PhotographerCard()
    }
}

미리보기는 다음과 같습니다.

bf29f2c3f5d6a27.png

이제 사진이 로드되는 동안 자리표시자를 표시하는 것이 좋습니다. 이를 위해 원형과 자리표시자 색상을 지정하는 Surface를 사용할 수 있습니다. 크기를 지정하려면 size 수정자를 사용하면 됩니다.

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

84f2bb229d67987b.png

여기서 몇 가지 개선할 점이 있습니다.

  1. 자리표시자와 텍스트 사이를 분리해야 합니다.
  2. 텍스트가 세로로 가운데에 와야 합니다.

첫 번째의 경우 텍스트가 포함된 Column에서 Modifier.padding을 사용하여 컴포저블의 start에 공간을 추가해 이미지와 텍스트를 분리할 수 있습니다. 두 번째의 경우 일부 레이아웃에서는 텍스트와 그 레이아웃 특성에만 적용되는 수정자를 제공합니다. 예를 들어 Row의 컴포저블은 weight 또는 align과 같이 적절한 특정 수정자에 액세스(Row 콘텐츠의 RowScope 수신기에서)할 수 있습니다. 이 범위 지정은 유형 안전성을 제공하므로 다른 레이아웃에 적절하지 않은 수정자를 실수로 사용할 수 없습니다. 예를 들어 weightBox에 적절하지 않으므로 이는 컴파일 시간 오류로 방지됩니다.

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column(
            modifier = Modifier
                .padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        ) {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

미리보기는 다음과 같습니다.

1542fadc7f68feb2.png

대부분의 컴포저블은 선택적인 수정자 매개변수를 허용하여 유연성을 높이므로 호출자가 수정할 수 있습니다. 자체 컴포저블을 만든다면 수정자를 매개변수로 사용하는 것이 좋고 기본값을 Modifier(아무것도 하지 않는 빈 수정자)로 설정하여 함수의 루트 컴포저블에 적용합니다. 이 경우에는 다음과 같습니다.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier) { ... }
}

수정자 순서가 중요

코드에서 팩토리 확장 함수(Modifier.padding(start = 8.dp).align(Alignment.CenterVertically))를 사용하여 여러 수정자를 차례로 체이닝할 수 있는 방법을 확인하세요.

순서가 중요하므로 신중하게 수정자를 체이닝해야 합니다. 단일 인수로 연결되므로 순서는 최종 결과에 영향을 미칩니다.

사진가 프로필을 클릭 가능하게 하면서 패딩도 적용하려면 다음과 같이 하면 됩니다.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(16.dp)
        .clickable(onClick = { /* Ignoring onClick */ })
    ) {
        ...
    }
}

대화형 미리보기를 사용하거나 에뮬레이터에서 실행하면 다음과 같습니다.

c15a1050b051617f.gif

전체 영역을 클릭할 수는 없습니다. paddingclickable 수정자 앞에 적용되었기 때문입니다. padding 수정자를 clickable 수정자 뒤에 적용하면 패딩이 클릭 가능한 영역에 포함됩니다.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

대화형 미리보기를 사용하거나 에뮬레이터에서 실행하면 다음과 같습니다.

a1ea4c8e16d61ffa.gif

자유롭게 적용해 보세요. 수정자를 사용하면 컴포저블을 매우 유연하게 수정할 수 있습니다. 예를 들어 외부 간격을 추가하고 컴포저블의 배경 색상을 변경하고 Row의 모서리를 둥글게 처리하려면 다음 코드를 사용하면 됩니다.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(8.dp)
        .clip(RoundedCornerShape(4.dp))
        .background(MaterialTheme.colors.surface)
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

대화형 미리보기를 사용하거나 에뮬레이터에서 실행하면 다음과 같습니다.

4c7652fc71ccf8dc.gif

이 Codelab 뒷부분에서 수정자가 내부적으로 작동하는 방식을 자세히 알아봅니다.

4. 슬롯 API

Compose는 UI를 빌드하는 데 사용할 수 있는 상위 수준 머티리얼 구성요소 컴포저블을 제공합니다. 이는 UI를 만드는 기본 요소이므로 개발자는 여전히 화면에 표시할 항목에 관한 정보를 제공해야 합니다.

슬롯 API는 컴포저블(이 사용 사례에서는 사용 가능한 머티리얼 구성요소 컴포저블) 위에 맞춤설정 레이어를 가져오려고 Compose에서 도입한 패턴입니다.

예를 들어 이를 확인해 보겠습니다.

머티리얼 버튼을 고려한다면 버튼 모양과 포함될 내용에 관한 정해진 가이드라인이 있습니다. 이를 간단한 API로 변환하여 다음과 같이 사용할 수 있습니다.

Button(text = "Button")

b3cb99320ec18268.png

그러나 개발자는 예상을 뛰어넘어 구성요소를 맞춤설정하려고 할 때가 많습니다. Google에서는 개발자가 맞춤설정할 수 있는 각 개별 요소의 매개변수를 시도하고 추가할 수 있지만 개발자의 속도를 따라잡기 쉽지 않습니다.

Button(
    text = "Button",
    icon: Icon? = myIcon,
    textStyle = TextStyle(...),
    spacingBetweenIconAndText = 4.dp,
    ...
)

ef5893f332864e28.png

따라서 예상할 수 없는 방식으로 구성요소를 맞춤설정할 매개변수를 여러 개 추가하는 대신 Google에서는 슬롯을 추가했습니다. 슬롯은 개발자가 원하는 대로 채울 수 있도록 UI에 빈 공간을 남겨둡니다.

fccfb817afa8876e.png

예를 들어 버튼의 경우 버튼의 내부를 개발자가 채우도록 남겨둘 수 있습니다. 개발자는 아이콘과 텍스트가 있는 행을 삽입하려고 할 수 있습니다.

Button {
    Row {
        MyImage()
        Spacer(4.dp)
        Text("Button")
    }
}

이를 위해 Google에서는 하위 컴포저블 람다(content: @Composable () -> Unit)를 사용하는 버튼용 API를 제공합니다. 이를 통해 버튼 내에서 내보내도록 자체 컴포저블을 정의할 수 있습니다.

@Composable
fun Button(
    modifier: Modifier = Modifier,
    onClick: (() -> Unit)? = null,
    ...
    content: @Composable () -> Unit
)

이름이 content인 이 람다는 마지막 매개변수입니다. 이를 통해 후행 람다 문법을 사용하여 콘텐츠를 구조화된 방식으로 버튼에 삽입할 수 있습니다.

Compose는 상단 앱 바와 같은 좀 더 복잡한 구성요소에서 슬롯을 많이 사용합니다.

4365ce9b02ec2805.png

여기에서 제목을 제외하고 많은 항목을 맞춤설정할 수 있습니다.

2decc9ec64c79a84.png

사용 예시:

TopAppBar(
    title = {
        Text(text = "Page title", maxLines = 2)
    },
    navigationIcon = {
        Icon(myNavIcon)
    }
)

자체 컴포저블을 빌드할 때는 슬롯 API 패턴을 사용하면 좀 더 재사용성을 높일 수 있습니다.

다음 섹션에서는 사용 가능한 다양한 머티리얼 구성요소 컴포저블과 Android 앱 빌드 시 이를 사용하는 방법을 알아봅니다.

5. 머티리얼 구성요소

Compose에는 앱을 만드는 데 사용할 수 있는 머티리얼 구성요소 컴포저블이 내장되어 있습니다. 가장 높은 수준의 컴포저블은 Scaffold입니다.

Scaffold

Scaffold를 사용하면 기본 머티리얼 디자인 레이아웃 구조로 UI를 구현할 수 있습니다. Scaffold는 TopAppBar, BottomAppBar, FloatingActionButton, Drawer와 같은 가장 일반적인 최상위 머티리얼 구성요소를 위한 슬롯을 제공합니다. Scaffold를 통해 이러한 구성요소가 올바르게 배치되고 함께 작동하는지 확인합니다.

생성된 Android 스튜디오 템플릿에 기반하여 Scaffold를 사용하도록 샘플 코드를 수정합니다. MainActivity.kt를 열고 사용하지 않을 GreetingGreetingPreview 컴포저블을 삭제합니다.

이 Codelab 전반에 걸쳐 수정할 LayoutsCodelab이라는 새 컴포저블을 만듭니다.

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.codelab.layouts.ui.LayoutsCodelabTheme

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LayoutsCodelabTheme {
                LayoutsCodelab()
            }
        }
    }
}

@Composable
fun LayoutsCodelab() {
    Text(text = "Hi there!")
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

@Preview로 주석 처리해야 하는 Compose 미리보기 함수가 표시되면 LayoutsCodelab이 다음과 같이 표시됩니다.

bd1c58d4497f523f.png

일반적인 머티리얼 디자인 구조를 가질 수 있도록 Scaffold 컴포저블을 예시에 추가해 보겠습니다. Scaffold API의 모든 매개변수는 @Composable (InnerPadding) -> Unit 유형인 본문 콘텐츠를 제외하고 선택사항입니다. 람다가 패딩을 매개변수로 받습니다. 이는 화면의 항목을 적절하게 제한하도록 콘텐츠 루트 컴포저블에 적용해야 하는 패딩입니다. 간단하게 다른 머티리얼 구성요소 없이 Scaffold를 추가해 보겠습니다.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Text(text = "Hi there!", modifier = Modifier.padding(innerPadding))
    }
}

미리보기는 다음과 같습니다.

54b175d305766292.png

화면의 기본 콘텐츠가 포함된 Column을 만들려면 수정자를 Column에 적용해야 합니다.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text(text = "Hi there!")
            Text(text = "Thanks for going through the Layouts codelab")
        }
    }
}

미리보기는 다음과 같습니다.

aceda77e27f25fe9.png

쉽게 재사용하고 테스트할 수 있는 코드를 만들려면 작은 청크로 구조화해야 합니다. 이를 위해 화면의 콘텐츠가 포함된 구성 가능한 함수를 하나 더 만들어 보겠습니다.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}

일반적으로 Android 앱의 상단 AppBar가 표시되며 현재 화면과 탐색, 작업에 관한 정보가 포함되어 있습니다. 이를 현재 예시에 추가해 보겠습니다.

TopAppBar

Scaffold에는 @Composable () -> Unit 유형의 topBar 매개변수가 있는 상단 AppBar 슬롯이 있습니다. 즉, 원하는 컴포저블로 슬롯을 채울 수 있습니다. 예를 들어 h3 스타일 텍스트만 포함되도록 하려면 제공된 슬롯에서 다음과 같이 Text를 사용할 수 있습니다.

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            Text(
                text = "LayoutsCodelab",
                style = MaterialTheme.typography.h3
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

미리보기는 다음과 같습니다.

6adf05bb92b48b76.png

그러나 대부분의 머티리얼 구성요소와 관련하여 Compose는 제목과 탐색 아이콘, 작업용 슬롯이 있는 TopAppBar 컴포저블과 함께 제공됩니다. 일부 기본값도 함께 제공되는데 이는 각 구성요소에서 사용할 색상과 같이 머티리얼 사양에서 권장하는 사항에 맞게 조정됩니다.

슬롯 API 패턴을 따라 TopAppBartitle 슬롯에 화면 제목이 있는 Text가 포함되도록 합니다.

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

미리보기는 다음과 같습니다.

c93d09851d6560c7.png

상단 AppBar에는 일반적으로 작업 항목이 있습니다. 예시에서는 무언가를 학습했다고 생각할 때 탭할 수 있는 즐겨찾기 버튼을 추가합니다. Compose에는 닫기, 즐겨찾기, 메뉴 아이콘 등 개발자가 사용할 수 있는 사전 정의된 머티리얼 아이콘도 있습니다.

상단 AppBar의 작업 항목 슬롯은 내부적으로 Row를 사용하는 actions 매개변수이므로 여러 작업이 가로로 배치됩니다. 사전 정의된 아이콘 중 하나를 사용하려면 내부에 Icon이 있는 IconButton 컴포저블을 사용하면 됩니다.

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

미리보기는 다음과 같습니다.

b2d81ccec4667ef5.png

일반적으로 작업은 어떤 식으로든 애플리케이션의 상태를 수정합니다. 상태에 관한 자세한 내용은 Compose 기본사항 Codelab의 상태 관리 기본사항을 참고하세요.

수정자 배치

새 컴포저블을 만들 때마다 기본값이 Modifiermodifier 매개변수가 있으면 컴포저블의 재사용성이 높아져 좋습니다. BodyContent 컴포저블은 이미 수정자를 매개변수로 사용합니다. BodyContent에 패딩을 더 추가하려면 padding 수정자를 어디에 배치해야 할까요?

두 곳에 배치할 수 있습니다.

  1. 모든 BodyContent 호출이 추가 패딩을 적용하도록 수정자를 컴포저블 내 유일한 직계 하위 요소에 적용합니다.
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(8.dp)) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}
  1. 필요할 때만 추가 패딩을 추가하는 컴포저블을 호출할 때 수정자를 적용합니다.
@Composable
fun LayoutsCodelab() {
    Scaffold(...) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding).padding(8.dp))
    }
}

어디에 배치할지는 컴포저블의 유형과 사용 사례에 따라 완전히 달라집니다. 수정자가 컴포저블에 고유하면 내부에, 고유하지 않으면 외부에 배치합니다. 여기서는 두 번째 옵션을 선택합니다. 패딩은 BodyContent를 호출할 때마다 항상 강제 적용되지는 않으므로 사례별로 적용해야 합니다.

수정자는 이전 수정자에서 각 연속 수정자 함수를 호출하여 체이닝될 수 있습니다. 사용 가능한 체이닝 메서드가 없으면 .then()을 사용하면 됩니다. 예시에서는 modifier(소문자)로 시작합니다. 즉, 체인이 매개변수로 전달된 체인 위에 빌드됩니다.

아이콘 더보기

이전에 나열한 아이콘 외에도 프로젝트에 새 종속 항목을 추가하여 전체 머티리얼 아이콘 목록을 사용할 수 있습니다. 이러한 아이콘을 사용하려면 app/build.gradle(또는 build.gradle (Module: app)) 파일을 열고 ui-material-icons-extended 종속 항목을 가져옵니다.

dependencies {
  ...
  implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

자유롭게 TopAppBar 아이콘을 변경하여 사용해 보세요.

추가 작업

ScaffoldTopAppBar는 머티리얼이 애플리케이션처럼 보이도록 하는 데 사용할 수 있는 일부 컴포저블일 뿐입니다. BottomNavigation 또는 BottomDrawer와 같은 다른 머티리얼 구성요소에도 같은 작업을 할 수 있습니다. 연습 삼아 지금까지 한 방식과 동일하게 이러한 API로 Scaffold 슬롯을 채워 보세요.

6. 목록 사용

항목 목록 표시는 애플리케이션에서 일반적인 패턴입니다. Jetpack Compose를 사용하면 이 패턴을 ColumnRow 컴포저블로 쉽게 구현할 수 있지만 현재 표시되는 항목만 구성하고 배치하는 지연 목록도 제공됩니다.

Column 컴포저블을 사용하여 항목이 100개인 세로 목록을 만들어 연습해 보겠습니다.

@Composable
fun SimpleList() {
    Column {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

Column은 기본적으로 스크롤을 처리하지 않으므로 일부 항목은 표시되지 않습니다. 화면 밖에 있기 때문입니다. Column 내에서 스크롤이 가능하도록 verticalScroll 수정자를 추가합니다.

@Composable
fun SimpleList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberScrollState()

    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

지연 목록

Column은 화면에 표시되지 않는 항목도 포함하여 목록 항목을 모두 렌더링하므로 목록 크기가 커질 때 성능 문제가 발생합니다. 이 문제를 방지하려면 화면에 표시되는 항목만 렌더링하여 성능을 높이고 scroll 수정자가 필요하지 않은 LazyColumn을 사용합니다.

LazyColumn에는 목록 콘텐츠를 설명하는 DSL이 있습니다. 여기서는 목록 크기로 숫자를 사용할 수 있는 items를 사용합니다. LazyColumn은 배열과 목록도 지원합니다(자세한 내용은 목록 문서 섹션 참고).

@Composable
fun LazyList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            Text("Item #$it")
        }
    }
}

1c747e54111e28c.gif

이미지 표시

앞서 PhotographCard로 확인했듯이 Image비트맵 또는 벡터 이미지를 표시하는 데 사용할 수 있는 컴포저블입니다. 이미지를 원격으로 가져오면 프로세스에는 더 많은 단계가 포함됩니다. 앱에서 애셋을 다운로드하여 비트맵으로 디코딩하고 최종적으로 Image 내에서 렌더링해야 하기 때문입니다.

이러한 단계를 간소화하기 위해 이러한 작업을 효율적으로 실행하는 컴포저블을 제공하는 Coil 라이브러리를 사용합니다.

Coil 종속 항목을 프로젝트의 build.gradle 파일에 추가합니다.

// build.gradle
implementation 'io.coil-kt:coil-compose:1.4.0'

원격 이미지를 가져오므로 매니페스트 파일에 INTERNET 권한을 추가합니다.

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

이제 항목 색인이 옆에 있는 이미지를 표시할 항목 컴포저블을 만듭니다.

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {

        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

다음 단계는 목록의 Text 컴포저블을 다음 ImageListItem으로 교체합니다.

@Composable
fun ImageList() {
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            ImageListItem(it)
        }
    }
}

9c6a666c57a84211.gif

목록 스크롤

이제 목록의 스크롤 위치를 수동으로 제어해 보겠습니다. 목록 상단과 하단으로 부드럽게 스크롤할 수 있도록 하는 버튼 두 개를 추가합니다. 스크롤하는 동안 목록 렌더링 차단을 방지하기 위해 스크롤 API는 정지 함수입니다. 따라서 코루틴에서 호출해야 합니다. 이렇게 하려면 버튼 이벤트 핸들러에서 코루틴을 만들도록 rememberCoroutineScope 함수를 사용하여 CoroutineScope를 만들면 됩니다. 이 CoroutineScope는 호출 사이트의 수명 주기를 따릅니다. 컴포저블 수명 주기와 코루틴, 부수 효과에 관한 자세한 내용은 이 가이드를 참고하세요.

val listSize = 100
// We save the scrolling position with this state
val scrollState = rememberLazyListState()
// We save the coroutine scope where our animated scroll will be executed
val coroutineScope = rememberCoroutineScope()

이제 스크롤을 제어하는 버튼을 추가했습니다.

Row {
    Button(onClick = {
        coroutineScope.launch {
            // 0 is the first item index
            scrollState.animateScrollToItem(0)
        }
    }) {
        Text("Scroll to the top")
    }

    Button(onClick = {
        coroutineScope.launch {
            // listSize - 1 is the last index of the list
            scrollState.animateScrollToItem(listSize - 1)
        }
    }) {
        Text("Scroll to the end")
    }
}

9bc52801a90401f3.gif

이 섹션의 전체 코드

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import kotlinx.coroutines.launch

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

@Composable
fun ScrollingList() {
    val listSize = 100
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()
    // We save the coroutine scope where our animated scroll will be executed
    val coroutineScope = rememberCoroutineScope()

    Column {
        Row {
            Button(onClick = {
                coroutineScope.launch {
                    // 0 is the first item index
                    scrollState.animateScrollToItem(0)
                }
            }) {
                Text("Scroll to the top")
            }

            Button(onClick = {
                coroutineScope.launch {
                    // listSize - 1 is the last index of the list
                    scrollState.animateScrollToItem(listSize - 1)
                }
            }) {
                Text("Scroll to the end")
            }
        }

        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

7. 맞춤 레이아웃 만들기

Compose는 Column이나 Row, Box와 같은 내장 컴포저블을 결합하여 맞춤 레이아웃에 충분할 수 있는 작은 청크로 컴포저블의 재사용성을 높입니다.

그러나 하위 요소를 수동으로 측정하고 배치해야 하는 앱에 고유한 것을 빌드해야 할 수도 있습니다. 이를 위해 Layout 컴포저블을 사용할 수 있습니다. 실제로 Column, Row와 같은 모든 상위 수준 레이아웃은 이를 사용하여 빌드됩니다.

맞춤 레이아웃을 만드는 방법을 알아보기 전에 Compose의 레이아웃 원칙에 관해 자세히 알아봐야 합니다.

Compose의 레이아웃 원칙

일부 구성 가능한 함수는 호출될 때 UI 요소를 내보내며 이 요소는 화면에 렌더링될 UI 트리에 추가됩니다. 각 요소에는 상위 요소가 하나 있고 하위 요소는 여러 개 있을 수 있습니다. 위치(상위 요소 내에 있음, (x, y) 위치)와 크기(width, height)도 포함되어 있습니다.

요소는 자체적으로 측정해야 하고 제약 조건을 충족해야 합니다. 제약 조건은 요소의 최소/최대 widthheight를 제한합니다. 요소에 하위 요소가 있으면 각 하위 요소를 측정하여 자체 크기를 파악할 수 있습니다. 요소가 자체 크기를 보고하면 요소 자체를 기준으로 하위 요소를 배치할 수 있습니다. 이 내용은 맞춤 레이아웃을 만들 때 자세히 설명합니다.

Compose UI는 다중 패스 측정을 허용하지 않습니다. 즉, 레이아웃 요소가 다른 측정 구성을 시도하기 위해 하위 요소를 두 번 이상 측정할 수 없습니다. 단일 패스 측정은 성능 측면에서 좋으므로 Compose가 깊은 UI 트리를 효율적으로 처리할 수 있습니다. 레이아웃 요소가 한 하위 요소를 두 번 측정하고 이 하위 요소가 자체 하위 요소 중 하나를 두 번 측정하는 식으로 측정을 진행하면 전체 UI를 배치하려는 한 번의 시도에도 많은 작업을 실행해야 하므로 앱의 성능을 유지하기가 어렵습니다. 그러나 단일 하위 요소 측정으로 알 수 있는 내용 외에도 추가 정보가 실제로 필요할 때가 있습니다. 이러한 경우 해결할 수 있는 방법이 있으며 나중에 설명하겠습니다.

레이아웃 수정자 사용

layout 수정자를 사용하여 요소를 측정하고 배치하는 방법을 수동으로 제어합니다. 일반적으로 맞춤 layout 수정자의 일반적인 구조는 다음과 같습니다.

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

layout 수정자를 사용하면 람다 매개변수 두 개를 얻습니다.

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

화면에 Text를 표시하고 텍스트 첫 줄의 상단에서 기준선까지 거리를 제어해 보겠습니다. 이렇게 하려면 layout 수정자를 사용하여 화면에 컴포저블을 수동으로 배치해야 합니다. 상단에서 첫 번째 기준선까지 거리가 24.dp인 다음 그림에서 원하는 동작을 확인하세요.

4ee1054702073598.png

먼저 firstBaselineToTop 수정자를 만들어 보겠습니다.

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

먼저 컴포저블을 측정해야 합니다. Compose의 레이아웃 원칙 섹션에서 설명했듯이 하위 요소는 한 번만 측정할 수 있습니다.

measurable.measure(constraints)를 호출하여 컴포저블을 측정합니다. measure(constraints)를 호출할 때 constraints 람다 매개변수에서 사용할 수 있는 컴포저블의 지정된 제약 조건을 전달하거나 직접 만들 수 있습니다. Measurable에서 measure()를 호출한 결과는 placeRelative(x, y)를 호출하여 배치할 수 있는 Placeable입니다. 이는 나중에 해 봅니다.

이 사용 사례의 경우 측정을 추가로 제한하지 말고 주어진 제약 조건만 사용합니다.

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

        ...
    }
)

이제 컴포저블을 측정했으므로 콘텐츠 배치에 사용되는 람다도 허용하는 layout(width, height) 메서드를 호출하여 크기를 계산하고 지정해야 합니다.

여기서는 컴포저블의 너비가 측정된 컴포저블의 width가 되고 높이는 원하는 상단에서 기준선까지의 높이에서 첫 번째 기준선을 뺀 컴포저블의 height가 됩니다.

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에 따라 placeable의 위치를 자동으로 조정합니다.

여기서 텍스트의 y 위치는 상단 패딩에서 첫 번째 기준선 위치를 뺀 값과 일치합니다.

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
        layout(placeable.width, height) {
            // Where the composable gets placed
            placeable.placeRelative(0, placeableY)
        }
    }
)

예상대로 작동하는지 확인하려면 위 그림에서 확인한 것처럼 Text에서 이 수정자를 사용하면 됩니다.

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

미리보기는 다음과 같습니다.

dccb4473e2ca09c6.png

레이아웃 컴포저블 사용

단일 컴포저블이 측정되고 화면에 배치되는 방식을 제어하는 대신 컴포저블 그룹에도 같은 필요성이 있을 수 있습니다. 이를 위해 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
    }
}

CustomLayout의 최소 필수 매개변수는 modifier, content입니다. 이러한 매개변수는 이후 Layout으로 전달됩니다. Layout(MeasurePolicy 유형)의 후행 람다에서 layout 수정자로 얻은 것과 같은 람다 매개변수를 얻습니다.

작동하는 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 ->
        // measure and position children given constraints logic here
    }
}

이전과 마찬가지로 먼저 한 번만 측정할 수 있는 하위 요소를 측정해야 합니다. 레이아웃 수정자가 작동하는 방식과 유사하게 measurables 람다 매개변수에서 measurable.measure(constraints)를 호출하여 측정할 수 있는 모든 content를 얻습니다.

이 사용 사례의 경우 하위 뷰를 추가로 제한하지 않습니다. 하위 요소를 측정할 때 각 행의 width와 최대 height도 추적하여 나중에 화면에 올바르게 배치할 수 있어야 합니다.

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

이제 측정된 하위 요소 목록이 로직에 있으므로 화면에 배치하기 전에 이 Column 버전의 크기를 계산해야 합니다. 상위 요소만큼 크게 만들고 있으므로 크기는 상위 요소에서 전달된 제약 조건입니다. 하위 요소 배치에 사용되는 람다도 제공하는 layout(width, height) 메서드를 호출하여 자체 Column의 크기를 지정합니다.

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Measure children - code in the previous code snippet
        ...

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children
        }
    }
}

마지막으로 placeable.placeRelative(x, y)를 호출하여 화면에 하위 요소를 배치합니다. 하위 요소를 세로로 배치하기 위해 하위 요소를 배치한 y 좌표를 추적합니다. MyOwnColumn의 최종 코드는 다음과 같습니다.

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

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

미리보기는 다음과 같습니다.

e69cdb015e4d8abe.png

8. 복잡한 맞춤 레이아웃

Layout의 기본사항을 알아봤습니다. API의 유연성을 보여주도록 좀 더 복잡한 예시를 만들어 보겠습니다. 맞춤 머티리얼 연구 Owl의 지그재그형 그리드를 빌드합니다. 다음 그림 가운데 부분에서 확인할 수 있습니다.

7a54fe8390fe39d2.png

Owl의 지그재그형 그리드는 항목을 세로로 배치하여 행이 n 개 주어지면 한 번에 열을 채웁니다. ColumnsRow로 이 작업을 실행할 수는 없습니다. 지그재그형의 레이아웃을 얻을 수 없기 때문입니다. 세로로 표시되도록 데이터를 준비하면 RowsColumn은 가능할 수 있습니다.

그러나 맞춤 레이아웃을 통해 지그재그형 그리드에 있는 모든 항목의 높이를 제한할 수도 있습니다. 따라서 레이아웃을 세밀하게 제어하고 맞춤 레이아웃을 만드는 방법을 알아보기 위해 직접 하위 요소를 측정하고 배치합니다.

그리드를 다른 방향으로 재사용할 수 있도록 하려면 화면에 표시하려는 행 수를 매개변수로 사용할 수 있습니다. 레이아웃이 호출될 때 이 정보를 얻어야 하므로 매개변수로 전달합니다.

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

이전과 마찬가지로 하위 요소를 먼저 측정해야 합니다. 하위 요소는 한 번만 측정할 수 있습니다.

이 사용 사례의 경우 하위 뷰를 추가로 제한하지 않습니다. 하위 요소를 측정할 때 각 행의 width와 최대 height도 추적해야 합니다.

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

    // Keep track of the width of each row
    val rowWidths = IntArray(rows) { 0 }

    // Keep track of the max height of each row
    val rowHeights = IntArray(rows) { 0 }

    // Don't constrain child views further, measure them with given constraints
    // List of measured children
    val placeables = measurables.mapIndexed { index, measurable ->

        // Measure each child
        val placeable = measurable.measure(constraints)

        // Track the width and max height of each row
        val row = index % rows
        rowWidths[row] += placeable.width
        rowHeights[row] = Math.max(rowHeights[row], placeable.height)

        placeable
    }
    ...
}

이제 측정된 하위 요소 목록이 로직에 있으므로 화면에 배치하기 전에 그리드의 크기(전체 widthheight)를 계산해야 합니다. 각 행의 최대 높이는 이미 알고 있으므로 Y 위치에서 각 행의 요소를 배치할 곳을 계산할 수 있습니다. rowY 변수에 Y 위치를 저장합니다.

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

    // Grid's width is the widest row
    val width = rowWidths.maxOrNull()
        ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

    // Grid's height is the sum of the tallest element of each row
    // coerced to the height constraints
    val height = rowHeights.sumOf { it }
        .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

    // Y of each row, based on the height accumulation of previous rows
    val rowY = IntArray(rows) { 0 }
    for (i in 1 until rows) {
        rowY[i] = rowY[i-1] + rowHeights[i-1]
    }

    ...
}

마지막으로 placeable.placeRelative(x, y)를 호출하여 화면에 하위 요소를 배치합니다. 이 사용 사례에서는 rowX 변수에서 각 행의 X 좌표도 추적합니다.

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

    // Set the size of the parent layout
    layout(width, height) {
        // x cord we have placed up to, per row
        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 사용

하위 요소를 측정하고 배치하는 방법을 알고 있는 맞춤 그리드 레이아웃을 만들었으므로 이제 이를 앱에서 사용해 보겠습니다. 그리드에서 Owl의 칩을 시뮬레이션하려면 다음과 같은 작업을 하는 컴포저블을 간단히 만들면 됩니다.

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

미리보기는 다음과 같습니다.

f1f8c6bb7f12cf1.png

이제 BodyContent에 표시할 수 있는 주제 목록을 만들고 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"
)

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

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        BodyContent()
    }
}

미리보기는 다음과 같습니다.

e9861768e4e27dd4.png

그리드의 행 수를 변경할 수 있으며 예상대로 여전히 작동합니다.

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

미리보기는 다음과 같습니다.

555f88fd41e4dff4.png

행 수에 따라 주제가 화면 밖으로 나갈 수 있으므로 StaggeredGrid를 스크롤 가능한 Row에 래핑하고 수정자를 StaggeredGrid 대신 이 Row에 전달하여 BodyContent를 스크롤 가능하게 만들 수 있습니다.

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

대화형 미리보기 버튼 bb4c8dfe4b8debaa.png을 사용하거나 Android 스튜디오 실행 버튼을 탭하여 기기에서 앱을 실행하면 콘텐츠를 가로로 스크롤하는 방법을 확인할 수 있습니다.

9. 레이아웃 수정자의 내부

이제 수정자의 기본사항과 맞춤 컴포저블을 만들고 수동으로 하위 요소를 측정하고 배치하는 방법을 알았으므로 수정자가 내부적으로 어떻게 작동하는지 쉽게 파악할 수 있습니다.

요약하면 수정자를 통해 컴포저블의 동작을 맞춤설정할 수 있습니다. 여러 수정자를 함께 체이닝하여 결합할 수 있습니다. 수정자 유형에는 여러 가지가 있지만 이 섹션에서는 LayoutModifier에 집중합니다. UI 구성요소가 측정되고 배치되는 방법을 변경할 수 있기 때문입니다.

컴포저블은 자체 콘텐츠를 담당하고 이 콘텐츠는 상위 요소에서 검사하거나 조작하지 않을 수 있습니다. 단, 컴포저블의 작성자가 그렇게 하도록 명시적인 API를 노출하는 경우는 예외입니다. 마찬가지로 컴포저블의 수정자는 수정하는 것을 동일하게 불투명한 방식으로 장식합니다. 수정자는 캡슐화됩니다.

수정자 분석

ModifierLayoutModifier는 공개 인터페이스이므로 자체 수정자를 만들 수 있습니다. 전에 Modifier.padding을 사용한 것처럼 수정자를 더 잘 이해하도록 구현을 분석해 보겠습니다.

paddingLayoutModifier 인터페이스를 구현하는 클래스로 백업되는 함수이며 measure 메서드를 재정의합니다. PaddingModifierequals()를 구현하는 일반 클래스이므로 재구성에서 수정자를 비교할 수 있습니다.

예를 들면 다음은 padding이 요소의 크기와 제약 조건을 수정하는 방법에 관한 소스 코드입니다.

// How to create a modifier
@Stable
fun Modifier.padding(all: Dp) =
    this.then(
        PaddingModifier(start = all, top = all, end = all, bottom = all, rtlAware = true)
    )

// Implementation detail
private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp,
    val rtlAware: Boolean,
) : LayoutModifier {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
            } else {
                placeable.place(start.roundToPx(), top.roundToPx())
            }
        }
    }
}

요소의 새 width는 하위 요소의 width에 요소의 너비 제약 조건으로 강제 적용된 시작 및 끝 패딩 값을 더한 값이 됩니다. height는 하위 요소의 height에 요소의 높이 제약 조건에 강제 적용된 상단 및 하단 패딩 값을 더한 값이 됩니다.

순서가 중요

첫 번째 섹션에서 확인했듯이 수정자를 체이닝할 때는 순서가 중요합니다. 이전에서 이후로 수정하는 컴포저블에 적용되기 때문입니다. 즉, 왼쪽에 있는 수정자의 측정과 레이아웃이 오른쪽 수정자에 영향을 미칩니다. 컴포저블의 최종 크기는 매개변수로 전달된 모든 수정자에 따라 다릅니다.

먼저 수정자는 제약 조건을 왼쪽에서 오른쪽으로 업데이트하고 크기를 오른쪽에서 왼쪽으로 다시 반환합니다. 예시를 통해 자세히 살펴보겠습니다.

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray)
            .size(200.dp)
            .padding(16.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

이런 방식으로 적용된 수정자는 다음과 같은 미리보기를 생성합니다.

cb209bb5edf634d6.png

먼저 배경을 변경하여 수정자가 UI에 미치는 영향을 확인하고 200.dp widthheight가 되도록 크기를 제한한 후 마지막으로 패딩을 적용하여 텍스트와 주변 환경 사이에 공간을 추가합니다.

제약 조건은 왼쪽에서 오른쪽으로 체인을 통해 전파되므로 측정할 Row 콘텐츠의 제약 조건은 최소 및 최대 widthheight에 모두 (200-16-16)=168dp입니다. 즉, StaggeredGrid의 크기는 정확히 168x168dp가 됩니다. 따라서 modifySize 체인이 오른쪽에서 왼쪽으로 실행된 후 스크롤 가능한 Row의 최종 크기는 200x200dp가 됩니다.

수정자의 순서를 변경하는 경우(패딩을 먼저 적용하고 크기를 적용) UI가 다르게 표시됩니다.

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray, shape = RectangleShape)
            .padding(16.dp)
            .size(200.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

미리보기는 다음과 같습니다.

17da5805d6d8fc91.png

이 경우 스크롤 가능한 Rowpadding에 원래 적용된 제약 조건이 size 제약 조건에 강제 적용되어 하위 요소를 측정합니다. 따라서 StaggeredGrid는 최소 및 최대 widthheight에 모두 200dp로 제한됩니다. StaggeredGrid 크기는 200x200dp이고 크기가 오른쪽에서 왼쪽으로 수정되므로 padding 수정자는 크기를 (200+16+16)x(200+16+16)=232x232로 늘리며 이 크기가 Row의 최종 크기이기도 합니다.

레이아웃 방향

LayoutDirection 앰비언트를 사용하여 컴포저블의 레이아웃 방향을 변경할 수 있습니다.

화면에 컴포저블을 수동으로 배치하는 경우 layoutDirectionlayout 수정자 또는 Layout 컴포저블의 LayoutScope에 포함되어 있습니다. layoutDirection을 사용할 때 place를 사용하여 컴포저블을 배치합니다. placeRelative 메서드와 달리 오른쪽에서 왼쪽 컨텍스트에서 위치를 자동으로 미러링하지 않기 때문입니다.

이 섹션의 전체 코드

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.codelab.layouts.ui.LayoutsCodelabTheme
import kotlin.math.max

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

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

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier
        .background(color = Color.LightGray)
        .padding(16.dp)
        .size(200.dp)
        .horizontalScroll(rememberScrollState()),
        content = {
            StaggeredGrid {
                for (topic in topics) {
                    Chip(modifier = Modifier.padding(8.dp), text = topic)
                }
            }
        })
}

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Keep track of the width of each row
        val rowWidths = IntArray(rows) { 0 }

        // Keep track of the max height of each row
        val rowHeights = IntArray(rows) { 0 }

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.mapIndexed { index, measurable ->
            // Measure each child
            val placeable = measurable.measure(constraints)

            // Track the width and max height of each row
            val row = index % rows
            rowWidths[row] += placeable.width
            rowHeights[row] = Math.max(rowHeights[row], placeable.height)

            placeable
        }

        // Grid's width is the widest row
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

        // Grid's height is the sum of the tallest element of each row
        // coerced to the height constraints
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

        // Y of each row, based on the height accumulation of previous rows
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // Set the size of the parent layout
        layout(width, height) {
            // x co-ord we have placed up to, per row
            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
            }
        }
    }
}

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

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

10. 제약 조건 레이아웃

ConstraintLayout은 화면에 다른 요소를 기준으로 컴포저블을 배치하는 데 도움이 될 수 있으며 여러 Row, Column, Box 대신 사용할 수 있습니다. ConstraintLayout은 더 복잡한 정렬 요구사항이 있는 더 큰 레이아웃을 구현할 때 유용합니다.

Compose 제약 조건 레이아웃 종속 항목은 프로젝트의 build.gradle 파일에서 확인할 수 있습니다.

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

다음과 같이 Compose의 ConstraintLayoutDSL과 함께 작동합니다.

  • 참조는 createRefs() 또는 createRef()를 사용하여 생성되며 ConstraintLayout의 각 컴포저블에는 연결된 참조가 있어야 합니다.
  • 제약 조건은 constrainAs 수정자를 사용하여 제공됩니다. 이 수정자는 참조를 매개변수로 사용하고 본문 람다에 제약 조건을 지정할 수 있게 합니다.
  • 제약 조건은 linkTo 또는 다른 유용한 메서드를 사용하여 지정됩니다.
  • parentConstraintLayout 컴포저블 자체에 관한 제약 조건을 지정하는 데 사용할 수 있는 기존 참조입니다.

간단한 예시를 들어 보겠습니다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {

        // Create references for the composables to constrain
        val (button, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            // Assign reference "button" to the Button composable
            // and constrain it to the top of the ConstraintLayout
            modifier = Modifier.constrainAs(button) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button")
        }

        // Assign reference "text" to the Text composable
        // and constrain it to the bottom of the Button composable
        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
        })
    }
}

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

이 코드는 Button의 상단을 여백이 16.dp인 상위 요소로 제한하고 Text를 여백이 16.dpButton의 하단으로 제한합니다.

72fcb81ab2c0483c.png

텍스트를 가로로 가운데에 오도록 하려면 Textstartend를 모두 parent의 가장자리로 설정하는 centerHorizontallyTo 함수를 사용하면 됩니다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        ... // Same as before

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
            // Centers Text horizontally in the ConstraintLayout
            centerHorizontallyTo(parent)
        })
    }
}

미리보기는 다음과 같습니다.

729a1b4c03f1f187.png

ConstraintLayout의 크기는 콘텐츠를 래핑하도록 최대한 작게 합니다. 이로 인해 Text가 상위 요소가 아닌 Button을 중심으로 하는 것으로 보입니다. 다른 크기 조절 동작이 필요하면 Compose의 다른 레이아웃과 마찬가지로 크기 조절 수정자(예: fillMaxSize, size)를 ConstraintLayout 컴포저블에 적용해야 합니다.

도우미

DSL은 가이드라인과 배리어, 체인 만들기도 지원합니다. 예를 들면 다음과 같습니다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // Creates references for the three composables
        // in the ConstraintLayout's body
        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")
        }
    }
}

미리보기는 다음과 같습니다.

a4117576ef1768a2.png

다음 사항을 참고하세요.

  • 배리어와 다른 모든 도우미는 ConstraintLayout 본문에서 만들 수 있지만 constrainAs 내부에서는 만들 수 없습니다.
  • linkTo는 레이아웃 가장자리에서 작동하는 같은 방식으로 가이드라인과 배리어를 사용하여 제한하는 데 사용할 수 있습니다.

측정기준 맞춤설정

기본적으로 ConstraintLayout의 하위 요소는 콘텐츠를 래핑하는 데 필요한 크기를 선택할 수 있습니다. 예를 들어 텍스트가 너무 길면 화면 경계 밖으로 나갈 수 있습니다.

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

616c19b971811cfa.png

사용할 수 있는 공간에서 텍스트를 줄바꿈하는 것이 좋습니다. 이렇게 하려면 텍스트의 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
            }
        )
    }
}

미리보기는 다음과 같습니다.

fc41cacd547bbea.png

사용할 수 있는 Dimension 동작은 다음과 같습니다.

  • preferredWrapContent: 레이아웃이 이 측정기준의 제약 조건이 적용되는 랩 콘텐츠입니다.
  • wrapContent: 제약 조건에서 허용하지 않더라도 레이아웃은 랩 콘텐츠입니다.
  • fillToConstraints: 이 측정기준의 제약 조건으로 정의된 공간을 채우도록 레이아웃이 확장됩니다.
  • preferredValue: 레이아웃이 이 측정기준의 제약 조건이 적용되는 고정된 dp 값입니다.
  • value: 레이아웃이 이 측정기준의 제약 조건과 상관없이 고정된 dp 값입니다.

특정 Dimension은 강제 적용될 수 있습니다.

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

분리된 API

지금까지 예시에서 제약 조건은 적용되는 컴포저블의 수정자와 함께 인라인으로 지정되었습니다. 그러나 제약 조건을 적용되는 레이아웃과 분리된 상태로 유지하는 것이 중요한 경우가 있습니다. 일반적인 예로는 화면 구성에 따라 제약 조건을 쉽게 바꾸거나 두 가지 제약 조건 세트 간에 애니메이션을 적용하는 경우입니다.

이러한 경우에는 ConstraintLayout을 다르게 사용할 수 있습니다.

  1. ConstraintSet를 매개변수로 ConstraintLayout에 전달합니다.
  2. layoutId 수정자를 사용하여 ConstraintSet에 생성된 참조를 컴포저블에 할당합니다.

위와 같이 첫 번째 ConstraintLayout 예시에 적용되고 화면 너비에 최적화된 이 API 모양은 다음과 같습니다.

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

11. 내장 기능

Compose 규칙 중 하나는 하위 요소를 한 번만 측정해야 한다는 것입니다. 하위 요소를 두 번 측정하면 런타임 예외가 발생합니다. 하지만 하위 요소를 측정하기 전에 하위 요소에 관한 정보가 필요한 경우도 있습니다.

내장 기능을 사용하면 하위 요소가 실제로 측정되기 전에 하위 요소를 쿼리할 수 있습니다.

컴포저블에 intrinsicWidth 또는 intrinsicHeight를 요청할 수 있습니다.

  • (min|max)IntrinsicWidth: 이 높이에서 콘텐츠를 적절하게 그릴 수 있는 최소/최대 너비는 무엇인가요?
  • (min|max)IntrinsicHeight: 이 너비에서 콘텐츠를 적절하게 그릴 수 있는 최소/최대 높이는 무엇인가요?

예를 들어 width가 무한대인 TextminIntrinsicHeight를 요청하면 텍스트가 한 줄에 그려진 것처럼 Textheight가 반환됩니다.

내장 기능 실제 사례

다음과 같이 화면에 구분선으로 구분된 두 텍스트를 표시하는 컴포저블을 만든다고 가정해 보겠습니다.

835f0b8c9f07cd9.png

이렇게 하려면 어떻게 해야 하나요? 안에 두 Text가 있고 최대한 확장할 수 있으며 중앙에 Divider가 있는 Row를 만들 수 있습니다. 구분선을 가장 높은 Text만큼 높고 가늘게(width = 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() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

미리 보면 구분선이 전체 화면으로 확장되며 이는 원하는 결과가 아닙니다.

d61f179394ded825.png

Row가 각 하위 요소를 개별적으로 측정하며 Text의 높이를 사용하여 Divider를 제약할 수 없기 때문에 이러한 결과가 발생합니다. Divider가 가용 공간을 지정된 높이로 채우도록 하려고 합니다. 이를 위해 height(IntrinsicSize.Min) 수정자를 사용할 수 있습니다.

height(IntrinsicSize.Min)는 하위 요소의 크기를 고유한 최소 높이로 강제 지정합니다. 이 기능은 반복적이므로 Row 및 하위 minIntrinsicHeight를 쿼리합니다.

코드에 적용하면 예상대로 작동합니다.

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

미리보기는 다음과 같습니다.

835f0b8c9f07cd9.png

Row의 minIntrinsicHeight는 그 하위 요소의 최대 minIntrinsicHeight가 됩니다. Divider의 minIntrinsicHeight는 0입니다. 주어진 제약 조건이 없으면 공간을 차지하지 않기 때문입니다. Text의 minIntrinsicHeight는 특정 width가 주어지면 텍스트의 minIntrinsicHeight입니다. 따라서 Row의 height 제약 조건은 Text의 최대 minIntrinsicHeight입니다. 그런 다음 Dividerheight를 Row가 지정한 height 제약 조건으로 확장합니다.

DIY

맞춤 레이아웃을 만들 때마다 내장 기능이 MeasurePolicy 인터페이스의 (min|max)Intrinsic(Width|Height)으로 계산되는 방식을 수정할 수 있습니다. 그러나 대부분의 경우 기본값으로 충분합니다.

또한 좋은 기본값이 있는 수정자 인터페이스의 Density.(min|max)Intrinsic(Width|Height)Of 메서드를 재정의하는 수정자로 내장 기능을 수정할 수 있습니다.

12. 축하합니다

축하합니다. 이 Codelab을 완료했습니다.

Codelab 솔루션

GitHub에서 이 Codelab의 솔루션 코드를 다운로드할 수 있습니다.

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

또는 저장소를 ZIP 파일로 다운로드할 수도 있습니다.

다음 단계

Compose 개발자 과정의 다른 Codelab을 확인하세요.

추가 자료

샘플 앱

  • 맞춤 레이아웃을 만드는 Owl
  • 차트와 표를 보여주는 Rally
  • 맞춤 레이아웃이 포함된 Jetsnack