[Jetpack Compose] 2. 첫 번째 Compose 앱 만들기

akim·2023년 4월 11일
0

Jetpack Compose

목록 보기
2/2
post-thumbnail

1. 시작하기 전에

Jetpack Compose는 UI 개발을 간소화하기 위해 설계된 최신 툴킷이다.

이는 선언적인 접근 방식을 사용하며, 데이터를 UI 계층 구조로 변환하는 일련의 함수를 호출하여 UI를 설명한다. 기본 데이터가 변경되면 프레임워크가 이러한 함수를 자동으로 다시 실행하여 UI 계층 구조를 업데이트한다.

Compose 앱은 구성 가능한 함수로 구성된다.

  • 구성 가능한 함수는 @Composable 이라고 표시된 일반 함수이며, 다른 구성 가능한 함수를 호출할 수 있다.
  • 새로운 UI 구성요소를 만들기 위해서는 함수만 있으면 된다.
  • 애노테이션은 지속적으로 UI를 업데이트하고 유지관리하기 위해 함수에 특수 지원을 추가하도록 Compose에 알려주는 역할을 한다.
UI 구성요소 == 구성 가능한 함수 == 컴포저블

2. 새 Compose 프로젝트 시작

Empty Compose Activity(Material3)를 선택하여 프로젝트를 생성하고, minimumSdkVersion으로 API 수준 21(Compose에서 지원하는 최소 API 수준) 이상을 선택한다.

기본 프로젝트는 아래 내용을 이미 포함하고 있다.

AndroidManifest.xml 파일

  • 앱 구성요소
  • 인텐트 필터
  • 아이콘 및 라벨
  • 권한
  • 기기 호환성

build.gradle 및 app/build.gradle 파일

  • Compose에 필요한 옵션 및 dependency가 포함되어 있음

MainActivity.kt

  • 기본 텍스트, 테마, 프리뷰 등의 코드를 포함하고 있음

3. Compose 시작하기

구성 가능한 함수

구성 가능한 함수는 @Composable 이라는 주석이 달린 일반 함수다.

@Composable
private fun Greeting(name: String) {
   Text(text = "Hello $name!")
}
  • Greeting 함수는 지정된 입력 String 을 표시하는 UI 계층 구조를 생성한다.
  • Text 는 라이브러리에서 제공하는 구성 가능한 함수다.

Android 앱의 Compose

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") // 구성 가능한 함수 Greeting
                }
            }
        }
    }
}
  • xml 파일을 사용하는 대신 setContent 함수에서 구성 가능한 함수를 호출하여 레이아웃을 정의한다.
  • BasicsCodelabTheme 은 구성 가능한 함수의 스타일을 지정하는 방법이다.

@Preview(showBackground = true, name = "Text preview")
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        Greeting(name = "Android")
    }
}
  • Android 스튜디오 미리보기를 사용하려면 구성 가능한 함수를 @Preview 주석으로 표시하고 프로젝트를 빌드(⌘+⌥+⇧+R)하기만 하면 된다. (애뮬레이터나 기기에서 앱을 실행할 필요가 없다!)
  • 동일한 파일에 미리보기를 여러 개 만들고 이름을 지정할 수 있다.


4. UI 조정

Greeting 에 다른 배경 색상을 설정해보자.

@Composable
private fun Greeting(name: String) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text (text = "Hello $name!")
    }
}
  • Text 컴포저블을 Surface 로 래핑한다.
  • Surfacecolor 를 사용하므로 MaterialTheme.colorScheme.primary 를 써준다.
  • Surface 내부에 중첩된 구성요소(Text 등)는 배경 색상 위에 그려진다.
* Surface 및 MaterialTheme은 Material Design과 관련된 개념이며, 
Material Design은 사용자 인터페이스와 환경을 만드는 데 도움을 주기 위해 
Google에서 만든 디자인 시스템이다.


modifier (수정자)

SurfaceText와 같은 대부분의 Compose UI 요소는 modifier 매개변수를 선택적으로 허용한다.

수정자는 상위 요소 레이아웃 내에서 UI 요소가 배치되고 표시되고 동작하는 방식을 UI 요소에 알려준다.

@Composable
private fun Greeting(name: String) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(text = "Hello $name!", modifier = Modifier.padding(24.dp))
    }
}
  • padding 수정자는 수정자가 데코레이션하는 요소 주변의 공간을 나타낸다.


5. 컴포저블 재사용

UI에 추가하는 구성요소가 많을수록 생성되는 중첩 레벨이 더 많아진다.

함수가 매우 커지면 가독성에 영향을 줄 수 있으므로 재사용할 수 있는 작은 구성요소를 만들어 사용해보도록 하자. 각 요소는 화면의 작은 부분을 담당하며 독립적으로 수정할 수 있다.

함수는 기본적으로 빈 수정자가 할당되는 수정자 매개변수를 포함하는 것이 좋다.

함수 내에서 호출하는 첫 번째 컴포저블로 이 수정자를 전달한다. 그러면 호출 사이트(함수를 호출하는 코드가 위치한 곳)가 구성 가능한 함수 외부에서 레이아웃 안내와 동작을 조정할 수 있다.

인사말이 포함된 MyApp 이라는 컴포저블을 만들어보자.


class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
            	// 뷰를 그리는 코드를 중복 작성하지 않고 MyApp을 호출함
                MyApp(modifier = Modifier.fillMaxSize()) 
            }
        }
    }
}

@Composable
private fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}
  • MyApp 컴포저블을 재사용하여 코드 중복을 피할 수 있다.
  • onCreate 콜백과 미리보기를 정리할 수 있다.

풀어서 설명하자면,
MyApp 컴포저블을 사용하여 화면을 구성하고 onCreate 콜백에서 MyApp 컴포저블을 호출하여 뷰를 생성하는 코드 중복을 피할 수 있다.
또한, MyApp 컴포저블을 미리보기(preview)에 사용하여 실시간으로 화면을 미리 확인할 수도 있다.


6. 열과 행 만들기

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

이러한 요소는 컴포저블 콘텐츠를 사용하는 구성 가능한 함수이므로 내부에 항목을 배치할 수 있다.

예를 들어 Column 내부의 각 하위 요소는 세로로 배치된다.

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

  • Column 내부의 하위 요소 text 두 개가 세로로 배치되었다.

Compose와 Kotlin

구성 가능한 함수는 Kotlin의 다른 함수처럼 사용할 수 있다.

이는 UI가 표시되는 방식에 영향을 주는 구문(if, for 등)을 추가할 수 있으므로 매우 강력한 UI를 제작할 수 있게 해준다.

@Composable
fun MyApp(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}
  • for 루프를 사용해 list에 있는 값에 접근하여 Column 에 요소를 추가할 수 있다.

아직 크기를 설정하거나 크기에 대한 제약사항을 추가하지 않았으므로 각 행은 사용할 수 있는 최소 공간을 차지한다.

미리보기를 변경하면 스마트폰 사이즈에 맞춰 dp를 변경할 수 있다.

@Preview(showBackground = true, widthDp = 320) // widthDp 설정
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

또한 fillMaxWidthpadding 수정자를 사용하여 레이아웃을 꽉 차게 만들 수 있다.

@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
private fun Greeting(name: String) {
    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)
        }
    }
}


버튼 추가

Button 은 material3 패키지에서 제공하는 컴포저블로, 컴포저블을 마지막 인수로 사용한다.

컴포저블을 마지막 인수로 사용하는 Button 과 같은 후행 람다는 괄호 밖으로 이동할 수 있으므로 모든 콘텐츠를 버튼에 하위 요소로 추가할 수 있다.

* 후행 람다: 함수의 매개변수 중 마지막 매개변수가 함수일 경우, 해당 함수를 괄호 밖으로 빼내어 쓸 수 있는 문법

// 함수 선언
fun printNumbers(numbers: List<Int>, action: (Int) -> Unit) {
	for (number in numbers) {
    	action(number)
	}
}

// 함수 호출
printNumbers(listOf(1, 2, 3)) { println(it) }

위 함수는 마지막 매개변수 action이 함수형 매개변수 이므로, 람다식으로 대체하여 호출할 수 있으며, 이때 람다식은 괄호 밖으로 뺄 수 있다.

따라서 { println(it) }과 같이 괄호 밖으로 println(it)을 빼서 호출할 수 있다.

람다식과 관련한 자세한 내용은 [Kotlin in Action] 5. 람다로 프로그래밍 포스팅 참고


아래와 같이 Button 을 사용한 뷰를 만들어보자.

이렇게 하려면 컴포저블을 행 끝에 배치해야 한다. 따라서 코드를 새로 작성해보면 아래와 같다.

@Composable
private fun Greeting(name: String) {

    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")
            }
        }
    }
}
  • alignEnd 와 같은 수정자는 없으므로 컴포저블의 시작 부분에 약간의 weight을 준다.

weight 수정자는 요소를 유연하게 만들기 위해 가중치가 없는 다른 요소(유연성 부족이라고 함)를 밀어내고 사용 가능한 모든 공간을 채운다. (fillMaxWidth 수정자와 중복되기도 한다.)


7. Compose에서의 상태

화면에 약간의 상호작용을 추가하여 정적 레이아웃이 아니라 사용자 변경사항에 반응할 수 있는 레이아웃을 만들어보자.

각 항목이 펼쳐진 상태인지를 가리키는 값을 어딘가에 저장해야 한다. 이 값을 항목의 상태 라고 한다.

화면의 각 인사말마다 이러한 값이 필요하므로 이 값은 Greeting 컴포저블에 위치하면 된다.

@Composable
private 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")
            }
        }
    }
}
  • Booleanexpanded 추가되어 펼쳐진 상태를 나타내고 있다.

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

Compose 앱은 기본적으로 구성 가능한 함수를 호출하여 데이터를 UI로 변환한다. 이때 데이터가 변경되면 Compose는 새 데이터로 이러한 함수를 다시 실행하여 업데이트된 UI를 만든다. 이를 Recomposition 이라고 한다.

Compose는 데이터가 변경된 구성요소만 리컴포지션하고 영향을 받지 않는 구성요소는 리컴포지션없이 건너뛸 수 있도록 개별 컴포저블에서 필요한 데이터를 확인한다.

이러한 리컴포지션의 로직을 고려해보았을 때, 위 함수에서 리컴포지션을 트리거하지 않는 이유는 이 변수를 Compose에서 추적하고 있지 않기 때문이다.

또한, Greeting이 호출될 때마다 변수가 거짓으로 재설정된다.


컴포저블에 내부 상태를 추가하려면 mutableStateOf 함수를 사용하면 된다.

이 함수를 사용하면 Compose가 이 State 를 읽는 함수를 재구성한다.

(StateMutableState 는 어떤 값을 보유하고 그 값이 변경될 때마다 UI 업데이트(리컴포지션)을 트리거하는 인터페이스이다.)

하지만 컴포저블 내의 변수에 mutableStateOf 를 할당하기만 할 수는 없다.

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

앞서 설명한 것처럼 false 값을 가진 mutable한 새로운 상태로 상태를 재설정하여 컴포저블을 다시 호출하면 언제든지 리컴포지션이 일어날 수 있다. Compose에서는 상태를 변경하는 것이 UI 업데이트를 유발하기 때문이다.

위 코드에서는 전환된 상태를 유지하지 못하고 리컴포지션 시에 계속 false로 값이 재설정 되고 있다.

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

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

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

  • 내부 상태는 클래스의 private 변수로 보면 된다.

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

상태를 변경하기 위해 그냥 onClick 과 같은 매개변수 값을 사용하지 않고, 함수를 사용한다.

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

ElevatedButton(
    onClick = { expanded.value = !expanded.value }, // 이 expanded 값은 remember로 설정되어 있다.
) {
   Text(if (expanded.value) "Show less" else "Show more")
}
  • 버튼을 클릭하면 expanded 가 전환되어 버튼 내부의 텍스트 리컴포지션이 트리거된다.
  • Greeting 은 서로 다른 UI 요소에 속하므로 독립적으로 펼쳐진 상태를 유지한다.

interactive mode를 사용하면 애뮬레이터를 실행시키지 않고도 상호작용을 바로 확인해볼 수 있다! ˗ˋˏ와ˎˊ˗


항목 펼치기

이제 글자를 변경시키는 것 뿐만 아니라 해당 항목을 펼쳐보자.

상태에 따라 달라지는 변수를 추가로 선언한다.

@Composable
private fun Greeting(name: String) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp
...
  • extraPadding 은 간단한 계산을 실행하므로 리컴포지션에 대비하여 이 값을 기억할 필요가 없다.

Column 에 새로운 패딩 수정자를 적용하여 펼쳐지는 UI를 구현해보자.

@Composable
private fun Greeting(name: String) {

    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")
            }
        }
    }
}
  • 수정자 paddingbottom 은 아래쪽 여백을 조절하는 역할을 한다.
  • bottom 에 들어가는 extraPadding 값은 expandedtrue 인 경우 48dp 의 padding 값을 넘겨주게 된다.


8. 상태 호이스팅

구성 가능한 함수에서 여러 함수가 읽거나 수정하는 상태는 공통의 상위 항목에 위치해야 한다. 이 프로세스를 상태 호이스팅(State Hoisting)이라고 한다.

구성 가능한 함수에서 사용되는 상태는 해당 함수 내부에서만 사용할 수 있는 것이 아니라 다른 함수에서도 읽거나 수정할 수 있다. 이때 문제가 발생할 수 있는데, 상태가 여러 함수에서 사용되면서 상태 관리가 어려워지기 때문이다.

이러한 문제를 해결하기 위해 여러 함수에서 읽거나 수정하는 상태는 공통의 상위 항목에 위치시켜야 한다.

이렇게 하면

  • 상태의 범위가 한정되어 상태 관리가 쉬워지며,
  • 코드의 가독성과 유지 보수성이 향상된다.

이처럼 공통 상위 항목으로 상태(State)를 위치시키는 과정을 상태 호이스팅(State Hoisting)이라고 한다. 말 그대로 구성 가능한 함수에서 사용되는 상태를 해당 함수의 상위 항목으로 끌어올리는 것이다.

이를 통해

  • 구성 가능한 함수의 재사용성을 높일 수 있고,
  • 코드의 유지보수성과 가독성을 향상시킬 수 있다.

이에 반해, 컴포저블의 상위 요소에서 제어할 필요가 없는 상태는 호이스팅되면 안된다.


온보딩 화면 만들기

앱의 온보딩 화면을 만들어 보자.

@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()
    }
}
  • shouldShowOnboarding= 대신 by 키워드를 사용하고 있다. 이 키워드는 매번 .value 를 입력할 필요가 없도록 해주는 속성 위임이다.

위임과 관련하여 더 자세한 내용은 [Kotlin in Action] 7. 연산자 오버로딩과 기타 관례 참고


온보딩 화면 숨기기

이제 앱에 새로운 온보딩 화면을 추가할 수 있게 되었다. 시작 시에 이 화면을 표시하고 사용자가 Continue 버튼을 누르면 화면을 숨겨보자.

우선 Compose에서는 UI 요소를 숨기지 않는다.

대신 해당 요소를 UI 구성에 아예 포함하지 않는 방식을 취한다. 예를 들어 특정 버튼이나 텍스트 필드를 사용자에게 보여주지 않으려면 해당 UI 요소를 컴포지션에 추가하지 않으면 된다. 이렇게 하면 Compose가 생성하는 UI 트리에 추가되지 않는다.

불필요한 UI 요소를 숨기는 대신에 해당 요소를 컴포지션에서 누락시키므로

  • 코드를 간결하게 유지할 수 있으며
  • 동적인 UI를 만들기에 용이(특정 상황에서만 UI 요소를 추가하거나 제거하는 등)하다.

간단한 조건부 Kotlin 로직을 사용하여 이 작업을 실행해보자.

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(modifier) {
        if (shouldShowOnboarding) { // Where does this come from?
            OnboardingScreen()
        } else {
            // 인사말 작업 수행
        }
    }
}

이 코드에서는 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)
        }
    }
}

그리고 기존의 MyApp을 수정하여 다른 화면을 표시하는 로직을 if 문을 이용해 추가하고 상태를 호이스팅한다.

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

    var shouldShowOnboarding by remember { mutableStateOf(true) }

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

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


그럼 이벤트는 어떻게 전달할까?

이벤트는 아래로 콜백을 전달한다. 콜백은 다른 함수에 인수로 전달되는 함수로, 이벤트가 발생하면 실행된다.

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

@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 에서 onClick = { shouldShowOnboarding = false } 으로 상태를 전달했던 것과 달리 onClick = onContinueClicked 으로 함수를 전달하고 있다.

이를 통해

  • 컴포저블의 재사용 가능성을 높이고
  • 다른 컴포저블이 상태를 변경하지 않도록 보호하고 있다.

MyApp, OnboardingScreen, Greetings, Greeting 함수가 모두 잘 작성되었다면 아래와 같이 미리보기를 만들어보자.

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

아래와 같이 온보딩을 지나 인사말 화면으로 진입하는 것을 볼 수 있다.


9. 성능 지연 목록 만들기

지금까지는 Column 에 두 개의 인사말을 표시했다. 여러개의 인사말을 처리하려면 어떻게 해야할까?

목록 크기를 설정하고, 람다에 포함된 값으로 목록을 채우도록 허용하는 다른 목록 생성자를 사용하기 위해 Greetings 매개변수의 기본 목록 값을 변경한다.

names: List<String> = List(1000) { "$it" } // $it 은 목록 색인

이렇게 하면 화면에 담을 수도 없는 1000개의 인사말이 생성된다.... 만약 애뮬레이터로 실행해본다면 애뮬레이터가 중단될 수도 있다

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

어디서 많이 들어본 개념 같은데 맞다. 기존 Android View의 RecyclerView 와 동일하다.

아래와 같이 인사말의 리스트를 관리하던 Greetings 함수를 수정해보자.

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

리사이클러뷰처럼 스크롤 시 동일한 UI를 연속적으로 보여주는 것을 확인할 수 있다.


10. 상태 유지

앱에 한 가지 문제가 있다.

기기에서 앱을 실행하고 버튼을 클릭한 다음 회전 하면 온보딩 화면이 다시 표시된다.

remember 함수는 컴포저블이 컴포지션에 유지되는 동안에만 작동한다. 기기를 회전하면 전체 Activity가 다시 시작되므로 모든 상태가 손실된다. 이 현상은 구성이 변경되거나 프로세스가 중단될 때도 발생한다.

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

MyAppshouldShowOnboarding에서 rememberrememberSaveable로 교체해주면 된다.


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

Compose에서는 여러 가지 방법으로 UI에 애니메이션을 지정할 수 있다. 여기서는 각 인사말의 크기 변경에 애니메이션을 적용해보자.

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

인사말의 속성을 다루던 Greeting 를 수정하여 펼쳐진 상태에 따라 달라지는 extraPadding을 만들고 애니메이션을 적용해보자.

@Composable
private fun Greeting(name: String) {

    var expanded by remember { 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")
            }

        }
    }
}
  • spring 은 매개변수로 시간과 관련된 것 대신 물리적 속성을 사용한다.
  • 패딩은 음수가 되면 안된다. (앱이 다운될 수 있다.)

애니메이션 설정을 마치면 아래와 같이 펼쳐진 인사말이 스프링 효과를 나타내는 것을 볼 수 있다.


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

MaterialTheme 적용

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


우리가 작성했던 코드에서 BasicsCodelabThemeMaterialTheme 을 내부적으로 래핑하므로 MyApp 은 테마에 정의된 속성으로 스타일이 지정된다.


BasicsCodelabThemeMaterialTheme 을 내부적으로 래핑하므로 MyApp은 테마에 정의된 속성으로 스타일이 지정된다.

모든 하위 컴포저블에서 MaterialTheme의 세 가지 속성

  • colorScheme
  • typography
  • shapes
    를 가져올 수 있다.

이러한 속성을 사용하여 Text 중 하나에 헤더 스타일을 설정해보자.


인사말의 속성을 다루던 Greeting 함수에서 각 이름에 스타일을 설정해준다.

Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
) {
    Text(text = "Hello, ")
    Text(text = name, style = MaterialTheme.typography.headlineMedium)
  }
  • 위 코드처럼 기본 MaterialTheme.typography 를 사용하여 테마가 정의된 스타일을 가져올 수도 있고, 유한 TextStyle 을 만들 수도 있다.
  • Material을 이용하면 정의된 텍스트 스타일(displayLarge, headlineMedium, titleSmall, bodyLarge, labelMedium 등)에 액세스할 수 있다.


일반적으로는 위와 같이 MaterialTheme 내부의 색상, 모양, 글꼴 스타일을 유지하는 것이 좋다. 그러나 가끔 색상과 글꼴 스타일의 선택에서 약간 벗어나야 할 때도 있다.

copy 함수를 사용하여 미리 정의된 스타일을 수정할 수 있다.

Text(
     text = name,
     style = MaterialTheme.typography.headlineMedium.copy(
     	fontWeight = FontWeight.ExtraBold
     )
)

조금 더 굵어진 숫자를 확인할 수 있다.


어두운 모드 미리보기 설정

UI_MODE_NIGHT_YES 를 추가해주면 다크 모드의 미리보기도 확인할 수 있다.

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES, // 다크 모드 추가
    name = "Dark"
)

앱 테마 조정

새로운 색상을 정의하는 것부터 시작해보자.

ui/theme/Color.kt 에 색상을 추가한다.

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

ui/theme/Theme.kt 에서 팔레트에 색상을 할당한다.

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

이렇게 수정을 마쳐도, 기본적으로 미리보기 색상은 변경되지 않는다. 미리보기에서동적 색상이 사용되기 때문이다.

따라서 Theme.kt 안에서 dynamicColor Boolean 매개변수를 사용하여 Theme.kt 에서 동적 색상을 추가하는 로직을 확인할 수 있다.

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

13. 설정 완료

버튼을 아이콘으로 대체

  • 하위 요소인 Icon과 함께 IconButton 컴포저블을 사용한다.
    Icons.Filled.ExpandLessIcons.Filled.ExpandMore를 사용한다. 이는 material-icons-extended 아티팩트에서 사용할 수 있다.

  • app/build.gradle 파일의 종속 항목에 아래를 추가한다.

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

문자열 리소스 사용

간단한 if 문을 사용하여 설명을 직접 추가할 수 있다. 하지만 문자열을 하드 코딩하는 것은 바람직하지 않으며 strings.xml 파일에서 문자열을 가져와야 한다.

  • 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 역시 더 이상 필요하지 않다.

고도 및 도형 추가

  • Material 컴포저블 Card 를 이용한다.
  • CardDefaults.cardColors 를 호출하고 변경하려는 색상을 재정의하여 색상을 변경할 수 있다.
@Composable
private fun Greeting(name: String) {
    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 remember { 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)
                }
            )
        }
    }
}

위 코드대로 작성해주면 잘 될 것 같지만 한 가지 이슈가 발생한다...

실험용 API를 사용하여 한 가지 에러가 뜨는 것이다 두둥

열심히 구글링해본 결과, app단의 build.gradle 파일에서 아래 두 이미지와 같이 meterial과 관련한 코드를 추가해주면 된다.

아래와 같이 빨간 줄이 사라지는 것을 확인할 수 있을 것이다.

그리고 최종적인 결과물도 아래와 같이 잘 나온다.


참고 자료
Android 개발자를 위한 Jetpack Compose - Compose 기본사항(1)

profile
학교 다니는 개발자

5개의 댓글

comment-user-thumbnail
2023년 4월 18일

마지막 결과물 사진에 오류가 있나봐요 뜨질 않네요

2개의 답글