[Android] Jetpack Compose - 4. Compose 기초 앱 만들어보기 (2)

문승연·2023년 9월 4일
0

이 포스트는 안드로이드 공식 Codelab 내용을 기반으로 작성되었습니다.

7. 상태 호이스팅 (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()
    }
}

OnboardingScreen 컴포저블을 구성했으니 이제 앱에 새로운 온보딩 화면을 추가할 수 있다. 앱 시작시 이 화면을 표시하고 사용자가 Continue 버튼을 누르면 숨긴다.

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

하지만 shouldShowOnboarding 에 액세스 할 수 없다. OnboardingScreen 에서 만든 상태값을 MyApp 컴포저블과 공유하지 않기 때문이다.

여기서 OnboardingScreen 에서 만든 상태를 상위 요소와 공유하는 것을 호이스팅이라고 한다. 상태 값에 액세스해야하는 공통 상위 요소로 상태 값을 이동시키기만 하면 된다.

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    
    var shouldShowOnboarding by remember { mutableStateOf(true) }
    
    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen( /* TODO */ )
        } else {
            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)
        }
    }
}

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

이벤트 전달 방식은? 아래로 콜백을 전달한다.

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

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

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

    var shouldShowOnboarding by remember { mutableStateOf(true) }

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

8. 성능 지연 목록 만들기

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

목록 크기를 설정하고 람다에 포함된 값으로 목록을 채우도록 허용하는 다른 목록 생성자를 사용할 수 있다.

Greetings 함수의 List<String> 매개변수의 기본 목록 값을 아래와 같이 변경할 수 있다.

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

이렇게 하면 화면에 포함되지 않는 인사말을 포함하여 1,000개의 인사말이 생성된다. 이는 성능적인 측면에서 바람직하지 않다.

스크롤이 가능한 열을 표시하기 위해 LazyColumn 을 사용한다. LazyColumn 은 화면에 보이는 항목만 렌더링한다. (like RecyclerView)

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

9. 상태 유지

현재 앱을 실행하고 Continue 버튼을 클릭한 다음 회전시키면 다시 온보딩 화면이 표시된다.
remember 함수가 컴포저블이 컴포지션에 유지되는 동안에만 작동하기 때문이다. 기기를 회전하면 전체 활동이 다시 시작되므로 모든 상태가 손실된다.

remember를 사용하는 대신 rememberSaveable 을 사용하면 해결된다.

var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

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

Compose에서는 여러가지 방법으로 UI에 애니메이션을 지정할 수 있다.

이미 구현한 크기 변경 기능에 애니메이션을 적용해보자.

먼저 이를 위해 animateDpAsState 컴포저블을 사용한다. 이 컴포저블은 애니메이션이 완료될 때까지 애니메이션에 의해 객체의 value가 계속 업데이트되는 상태 객체를 반환한다.

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

@Composable
private fun Greeting(name: String) {

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

        }
    }
}

하지만 지금 앱에서는 1번 항목을 펼친 후 20번까지 스크롤했다가 다시 1번으로 돌아오면 1번 항목이 원래 크기로 돌아온 것을 알 수 있다. 이는 expanded 상태값을 rememberSaveable 로 바꾸면 해결할 수 있다.

animateDpAsState 는 애니메이션을 맞춤설정할 수 있는 animationSpec 매개변수를 선택적으로 사용할 수 있다.

@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(
    ...
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))

    ...

    )
}

spring을 이용하여 화면이 확대될때 통통 튀는 것 같은 애니메이션 효과를 줄 수 있다.
이때 padding이 음수가 되지 않도록 설정해주어야 한다. (extraPadding.coerceAtLeast(0.dp)) 패딩이 음수가 될 경우 앱이 다운될 수 있다. ㅇ
단 이로 인해 미세한 애니메이션 버그가 발생하는데 이는 다음 단계에서 수정할 예정이다.

animateAsState를 사용하여 만든 애니메이션은 중간에 중단될 수 있다. 애니메이션 실행 도중 목표 값이 변경되면 애니메이션을 다시 시작한다.

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

지금까지 컴포저블의 스타일을 따로 지정해주지 않았지만 BasicsCodelabTheme 를 통해서 어두운 모드 지원을 비롯한 적절한 기본값을 얻었다.

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

어두운 모드 미리보기 설정

현재 미리보기에서는 밝은 모드에서의 모습만 표시되는데 UI_MODE_NIGHT_YES 와 함께 @Preview 주석을 추가할 수 있다.

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

그 외에 ui/theme 폴더에 있는 파일에서 현재 테마와 관련된 모든 항목 (Color, Theme, Type) 을 찾아볼 수 있다. 해당 속성 값을 변경하면서 테마를 조정할 수 있다.

profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

0개의 댓글