Jetpack Compose 기초

1. 시작하기 전에

Jetpack Compose는 UI 개발을 간소화하기 위해 설계된 최신 툴킷입니다. 반응형 프로그래밍 모델을 Kotlin 프로그래밍 언어의 간결함 및 사용 편의성과 결합합니다. 이는 완전히 선언적인 접근 방식으로, 데이터를 UI 계층 구조로 변환하는 일련의 함수를 호출하여 UI를 설명합니다. 기본 데이터가 변경되면 프레임워크가 이러한 함수를 자동으로 다시 실행하여 UI 계층 구조를 업데이트합니다.

Compose 앱은 구성 가능한 함수로 구성됩니다. 구성 가능한 함수는 @Composable이라고 표시된 일반 함수이며 다른 구성 가능한 함수를 호출할 수 있습니다. 새로운 UI 구성요소를 만들기 위해서는 함수만 있으면 됩니다. 주석은 지속적으로 UI를 업데이트하고 유지관리하기 위해 함수에 특수 지원을 추가하도록 Compose에 알려주는 역할을 합니다. Compose를 사용하면 코드를 작은 청크로 구성할 수 있습니다. 대개 구성 가능한 함수를 줄여서 '컴포저블'이라고 합니다.

재사용이 가능한 작은 컴포저블을 만들면 앱에 사용하는 UI 요소의 라이브러리를 쉽게 빌드할 수 있습니다. 각 요소는 화면의 한 부분을 담당하며 독립적으로 수정할 수 있습니다.

이 Codelab을 진행하는 동안 도움이 추가로 필요한 경우 다음 Code-Along 동영상을 시청하세요.

참고: 이 Codelab은 Material 3을 사용하도록 업데이트된 반면, Code-Along 동영상에서는 Material 2를 사용하고 있습니다. 몇 가지 단계에 차이가 있을 수 있다는 점에 유의하세요.

기본 요건

  • 람다를 비롯한 Kotlin 문법 사용 경험

실행할 작업

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

  • Compose의 정의
  • Compose로 UI를 빌드하는 방법
  • 구성 가능한 함수의 상태를 관리하는 방법
  • 성능 기준에 맞는 목록을 만드는 방법
  • 애니메이션을 추가하는 방법
  • 앱 스타일과 테마를 지정하는 방법

다음과 같이 애니메이션으로 항목이 펼쳐지는 목록과 온보딩 화면이 포함된 앱을 빌드합니다.

8d24a786bfe1a8f2.gif

필요한 항목

2. 새 Compose 프로젝트 시작

새 Compose 프로젝트를 시작하려면 Android 스튜디오를 엽니다.

Welcome to Android Studio 창에 있다면 Start a new Android Studio project를 클릭합니다. 이미 Android 스튜디오 프로젝트가 열려 있다면 메뉴 바에서 File > New > New Project를 선택합니다.

새 프로젝트의 경우 제공되는 템플릿에서 Empty Activity를 선택합니다.

d12472c6323de500.png

Next를 클릭하고 평소 프로젝트를 구성하던 대로 'Basics Codelab'이라는 프로젝트를 구성합니다. minimumSdkVersion으로 API 수준 21 이상을 선택해야 합니다. 이는 Compose에서 지원하는 최소 API 수준입니다.

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

  • 프로젝트가 이미 Compose를 사용하도록 구성되어 있습니다.
  • AndroidManifest.xml 파일이 생성됩니다.
  • build.gradle.ktsapp/build.gradle.kts 파일에는 Compose에 필요한 옵션과 종속 항목이 포함되어 있습니다.

프로젝트를 동기화한 후 MainActivity.kt를 열고 코드를 확인합니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greeting("Android")
    }
}

다음 섹션에서는 각 메서드의 역할과 이러한 메서드를 개선하여 유연하고 재사용 가능한 레이아웃을 만드는 방법을 알아봅니다.

Codelab 솔루션

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

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

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

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

3. Compose 시작하기

Android 스튜디오에서 생성한 Compose 관련 클래스 및 메서드를 살펴보겠습니다.

구성 가능한 함수

구성 가능한 함수@Composable이라는 주석이 달린 일반 함수입니다. 이렇게 하면 함수가 내부에서 다른 @Composable 함수를 호출할 수 있습니다. Greeting 함수를 @Composable로 어떻게 표시하는지 확인할 수 있습니다. 이 함수는 지정된 입력(String)을 표시하는 UI 계층 구조를 생성합니다. Text는 라이브러리에서 제공하는 구성 가능한 함수입니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

Android 앱의 Compose

Compose를 사용하면 Activity가 Android 앱의 진입점으로 유지됩니다. 이 프로젝트에서는 AndroidManifest.xml 파일에 지정된 대로 사용자가 앱을 열 때 MainActivity가 실행됩니다. setContent를 사용하여 레이아웃을 정의하지만, 기존 뷰 시스템에서 하던 것처럼 XML 파일을 사용하는 대신 이 함수에서 구성 가능한 함수를 호출합니다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                  modifier = Modifier.fillMaxSize(),
                  color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

BasicsCodelabTheme은 구성 가능한 함수의 스타일을 지정하는 방법입니다. 이에 관한 자세한 내용은 앱 테마 설정 섹션을 참고하세요. 텍스트가 화면에 어떻게 표시되는지 확인하려면 에뮬레이터나 기기에서 앱을 실행하거나 Android 스튜디오 미리보기를 사용하면 됩니다.

Android 스튜디오 미리보기를 사용하려면 매개변수가 없는 구성 가능한 함수 또는 기본 매개변수를 포함하는 함수를 @Preview 주석으로 표시하고 프로젝트를 빌드하기만 하면 됩니다. MainActivity.kt 파일에 이미 Preview Composable 함수가 있는 것을 볼 수 있습니다. 동일한 파일에 미리보기를 여러 개 만들고 이름을 지정할 수 있습니다.

@Preview(showBackground = true, name = "Text preview")
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greeting(name = "Android")
    }
}

fb011e374b98ccff.png

Code eeacd000622ba9b.png가 선택되어 있는 경우 미리보기가 표시되지 않을 수 있습니다. 미리보기를 보려면 Split 7093def1e32785b2.png을 클릭하세요.

4. UI 조정

먼저 Greeting에 다른 배경 색상을 설정해 보겠습니다. 이렇게 하려면 Text 컴포저블을 Surface로 래핑하면 됩니다. Surface는 색상을 사용하므로 MaterialTheme.colorScheme.primary를 사용합니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier
        )
    }
}

Surface 내부에 중첩된 구성요소는 배경 색상 위에 그려집니다.

다음과 같이 미리보기에서 새로운 변경사항을 확인할 수 있습니다.

c88121ec49bde8c7.png

중요한 사항을 놓쳤을 수도 있습니다. 지금 보이는 텍스트는 흰색입니다. 언제 텍스트를 흰색으로 정의했나요?

정의하지 않았습니다. androidx.compose.material3.Surface와 같은 Material 구성요소는 앱에 넣고자 하는 공통 기능(예: 텍스트에 적절한 색상 선택)을 처리하여 더 나은 환경을 만들도록 빌드됩니다. Material은 대부분의 앱에 공통으로 적용되는 적절한 기본값과 패턴을 제공하므로 유연성이 낮은 편입니다. Compose의 Material 구성요소는 androidx.compose.foundation의 다른 기본 구성요소를 기반으로 빌드되며 이러한 구성요소는 더 많은 유연성이 필요한 경우 앱 구성요소에서 액세스할 수도 있습니다.

이 경우, Surface는 배경이 primary 색상으로 설정될 때 배경 위의 모든 텍스트가 테마에도 정의된 onPrimary 색상을 사용해야 한다는 것을 인식합니다. 이에 관한 자세한 내용은 앱 테마 설정 섹션을 참고하세요.

수정자

SurfaceText와 같은 대부분의 Compose UI 요소는 modifier 매개변수를 선택적으로 허용합니다. 수정자는 상위 요소 레이아웃 내에서 UI 요소가 배치되고 표시되고 동작하는 방식을 UI 요소에 알려줍니다. 이미 알겠지만 Greeting 컴포저블에는 이미 기본 수정자가 있으며 이 기본 수정자는 Text에 전달됩니다.

예를 들어, padding 수정자는 수정자가 데코레이션하는 요소 주변의 공간을 나타냅니다. Modifier.padding()으로 패딩 수정자를 만들 수 있습니다. 체이닝을 통해 여러 수정자를 추가할 수도 있으므로 여기서는 패딩 수정자를 기본 수정자 modifier.padding(24.dp)에 추가할 수 있습니다.

이제 화면의 Text에 패딩을 추가합니다.

import androidx.compose.foundation.layout.padding
import androidx.compose.ui.unit.dp
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}

ef14f7c54ae7edf.png

정렬, 애니메이션 처리, 배치, 클릭 가능 여부 또는 스크롤 가능 여부 지정, 변환 등에 사용할 수 있는 수십 가지의 수정자가 있습니다. 전체 목록을 확인하려면 Compose 수정자 목록을 참고하세요. 다음 단계에서는 이러한 수정자 중 일부를 사용합니다.

5. 컴포저블 재사용

UI에 추가하는 구성요소가 많을수록 생성되는 중첩 레벨이 더 많아집니다. 함수가 매우 커지면 가독성에 영향을 줄 수 있습니다. 재사용할 수 있는 작은 구성요소를 만들면 앱에서 사용하는 UI 요소의 라이브러리를 쉽게 만들 수 있습니다. 각 요소는 화면의 작은 부분을 담당하며 독립적으로 수정할 수 있습니다.

함수는 기본적으로 빈 수정자가 할당되는 수정자 매개변수를 포함하는 것이 좋습니다. 함수 내에서 호출하는 첫 번째 컴포저블로 이 수정자를 전달합니다. 이렇게 하면 호출 사이트가 구성 가능한 함수 외부에서 레이아웃 안내와 동작을 조정할 수 있습니다.

인사말이 포함된 MyApp이라는 컴포저블을 만듭니다.

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}

이를 사용하면 MyApp 컴포저블을 재사용하여 코드 중복을 피할 수 있으므로 onCreate 콜백과 미리보기를 정리할 수 있습니다.

미리보기에서 MyApp을 호출하고 미리보기의 이름을 삭제해 보겠습니다.

MainActivity.kt 파일은 다음과 같이 표시됩니다.

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.basicscodelab.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

6. 열과 행 만들기

Compose의 세 가지 기본 표준 레이아웃 요소는 Column, Row, Box입니다.

518dbfad23ee1b05.png

이러한 요소는 컴포저블 콘텐츠를 사용하는 구성 가능한 함수이므로 내부에 항목을 배치할 수 있습니다. 예를 들어 Column 내부의 각 하위 요소는 세로로 배치됩니다.

// Don't copy over
Column {
    Text("First row")
    Text("Second row")
}

이제 아래의 예와 같이 두 개의 텍스트 요소가 있는 열을 표시하도록 Greeting을 변경해 보겠습니다.

bf27ee688c3231df.png

패딩을 이동해야 할 수도 있습니다.

이 솔루션과 결과를 비교해 보세요.

import androidx.compose.foundation.layout.Column
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Column(modifier = modifier.padding(24.dp)) {
            Text(text = "Hello ")
            Text(text = name)
        }
    }
}

Compose와 Kotlin

구성 가능한 함수는 Kotlin의 다른 함수처럼 사용할 수 있습니다. 이는 UI가 표시되는 방식에 영향을 주는 구문을 추가할 수 있으므로 매우 강력한 UI를 제작할 수 있게 해줍니다.

예를 들어, for 루프를 사용하여 Column에 요소를 추가할 수 있습니다.

@Composable
fun MyApp(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

a7ba2a8cb7a7d79d.png

아직 크기를 설정하지 않았거나 컴포저블의 크기에 제약사항을 추가하지 않았으므로 각 행은 사용할 수 있는 최소 공간을 차지하며 미리보기도 같은 작업을 실행합니다. 미리보기를 변경하여 소형 스마트폰의 일반적인 너비인 320dp로 에뮬레이션해 보겠습니다. 다음과 같이 @Preview 주석에 widthDp 매개변수를 추가합니다.

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

a5d5f6cdbdd918a2.png

수정자는 Compose에서 광범위하게 사용되므로 좀 더 고급 과정의 실습을 해보겠습니다. fillMaxWidthpadding 수정자를 사용하여 다음 레이아웃을 복제해 보세요.

a9599061cf49a214.png

이제 코드를 솔루션과 비교해 보세요.

import androidx.compose.foundation.layout.fillMaxWidth

@Composable
fun MyApp(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
            Text(text = "Hello ")
            Text(text = name)
        }
    }
}

참고:

  • 예를 들어, 수정자에는 오버로드가 있을 수 있으므로 패딩을 만드는 다양한 방법을 지정할 수 있습니다.
  • 요소에 여러 수정자를 추가하려면 수정자를 연결하기만 하면 됩니다.

이러한 결과를 얻는 데는 여러 방법이 있으므로 코드가 이 스니펫과 일치하지 않는다고 해서 코드가 잘못되었다는 의미는 아닙니다. 그러나 Codelab을 계속 진행하려면 이 코드를 복사하여 붙여넣으세요.

버튼 추가

다음 단계에서는 Greeting을 펼치는 클릭 가능한 요소를 추가하므로 이 버튼을 먼저 추가해야 합니다. 목표는 다음과 같은 레이아웃을 만드는 것입니다.

ff2d8c3c1349a891.png

Button은 material3 패키지에서 제공하는 컴포저블로, 컴포저블을 마지막 인수로 사용합니다. 후행 람다는 괄호 밖으로 이동할 수 있으므로 모든 콘텐츠를 버튼에 하위 요소로 추가할 수 있습니다. 예를 들어, 다음과 같이 Text를 추가할 수 있습니다.

// Don't copy yet
Button(
    onClick = { } // You'll learn about this callback later
) {
    Text("Show less")
}

이렇게 하려면 컴포저블을 행 끝에 배치하는 방법을 알아야 합니다. alignEnd 수정자가 없으므로 시작 시 컴포저블에 약간의 weight을 제공합니다. weight 수정자는 요소를 유연하게 만들기 위해 가중치가 없는 다른 요소(유연성 부족이라고 함)를 효과적으로 밀어내어 요소의 사용 가능한 모든 공간을 채웁니다. 이 수정자는 fillMaxWidth 수정자와 중복되기도 합니다.

이제 버튼을 추가하고 이전 이미지에 표시된 것처럼 버튼을 배치합니다.

여기에서 솔루션을 확인하세요.

import androidx.compose.foundation.layout.Row
import androidx.compose.material3.ElevatedButton
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { /* TODO */ }
            ) {
                Text("Show more")
            }
        }
    }
}

7. Compose에서의 상태

이 섹션에서는 화면에 약간의 상호작용을 추가합니다. 지금까지는 정적 레이아웃을 만들었지만, 이제는 사용자 변경사항에 반응하여 화면과 상호작용할 수 있게 합니다.

6675d41779cac69.gif

버튼을 클릭할 수 있게 만드는 방법과 항목의 크기를 조절하는 방법을 알아보기 전에 각 항목이 펼쳐진 상태인지를 가리키는 값을 어딘가에 저장해야 합니다. 이 값을 항목의 상태라고 합니다. 인사말마다 이러한 값 중 하나가 필요하므로 이 값의 논리적 위치는 Greeting 컴포저블에 있습니다. 이 불리언 값 expanded와 이 값이 코드에서 사용되는 방식을 살펴보세요.

// Don't copy over
@Composable
fun Greeting(name: String) {
    var expanded = false // Don't do this!

    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

onClick 작업과 동적 버튼 텍스트도 추가했습니다. 이에 관한 설명은 나중에 자세히 다룹니다.

하지만, 이 작업은 예상대로 작동하지 않습니다. expanded 변수에 다른 값을 설정해도 Compose에서 이 값을 상태 변경으로 감지하지 않으므로 아무 일도 일어나지 않습니다.

이 변수를 변경했을 때 리컴포지션을 트리거하지 않는 이유는 이 변수를 Compose에서 추적하고 있지 않기 때문입니다. 또한, Greeting이 호출될 때마다 변수가 거짓으로 재설정됩니다.

컴포저블에 내부 상태를 추가하려면 mutableStateOf 함수를 사용하면 됩니다. 이 함수를 사용하면 Compose가 이 State를 읽는 함수를 재구성합니다.

import androidx.compose.runtime.mutableStateOf
// ...

// Don't copy over
@Composable
fun Greeting() {
    val expanded = mutableStateOf(false) // Don't do this!
}

하지만, 컴포저블 내의 변수에 mutableStateOf를 할당하기만 할 수는 없습니다. 앞에서 설명한 것처럼 false 값을 가진 변경 가능한 새 상태로 상태를 재설정하여 컴포저블을 다시 호출하는 때는 언제든지 리컴포지션이 일어날 수 있습니다.

여러 리컴포지션 간에 상태를 유지하려면 remember를 사용하여 변경 가능한 상태를 기억해야 합니다.

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// ...

@Composable
fun Greeting(...) {
    val expanded = remember { mutableStateOf(false) }
    // ...
}

remember는 리컴포지션을 방지하는 데 사용되므로 상태가 재설정되지 않습니다.

화면의 서로 다른 부분에서 동일한 컴포저블을 호출하는 경우 자체 상태 버전을 가진 UI 요소를 만듭니다. 내부 상태는 클래스의 비공개 변수로 보면 됩니다.

구성 가능한 함수는 상태를 자동으로 '구독'합니다. 상태가 변경되면 이러한 필드를 읽는 컴포저블이 재구성되어 업데이트를 표시합니다.

상태 변경 및 상태 변경사항에 반응

상태를 변경하기 위해 ButtononClick이라는 매개변수를 사용한다고 알고 있을 수도 있지만, 값을 사용하지 않고 함수를 사용합니다.

작업에 람다 표현식을 할당하여 클릭 시 실행할 작업을 정의할 수 있습니다. 예를 들어, 펼침 상태의 값을 전환하고 값에 따라 다른 텍스트를 표시해 보겠습니다.

ElevatedButton(
    onClick = { expanded.value = !expanded.value },
) {
   Text(if (expanded.value) "Show less" else "Show more")
}

앱을 대화형 모드로 실행하여 동작을 확인합니다.

374998ad358bf8d6.png

버튼을 클릭하면 expanded가 전환되어 버튼 내부의 텍스트 리컴포지션을 트리거합니다. 각 Greeting은 서로 다른 UI 요소에 속하기 때문에 자체적으로 펼쳐진 상태를 유지합니다.

93d839b53b7d9bea.gif

이 지점까지의 코드는 다음과 같습니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val expanded = remember { mutableStateOf(false) }
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

항목 펼치기

이제 실제로 요청을 받은 경우 항목을 펼쳐 보겠습니다. 상태에 따라 달라지는 추가 변수를 추가합니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp
// ...

extraPadding은 간단한 계산을 실행하므로 리컴포지션에 대비하여 이 값을 기억할 필요가 없습니다.

따라서, 이제 Column에 새로운 패딩 수정자를 적용할 수 있습니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val expanded = remember { mutableStateOf(false) }
    val extraPadding = if (expanded.value) 48.dp else 0.dp
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(
                modifier = Modifier
                    .weight(1f)
                    .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

에뮬레이터나 대화형 모드에서 실행하는 경우 각 항목이 독립적으로 펼쳐질 수 있어야 합니다.

6675d41779cac69.gif

8. 상태 호이스팅

구성 가능한 함수에서 여러 함수가 읽거나 수정하는 상태는 공통의 상위 항목에 위치해야 합니다. 이 프로세스를 상태 호이스팅이라고 합니다. 호이스팅이란 들어 올린다 또는 끌어올린다라는 의미입니다.

상태를 호이스팅할 수 있게 만들면 상태가 중복되지 않고 버그가 발생하는 것을 방지할 수 있으며 컴포저블을 재사용할 수 있고 훨씬 쉽게 테스트할 수 있습니다. 이에 반하여, 컴포저블의 상위 요소에서 제어할 필요가 없는 상태는 호이스팅되면 안 됩니다. 정보 소스는 상태를 생성하고 관리하는 대상에 속합니다.

예를 들어, 앱의 온보딩 화면을 만들어 보겠습니다.

5d5f44508fcfa779.png

다음 코드를 MainActivity.kt에 추가합니다.

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
// ...

@Composable
fun OnboardingScreen(modifier: Modifier = Modifier) {
    // TODO: This state should be hoisted
    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = { shouldShowOnboarding = false }
        ) {
            Text("Continue")
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen()
    }
}

이 코드에는 여러 가지 새로운 기능이 포함되어 있습니다.

  • OnboardingScreen이라는 새 컴포저블과 새 미리보기를 추가했습니다. 프로젝트를 빌드하면 동시에 여러 개의 미리보기가 있을 수 있습니다. 또한, 콘텐츠가 정확하게 정렬되는지 확인할 수 있도록 고정된 높이를 추가했습니다.
  • 화면 중앙에 콘텐츠를 표시할 수 있도록 Column을 구성할 수 있습니다.
  • shouldShowOnboarding= 대신 by 키워드를 사용하고 있습니다. 이 키워드는 매번 .value를 입력할 필요가 없도록 해주는 속성 위임입니다.
  • 버튼을 클릭하면 shouldShowOnboardingfalse로 설정되지만, 아직 어디에서도 이 상태를 읽지 않습니다.

이제 앱에 이 새로운 온보딩 화면을 추가할 수 있습니다. 시작 시 이 화면을 표시하고 사용자가 'Continue'를 누르면 숨깁니다.

Compose에서는 UI 요소를 숨기지 않습니다. 대신, 컴포지션에 UI 요소를 추가하지 않으므로 Compose가 생성하는 UI 트리에 추가되지 않습니다. 간단한 조건부 Kotlin 로직을 사용하여 이 작업을 실행합니다. 예를 들어, 온보딩 화면이나 인사말 목록을 표시하려면 다음과 같이 합니다.

// Don't copy yet
@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(modifier) {
        if (shouldShowOnboarding) { // Where does this come from?
            OnboardingScreen()
        } else {
            Greetings()
        }
    }
}

하지만, shouldShowOnboarding에 액세스할 수 없습니다. OnboardingScreen에서 만든 상태를 MyApp 컴포저블과 공유해야 합니다.

여기서는 상태 값을 상위 요소와 공유하는 대신 상태를 호이스팅합니다. 즉, 상태 값에 액세스해야 하는 공통 상위 요소로 상태 값을 이동하기만 하면 됩니다.

먼저 MyApp의 콘텐츠를 Greetings라는 새 컴포저블로 이동합니다. 대신 Greetings 메서드를 호출하도록 미리보기를 조정합니다.

@Composable
fun MyApp(modifier: Modifier = Modifier) {
     Greetings()
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingsPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

새로운 최상위 MyApp 컴포저블의 미리보기를 추가하여 동작을 테스트할 수 있습니다.

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

이제 MyApp에 다른 화면을 표시하는 로직을 추가하고 상태를 호이스팅합니다.

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(/* TODO */)
        } else {
            Greetings()
        }
    }
}

또한, shouldShowOnboarding을 온보딩 화면과 공유해야 하지만, 직접 전달하지는 않습니다. OnboardingScreen이 상태를 변경하도록 하는 대신 사용자가 Continue 버튼을 클릭했을 때 앱에 알리도록 하는 것이 더 좋습니다.

이벤트는 어떻게 전달할까요? 아래로 콜백을 전달합니다. 콜백은 다른 함수에 인수로 전달되는 함수로 이벤트가 발생하면 실행됩니다.

MyApp의 상태를 변경할 수 있도록 onContinueClicked: () -> Unit으로 정의된 온보딩 화면에 함수 매개변수를 추가해 보세요.

해결 방법:

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier
                .padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }

}

이 방법은 상태가 아닌 함수를 OnboardingScreen에 전달하는 방식으로 이 컴포저블의 재사용 가능성을 높이고 다른 컴포저블이 상태를 변경하지 않도록 보호하고 있습니다. 일반적으로 이 방식은 작업을 간단하게 유지합니다. 다음은 이제 OnboardingScreen을 호출하기 위해 온보딩 미리보기를 어떻게 수정해야 하는지 보여주는 좋은 예입니다.

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {}) // Do nothing on click.
    }
}

onContinueClicked를 빈 람다 표현식에 할당하는 것은 '아무 작업도 하지 않음'을 의미합니다. 이는 미리보기를 위해 완벽합니다.

아래 그림은 실제 앱에 더 가깝습니다. 수고하셨습니다.

25915eb273a7ef49.gif

MyApp 컴포저블에서 매번 값을 사용하지 않도록 by 속성 위임을 처음으로 사용했습니다. expanded 속성의 Greeting 컴포저블에서도 = 대신 by를 사용해 보겠습니다. expandedval에서 var로 변경해야 합니다.

지금까지의 전체 코드는 다음과 같습니다.

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by remember { mutableStateOf(false) }

    val extraPadding = if (expanded) 48.dp else 0.dp

    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(
                modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

9. 성능 지연 목록 만들기

이제 이름을 더 현실적으로 나열해 보겠습니다. 지금까지 Column에 두 개의 인사말을 표시했습니다. 하지만 수천 개의 인사말을 처리할 수 있을까요?

목록 크기를 설정하고 람다에 포함된 값으로 목록을 채우도록 허용하는 다른 목록 생성자를 사용하기 위해 Greetings 매개변수의 기본 목록 값을 변경합니다(여기서 $it은 목록 색인을 나타냄).

names: List<String> = List(1000) { "$it" }

이렇게 하면 화면에 맞지 않는 인사말을 포함하여 1,000개의 인사말이 생성됩니다. 이는 분명히 성능 기준에 맞지는 않습니다. 에뮬레이터에서 실행해 볼 수 있습니다(경고: 이 코드로 인해 에뮬레이터가 중단될 수도 있음).

스크롤이 가능한 열을 표시하기 위해 LazyColumn을 사용합니다. LazyColumn은 화면에 보이는 항목만 렌더링하므로 항목이 많은 목록을 렌더링할 때 성능이 향상됩니다.

기본적인 사용법으로 LazyColumn API는 범위 내에서 items 요소를 제공하며, 여기서 로직을 렌더링하는 개별 항목은 다음과 같이 작성됩니다.

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
// ...

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

284f925eb984fb56.gif

10. 상태 유지

앱에는 두 가지 문제가 있습니다.

온보딩 화면 상태 유지

기기에서 앱을 실행하고 버튼을 클릭한 다음 회전하면 온보딩 화면이 다시 표시됩니다. remember 함수는 컴포저블이 컴포지션에 유지되는 동안에만 작동합니다. 기기를 회전하면 전체 활동이 다시 시작되므로 모든 상태가 손실됩니다. 이 현상은 구성이 변경되거나 프로세스가 중단될 때도 발생합니다.

remember를 사용하는 대신 rememberSaveable을 사용하면 됩니다. 이 함수는 구성 변경(예: 회전)과 프로세스 중단에도 각 상태를 저장합니다.

이제 shouldShowOnboarding에서 rememberrememberSaveable로 교체하여 사용합니다.

    import androidx.compose.runtime.saveable.rememberSaveable
    // ...

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

실행 또는 회전하거나 어두운 모드로 변경 또는 프로세스를 종료해 봅니다. 이전에 앱을 종료하지 않았다면 온보딩 화면이 표시되지 않습니다.

목록 항목의 펼쳐진 상태 유지

목록 항목을 펼친 다음 항목이 보이지 않을 때까지 목록을 스크롤하거나, 기기를 회전한 다음 펼쳐진 항목으로 돌아가면 이제 항목이 초기 상태로 돌아온 것을 확인할 수 있습니다.

이 문제의 해결 방법은 펼쳐진 상태에도 rememberSaveable을 사용하는 것입니다.

   var expanded by rememberSaveable { mutableStateOf(false) }

지금까지 약 120줄의 코드를 이용하여 각자의 상태를 보유하는 여러 항목으로 구성된 긴 스크롤 목록을 성능 기준에 맞게 표시할 수 있었습니다. 또한, 위에서 볼 수 있는 바와 같이 앱은 코드를 추가하지 않고 완벽하게 올바른 어두운 모드를 표시합니다. 테마 설정에 관해서는 나중에 알아보겠습니다.

11. 목록에 애니메이션 적용

Compose에서는 여러 가지 방법으로 UI에 애니메이션을 지정할 수 있습니다. 간단한 애니메이션을 위한 상위 수준의 API에서 전체 제어 및 복잡한 전환을 위한 하위 수준의 메서드까지 다양한 방법이 있습니다. 자세한 내용은 문서를 참고하세요.

이 섹션에서는 하위 수준의 API 중 하나를 사용하지만 걱정하지 않아도 됩니다. 매우 간단할 수도 있습니다. 이미 구현한 크기 변경에 애니메이션을 적용해 보겠습니다.

9efa14ce118d3835.gif

이를 위해 animateDpAsState 컴포저블을 사용합니다. 이 컴포저블은 애니메이션이 완료될 때까지 애니메이션에 의해 객체의 value가 계속 업데이트되는 상태 객체를 반환합니다. 유형이 Dp인 '목표 값'을 사용합니다.

펼쳐진 상태에 따라 달라지는 extraPadding을 만들고 애니메이션을 적용합니다.

import androidx.compose.animation.core.animateDpAsState

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp
    )
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }

        }
    }
}

앱을 실행하고 애니메이션을 시도해 보세요.

animateDpAsState는 애니메이션을 맞춤설정할 수 있는 animationSpec 매개변수를 선택적으로 사용합니다. 스프링 기반의 애니메이션 추가와 같이 더 재미있는 작업을 해보겠습니다.

import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )

    Surface(
    // ...
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))

    // ...

    )
}

참고로, 패딩이 음수가 되지 않도록 해야 합니다. 패딩이 음수가 되면 앱이 다운될 수 있습니다. 이로 인해 미세한 애니메이션 버그가 발생하며 이 버그는 추후 설정 완료에서 수정할 예정입니다.

spring 사양은 시간과 관련된 매개변수를 사용하지 않습니다. 대신, 물리적 속성(감쇠 및 강성)을 사용하여 애니메이션을 더 자연스럽게 만듭니다. 이제 앱을 실행하여 새 애니메이션을 사용해 보세요.

9efa14ce118d3835.gif

animate*AsState를 사용하여 만든 애니메이션은 중단될 수 있습니다. 즉, 애니메이션 중간에 목표 값이 변경되면 animate*AsState는 애니메이션을 다시 시작하고 새 값을 가리킵니다. 특히 스프링 기반 애니메이션에서는 중단이 자연스럽게 보입니다.

d5dbf92de69db775.gif

다양한 유형의 애니메이션을 살펴보려면 다양한 spring용 매개변수, 다양한 사양(tween, repeatable), 다양한 함수(animateColorAsState 또는 다양한 유형의 Animation API)를 사용해 보세요.

이 섹션의 전체 코드

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }

}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

12. 앱의 스타일 지정 및 테마 설정

지금까지 컴포저블의 스타일을 지정하지 않았지만 어두운 모드 지원을 비롯한 적절한 기본값을 얻었습니다. 이제 BasicsCodelabThemeMaterialTheme이 무엇인지 살펴보겠습니다.

ui/theme/Theme.kt 파일을 열면 BasicsCodelabTheme이 구현에서 MaterialTheme을 사용하는 것을 확인할 수 있습니다.

// Do not copy
@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    // ...

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

MaterialThemeMaterial 디자인 사양의 스타일 지정 원칙을 반영한 구성 가능한 함수입니다. 스타일 지정 정보는 content의 내부에 있는 구성요소로 하향 적용됩니다. 이러한 구성요소는 이 정보를 읽어 자신의 스타일을 지정합니다. UI에서는 이미 다음과 같이 BasicsCodelabTheme을 사용하고 있습니다.

    BasicsCodelabTheme {
        MyApp(modifier = Modifier.fillMaxSize())
    }

BasicsCodelabThemeMaterialTheme을 내부적으로 래핑하므로 MyApp은 테마에 정의된 속성으로 스타일이 지정됩니다. 모든 하위 컴포저블에서 MaterialTheme의 세 가지 속성, colorScheme, typography, shapes를 가져올 수 있습니다. 이러한 속성을 사용하여 Text 중 하나에 헤더 스타일을 설정하세요.

            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
            ) {
                Text(text = "Hello, ")
                Text(text = name, style = MaterialTheme.typography.headlineMedium)
            }

위의 예에서 Text 컴포저블은 새 TextStyle을 설정합니다. 고유한 TextStyle을 만들 수도 있고 기본인 MaterialTheme.typography를 사용하여 테마가 정의된 스타일을 가져올 수도 있습니다. 이 구성을 사용하면 Material에 정의된 텍스트 스타일(displayLarge, headlineMedium, titleSmall, bodyLarge, labelMedium 등)에 액세스할 수 있습니다. 이 예제에서는 테마에 정의된 headlineMedium 스타일을 사용합니다.

이제 빌드하여 새롭게 스타일이 지정된 텍스트를 확인합니다.

673955c38b076f1c.png

일반적으로 MaterialTheme 내부의 색상, 모양, 글꼴 스타일을 유지하는 것이 훨씬 좋습니다. 예를 들어, 어두운 모드는 색상을 하드 코딩하는 경우 구현하기 어렵고 오류가 발생하기 쉬운 작업이 많이 요구됩니다.

그러나 가끔 색상과 글꼴 스타일의 선택에서 약간 벗어나야 할 때도 있습니다. 이러한 상황에서는 기존에 사용하고 있는 색상이나 스타일을 기반으로 하는 것이 좋습니다.

이를 위해 copy 함수를 사용하여 미리 정의된 스타일을 수정할 수 있습니다. 다음과 같이 숫자를 더 굵게 만들어 보세요.

import androidx.compose.ui.text.font.FontWeight
// ...
Text(
    text = name,
    style = MaterialTheme.typography.headlineMedium.copy(
        fontWeight = FontWeight.ExtraBold
    )
)

이런 방법으로 글꼴 모음이나 다른 headlineMedium 속성을 변경해야 하면 작은 편차는 걱정하지 않아도 됩니다.

이제 미리보기 창에 다음과 같은 결과가 표시됩니다.

b33493882bda9419.png

어두운 모드 미리보기 설정

현재 미리보기에는 밝은 모드에서의 앱 모양만 표시됩니다. GreetingPreviewUI_MODE_NIGHT_YES와 함께 @Preview 주석을 추가합니다.

import android.content.res.Configuration.UI_MODE_NIGHT_YES

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "GreetingPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

이렇게 하면 어두운 모드의 미리보기가 추가됩니다.

2c94dc7775d80166.png

앱 테마 조정

ui/theme 폴더에 있는 파일에서 현재 테마와 관련된 모든 항목을 찾을 수 있습니다. 예를 들어, 지금까지 사용한 기본 색상은 Color.kt에 정의되어 있습니다.

새로운 색상을 정의하는 것부터 시작해 보겠습니다. Color.kt에 다음을 추가합니다.

val Navy = Color(0xFF073042)
val Blue = Color(0xFF4285F4)
val LightBlue = Color(0xFFD7EFFE)
val Chartreuse = Color(0xFFEFF7CF)

이제 Theme.kt에서 MaterialTheme 팔레트에 색상을 할당합니다.

private val LightColorScheme = lightColorScheme(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

MainActivity.kt로 돌아가서 미리보기를 새로고침해도 미리보기 색상은 실제로 변경되지 않습니다. 미리보기에서 기본적으로 동적 색상이 사용되기 때문입니다. dynamicColor 불리언 매개변수를 사용하여 Theme.kt에서 동적 색상을 추가하는 로직을 확인할 수 있습니다.

색 구성표의 비 적응형 버전을 보려면 API 수준이 31(적응형 색이 도입된 Android S에 해당)보다 낮은 기기에서 앱을 실행하세요. 다음과 같은 새로운 색상이 표시됩니다.

493d754584574e91.png

Theme.kt에서 어두운 색상을 위한 팔레트를 정의합니다.

private val DarkColorScheme = darkColorScheme(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

이제 앱을 실행하면 어두운 색상이 작동하는 것을 확인할 수 있습니다.

84d2a903ffa6d8df.png

Theme.kt의 최종 코드

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat

private val DarkColorScheme = darkColorScheme(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

private val LightColorScheme = lightColorScheme(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
            ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
        }
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

13. 설정 완료

이 단계에서는 이미 알고 있는 내용을 적용하고 몇 가지 힌트만 사용하여 새로운 개념을 학습합니다. 다음과 같이 만들어 보겠습니다.

8d24a786bfe1a8f2.gif

버튼을 아이콘으로 대체

  • 하위 요소인 Icon과 함께 IconButton 컴포저블을 사용합니다.
  • Icons.Filled.ExpandLessIcons.Filled.ExpandMore를 사용합니다. 이는 material-icons-extended 아티팩트에서 사용할 수 있습니다. app/build.gradle.kts 파일의 종속 항목에 다음 줄을 추가합니다.
implementation("androidx.compose.material:material-icons-extended")
  • 정렬을 고정하기 위해 패딩을 수정합니다.
  • 접근성을 위해 콘텐츠 설명을 추가합니다(아래 '문자열 리소스 사용' 참고).

문자열 리소스 사용

'더보기' 및 '간략히 보기'를 위한 콘텐츠 설명이 있어야 하며 간단한 if 문을 사용하여 설명을 추가할 수 있습니다.

contentDescription = if (expanded) "Show less" else "Show more"

하지만 문자열을 하드 코딩하는 것은 바람직하지 않으며 strings.xml 파일에서 이러한 문자열을 가져와야 합니다.

이를 자동으로 하려면 Android 스튜디오의 'Context Actions'에서 사용할 수 있는 'Extract string resource'를 각 문자열에 사용하면 됩니다.

또는 app/src/res/values/strings.xml을 열고 다음 리소스를 추가합니다.

<string name="show_less">Show less</string>
<string name="show_more">Show more</string>

더보기

각 카드의 크기 변경이 트리거되면 'Composem ipsum' 텍스트가 표시되고 사라집니다.

  • 항목을 펼칠 때 표시되는 Greeting 내부의 Column에 새로운 Text를 추가합니다.
  • extraPadding을 삭제하고 대신 animateContentSize 수정자를 Row에 적용합니다. 그러면 애니메이션 생성 프로세스가 자동화되고 이를 수동으로 실행하기가 어려워집니다. 또한 coerceAtLeast가 더 이상 필요하지 않습니다.

고도 및 도형 추가

  • shadow 수정자를 clip 수정자와 함께 사용하여 카드 스타일을 만들 수 있습니다. 그러나 이를 정확하게 실행하는 Material 컴포저블(Card)이 있습니다. CardDefaults.cardColors를 호출하고 변경하려는 색상을 재정의하여 Card의 색상을 변경할 수 있습니다.

최종 코드

package com.example.basicscodelab

import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.basicscodelab.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    Surface(modifier, color = MaterialTheme.colorScheme.background) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {
    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.primary
        ),
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        CardContent(name)
    }
}

@Composable
private fun CardContent(name: String) {
    var expanded by rememberSaveable { mutableStateOf(false) }

    Row(
        modifier = Modifier
            .padding(12.dp)
            .animateContentSize(
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioMediumBouncy,
                    stiffness = Spring.StiffnessLow
                )
            )
    ) {
        Column(
            modifier = Modifier
                .weight(1f)
                .padding(12.dp)
        ) {
            Text(text = "Hello, ")
            Text(
                text = name, style = MaterialTheme.typography.headlineMedium.copy(
                    fontWeight = FontWeight.ExtraBold
                )
            )
            if (expanded) {
                Text(
                    text = ("Composem ipsum color sit lazy, " +
                        "padding theme elit, sed do bouncy. ").repeat(4),
                )
            }
        }
        IconButton(onClick = { expanded = !expanded }) {
            Icon(
                imageVector = if (expanded) Filled.ExpandLess else Filled.ExpandMore,
                contentDescription = if (expanded) {
                    stringResource(R.string.show_less)
                } else {
                    stringResource(R.string.show_more)
                }
            )
        }
    }
}

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "GreetingPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

14. 축하합니다

축하합니다. Compose의 기본사항을 살펴보았습니다.

Codelab 솔루션

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

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

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

다음 단계

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

추가 자료