Week 1 Jetpack Compose basics (2)

jihyo·2021년 11월 14일

DevFest 2021

목록 보기
3/8

State in Compose

이번에는 아래와 같이 사용자와의 상호 작용에 반응하는 화면을 만들어 볼 것이다.
Interaction

다음을 구현할 것이다.
1. Button을 클릭할 수 있게 만들기
2. 항목 크기를 조절하기

위 2가지를 하기 위해서는 각 항목이 확장 되었는지 여부를 나타내는 값 즉, State(상태)가 필요하다. 항목에 대한 상태를 가져야 하기 때문에 Greeting에 필요하다.

Compose 앱은 Composable 함수를 호출하여 데이터를 UI로 변환한다. 데이터가 변하면 Compose는 새 데이터로 재실행하여 UI를 업데이트한다. 이를 재구성이라고 한다. 또한, Compose는 데이터가 변경된 구성 요소만 재구성하고 영향 받지 않은 구성 요소의 재구성을 건너 뛰도록 개별 구성 가능에 필요한 데이터를 확인한다.

단, Composable 함수는 자주 실행될 수 있으며 어던 순서로든 코드가 실행되는 순서나 함수가 재구성되는 횟수에 의존해서는 안된다.

Boolean 자료형으로 State를 담기 위해 변수를 만들었을 때 이 변수를 추적해야 한다. 그렇지 않으면 재구성되지 않는다. Composable에 State를 추가하려면 mutableStateOf 함수를 사용하면 Compose가 해당 State를 읽는 함수를 재구성할 수 있다.

StateMutableState는 일부 값을 가지고 해당 값이 바뀔 때마다 UI 재구성을 시작하는 인터페이스이다.

그러나 Composable 내부 변수에 mutableStateOf를 할당할 수 없다. 재구성은 언제든지 다시 Composable을 호출하여 State를 변경할 수 있는 새로운 상태로 재설정 할 수 있다.

remember는 재구성으로부터 State가 리셋되지 않게 하기 때문에 재구성에서 State를 보존하려면 remember를 사용해야 한다.

화면의 다른 부분에서 동일한 Composable을 호출하면 각자의 고유한 버전의 State가 있는 다른 UI를 생성하게 된다. 내부 State를 클래스의 개인 변수로 생각할 수도 있다.

Composable 함수는 자동으로 State에 "subscribed" 된다. State가 변경되면 이 필드를 읽는Composable이 업데이트를 하도록 재구성된다.

Mutating state and reacting to state changes

상태를 변경하기 위해 ButtononClick 매개변수가 있지만 값을 사용하지 않고 함수를 사용한다. 람다식을 사용해 Button 클릭 시 수행할 작업을 정의할 수 있다. 우선, 클릭해서 확장 상태의 값을 토글하고 그에 따라 다른 텍스트를 표시하는 코드를 작성해보면,

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

지난 글의 코드를 보면 2개의 Greeting 결과물이 있는데 서로 다른 UI 요소에 속하기 때문에 각자 고유한 확장 상태를 유지한다.

Expanding the item

클릭 요청이 됐을 때 실제로 확장해보는 코드를 구현해보겠다. 이를 위해 상태에 따라 달라지는 추가 변수가 필요하다.

@Composable
private fun Greeting(name: String) {
    val expanded = remember { mutableStateOf(false) }

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

extraPadding 변수는 기억될 필요가 없다. expanded에 의존하고 간단한 일을 수행하기 때문이다.

Column에 적용시키면, 확장하는 것을 확인할 수 있다.

	    Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }

Expand

State hoisting

Composable 함수에서 여러 함수에서 읽거나 수정한 상태는 공통 조상을 가진다. 이 프로세스를 state hoisting이라고 하며, hoistliftelevate를 의미한다.

상태를 hoistable하게 만들면 상태 복제와 버그 도입을 방지하고 Composable을 재사용하는 데 도움이 되며 Composable 테스트를 더 쉽게 할 수 있다.
반대로 Composable의 부모에 의해 제어될 필요가 없는 상태는 hoist되어서는 안된다.

아래와 같은 OnBoaridng 화면을 추가해보겠다.
OnBoarding Screen

MainActivity.kt 파일에 아래와 같은 코드를 추가하면 된다.

@Composable
fun OnBoardingScreen() {
    var shouldShowOnBoarding by remember { mutableStateOf(true) }

    Surface {
        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
@Composable(showBackground = true, widthDp = 320, heightDp = 320)
fun OnBoardingPreview() {
    JetpackComposeTutorialTheme {
        OnBoardingScreen()
    }
}

이 코드에는 여러 특징이 있다.

  • OnBoardingScreen이라는 Composable과 Preview를 추가. 현재 MainActivity.kt를 빌드하면 여러 Preview가 생성됨을 알 수 있다. 또한, 콘텐츠가 제대로 정렬되었는지 검증하기 위해 고정된 높이를 추가
  • Column은 콘텐츠를 화면 중앙에 표시하도록 구성
  • align modifier는 행과열 내부의 Composable을 정렬
  • shouldShowOnBoarding 함수는 = 대신 by를 사용. 매번 .value를 입력하지 않아도 되는 속성 대리자
  • 버튼이 클릭됐을 때 shouldShowOnBoarding 변수가 fals로 설정되지만, 아무데서나 상태를 읽지는 않는다.

사용자가 버튼을 눌렀을 때 사라지는 화면을 구현하려 한다.

Compose에서는 UI를 대신 Composition에 추가하지 않으므로 Compose가 생성하는 UI 트리에 추가되지 않는다. 간단한 조건부 Kotlin 로직으로 할 수 있다. 그러나 shouldShowOnBoarding에 대한 접근 권한이 없다. OnBoardingScreen에서 생성한 상태를 MyApp Composable과 공유해야 한다.

부모와 상태값을 공유하는 대신, 우리는 접근해야하는 공통 조상으로 이동해 상태를 hoist할 것이다.

먼저 MyApp의 콘텐츠를 Greetings라는 새 Composable로 이동시키고 MyApp에 다른 화면을 표시하는 로직을 추가하고 상태를 hoist한다. (Greeting이 아니므로 주의)

@Composable
private fun MyApp(names: List<String> = listOf("World", "Compose")) {
    var shouldSHowOnBoarding by remember { mutableStateOf(true) }
    
    if (shouldSHowOnBoarding) {
        OnBoardingScreen()
    } else {
        Greetings()
    }
}

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

shouldShowOnBoarding을 온보딩 화면과 공유하지만 직접 전달하지는 않을 것이다. OnBoardingScreen이 상태를 변경하도록 하는 대신 사용자가 버튼 클릭할 때 우리에게 알리는 것이 좋다.

콜백을 통해 클릭되는 이벤트를 전달할 것이다. 콜백은 다른 함수에 인수로 전달되고 이벤트rk 발생할 때 실행되는 함수이다.

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

@Composable
private fun MyApp(names: List<String> = listOf("World", "Compose")) {
    var shouldSHowOnBoarding by remember { mutableStateOf(true) }

    if (shouldSHowOnBoarding) {
        OnBoardingScreen(onContinueClicked = { shouldSHowOnBoarding = false })
    } else {
        Greetings()
    }
}

@Composable
fun OnBoardingScreen(onContinueClicked: () -> Unit) {
    Surface {
        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에 상태가 아닌 함수를 전달함으로써 이 Composable을 재사용 가능하게 만들고 상태가 다른 Composable에 의해 변경되지 않도록 보호해야 한다. OnBoardingScreen을 호출하기 위해 OnBoardingPreview를 수정하겠다. onContinueClicked를 빈 람다식에 할당하면 "아무것도 하지 않는다"이기 때문에 Preview에 적합하다.

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnBoardingPreview() {
    JetpackComposeTutorialTheme {
        OnBoardingScreen(onContinueClicked = {})
    }
}

Creating a performant lazy list

지금까지는 Greeting UI 요소 2개만 나왔다. 이번에는 리스트를 만들어 볼 것이다.

Greeting 매개변수의 기본 목록 값을 변경하여 리스트 크기를 설정하고 람다에 포함된 값으로 채울 수 있는 다른 리스트 생성자를 사용한다. 스크롤 가능한 Column을 만들기 위해 LazyColumnn을 사용한다. LazyColumn은 화면에 보이는 항목만 렌더링하므로 크기가 큰 리스트를 렌더링할 때 성능이 향상된다.

LazyColumnLazyRowRecyclerView와 동일하다. LazyColumn은 범위 내에서 개별 항목 렌더링 로직이 작성되는 항목 요소를 제공한다.

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

LazyColumnRecyclerView와 같은 자식을 재활용하지는 않는다. 스크롤할 때 새 Composable을 내보내는데 Composable을 내보내는 것이 Android View를 인스턴화하는 것에 비해 상대적으로 경제적이기 때문이다.

Persisting State

그러나 지금 문제가 있다. "Continue" 버튼을 클릭한 다음 회전하면 온보딩 화면이 다시 표시된다. remember 함수는 Composable이 컴포지션에 유지되는 동안에만 작동한다. 기기가 회전하면 전체 Activity가 다시 시작되기 때문에 모든 상태가 손실된다. 이는 configuration 변경(ex:회전)과 프로세스 종료 시에도 동일하다.

remember를 사용하는 대신 rememberSaveable를 사용하면 configuration 변경 등에서 상태가 살아남아 저장된다.

이제 shouldShownOnBoarding 변수의 remember 함수를 rememberSaveable로 바꿔준다. 그러면 Run, Rotate, Dark Mode로 변경, 프로세스 종료 등에서 온보딩 화면이 표시되지 않는다. (단, 앱을 종료하지 않는 조건)

Animating List

Compose에는 간단한 애니메이션을 위한 High-Level API에서 완전한 제어와 복잡한 전환을 위한 Low-Level API에 이르기까지 UI에 애니메이션을 적용하는 여러 가지 방법이 있다.

Low-Level API 중 크기 변경하는 애니메이션을 사용해 볼 것이다. Greeting UI에서 "Show more"를 클릭했을 때 확장되는 애니메이션을 구현하기 위해 animateDpAsState Composable을 사용한다. 애니메이션이 끝날 때까지 계속해서 값이 없데이트되는 State 객체를 반환한다. 유형이 Dp인 "목표값"을 취한다.

확장 상태에 따라 애니메이션 extraPadding을 만든다. 또한 속성 대리자 by를 사용한다. animateDpAsState는 애니메이션을 사용자 정의할 수 있는 선택적 animationSpec 매개변수를 사용한다.

@Composable
@Composable
private fun Greeting(name: String) {
    val expanded = remember { mutableStateOf(false) }

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

또한, padding이 절대 음수가 되면 충돌이 발생할 수 있기 때문에 음수가 되지 않도록 한다. spring 스펙은 시간 관련 매개변수를 사용하지 않는다. 대신 애니메이션을 보다 자연스럽게 만들기 위해 물리적 속성(dampingstiffness)에 의존한다.

animate*AsState로 생성된 모든 애니메이션은 interruptible(중단가능)하다. 애니메이션 중도에 대상값이 변경되면 animate*AsState가 애니메이션을 다시 시작하고 새 값을 가리킨다. Interruption은 스프링 기반 애니메이션에서 특히 자연스럽게 보인다.

다양한 유형의 애니메이션을 탐색하려면 spring에 대해 다른 매개변수, 다른 스펙(tween, repeatable)과 다른 함수들(animateColorAsState다른 유형의 애니메이션 API)를 사용해보면 된다.

앱 스타일 지정 및 테마 지정

지금까지 Composable의 스타일을 지정한 적이 없지만 Dark mode 지원을 포함한 기본값들을 사용했다. BasicsCodelabTheme(프로젝트명을 따라가기 때문에 나의 경우 JetpackComposeTutorialTheme 구글에서 제공하는 솔루션 프로젝트를 그대로 받았다면 전자)와 MaterialTheme이 무엇인지 살펴보겠다.

ui/Theme.kt 파일을 열면 BasicsCodelabThemeMaterialTheme을 사용하는 것을 확인할 수 있다. MaterialTheme은 머티리얼 디자인 사양의 스타일링 원칙을 반영하는 Composable 함수이다. 해당 스타일 정보는 콘텐츠 내부에 있는 구성 요소로 계단식으로 연결되며, 이 구성 요소는 정보를 읽어 스타일을 지정할 수 있다.

BasicsCodelabThemeMaterialTheme를 내부적으로 래핑하므로 MyApp은 테마에 정의된 속성으로 스타일이 지정된다. 모든 하위 Composable에서 MaterialTheme의 세 가지 속성(colors, typography, shapes)을 검색할 수 있다. 하나를 사용하기 위해 Greeting 코드를 수정해보자.

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

아래 그림처럼 "Hello," 아래의 숫자가 커졌음을 알 수 있다.
Styling
Composable Text는 새 TextStyle을 설정한다. 고유한 TextStyle을 만들거나 선호되는 MaterialTheme.typography를 사용하여 테마 정의 스타일을 찾을 수 있다. h1, body1, body2, caption 등과 같은 머티리얼 정의 텍스트 스타일을 사용할 수 있다.

MaterialTheme 내에서 색상, 모양, 글꼴 등을 유지하는 게 좋다. 그렇지 않으면 구현하기도 어렵고 수정하는 과정에서 오류가 발생할 가능성이 높다.

그러나 아래와 같이 copy 함수를 사용해 미리 정의된 스타일을 수정할 수도 있다.

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

앱 테마 조정

ui 폴더 안의 파일에서 현재 테마와 관련된 모든 것을 찾을 수 있다. 여태껏 사용해온 기본 색상들은 Color.kt에 정의되어 있다.

이번에는 새로운 색상을 Color.kt에 정의해 보겠다.

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

이제 Theme.ktMaterialThemeLightColorPalette에 할당한다. 그러면 결과가 아래와 같이 변경된다.

private val LightColorPalette = lightColors(
    primary = LightBlue,
    primaryVariant = Purple700,
    secondary = Teal200,
    surface = Blue,
    onSurface = Color.White,
    onPrimary = Navy
)

TweakingTheme

이번엔 어두운 색상을 수정해보겠다. 먼저 UI_MODE_NIGHT_YES를 사용해 DefaultPreview@Preview 주석을 추가한다. 아래 그림과 같은 결과물을 확인할 수 있다.

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "DefaultPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
private fun DefaultPreview() {
    JetpackComposeTutorialTheme {
        Greetings()
    }
}

DarkTheme

아까처럼 이번에는 Theme.ktDarkColorPalette에 할당한다.

private val DarkColorPalette = darkColors(
    primary = Navy,
    primaryVariant = Purple700,
    secondary = Teal200,
    surface = Blue,
    onSurface = Navy,
    onPrimary = Chartreuse
)

DarkTheme2

Finish!

마지막으로 그동안 배운 것들을 적용해서 아래 그림과 같은 앱을 만들 것이다.
Final

Replace button with an icon

  • IconButton Composable을 자식 Icon과 함께 사용
  • material-icons-extended 아티팩트에서 사용할 수 있는 Icons.Filled.ExpandLessIcons.Filled.ExpandMore을 사용. 이를 위해app/build.gradle파일에 다음 코드를 추가
implementation "androidx.compose.material:material-icons-extended:$compose_version"
  • 패딩을 수정하여 정렬
  • 접근성을 위한 콘텐츠 설명을 추가(Use String Resource 참조)

Use String Resources

"Show more"와 "Show less"에 대한 내용 설명을 추가하기 위해 간단한 if문을 사용한다.

문자열을 코드 내에 직접 하드 코딩하는 것은 권장되지 않기 때문에 string.xml 파일에서 가져오는 방법을 택한다.

"Context Actions"에서 사용할 수 있는 각 문자열에 "Extract string resource"를 사용하여 자동으로 수행할 수 있다. 아니면, app/src/res/values/strings.xml 파일에 아래 리소스를 추가한다.

Showing more

텍스트 콘텐츠로 쓸 "Composem ipsum~" 텍스트가 보이는 여부에 따라 각 카드의 크기가 변한다.

  • 아이템이 확장될 때 표시되는 Greeting 내부 Column에 새로운 Text를 추가
  • extraPadding을 제거하고 RowanimateContentSize 수정자를 적용. 수동으로 하기 힘든 애니메이션 생성 프로세스를 자동화한다. 또한, coerceAtLeast의 필요성을 제거

Add elevation and shapes

  • shadow 수정자를 clip 수정자와 함께 사용하여 카드 모양을 만들 수 있다. 그러나 Card Material Composable이 있다. Card

최종 코드는 아래와 같다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeTutorialTheme {
                MyApp()
            }
        }
    }
}

@Composable
private fun MyApp() {
    var shouldSHowOnBoarding by remember { mutableStateOf(true) }

    if (shouldSHowOnBoarding) {
        OnBoardingScreen(onContinueClicked = { shouldSHowOnBoarding = false })
    } else {
        Greetings()
    }
}

@Composable
fun OnBoardingScreen(onContinueClicked: () -> Unit) {
    Surface {
        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
// "$it"는 인덱스를 나타냄
private fun Greetings(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) {
    Card(
        backgroundColor = MaterialTheme.colors.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.h4.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) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
                contentDescription = if (expanded) {
                    stringResource(id = R.string.show_less)
                } else {
                    stringResource(id = R.string.show_more)
                }
            )
        }
    }
}

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "DefaultPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
private fun DefaultPreview() {
    JetpackComposeTutorialTheme {
        Greetings()
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnBoardingPreview() {
    JetpackComposeTutorialTheme {
        // 클릭됐을 때 아무것도 하지 않음
        OnBoardingScreen(onContinueClicked = {})
    }
}

0개의 댓글