안드로이드 컴포즈 코드랩(첫 번째 Compose 앱 만들기) 8~14

Mendel·2023년 12월 25일
0

안드로이드

목록 보기
6/7

상태 호이스팅

구성가능한 함수에서 사용하는 상태 중 외부 상위 함수에서 제어할 수 있는 요소들은 가능한 위로 끌어올리는 상태 호이스팅을 유지하는 것이 좋다. 반대로, 상위 구성요소에 의해 제어될 필요없는 상태들은 끌어올리지 않아도 된다고 한다.

이렇게 상태 호이스팅을 해서 상위요소로 State를 끌어올린다면 중복적인 상태를 정의하는 것을 피할 수 있고 재사용성과 테스트성을 갖을 수 있다고 함.

우선, 아래와 같은 온보딩 화면 뷰를 먼저 만들어보자.

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

위 코드를 보면 상태를 정의할 때, remember에 by를 사용하고 있다. 이는 속성 델리게이트로 매번 .value를 입력하지 않아도 값에 접근할 수 있도록 해준다.

구성가능한 함수에서 다음과 같이 조건부 로직을 통해 UI요소가 직접적으로 추가되지 않는 함수의 경우는 컴포즈 UI트리에 직접 추가되지는 않는다고 한다.
아래의 로직에서 shouldShownOnboarding이라는 불리언 값에 대한 접근을 해야하는데, 이는 현재 OnboardingScreen이라는 구성요소 안에 있다. 이렇게 상위요소에서 공유해야하는 경우 상태호이스팅 기법을 활용하라는 것이다. (즉, 온보딩 페이지에서 버튼을 눌러서 상태값을 수정하면, Greetings페이지로 화면을 변경하고 싶은 것임)

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

상태호이스팅을 활용한 결과 코드는 아래와 같다. (상태는 상위 요소가 갖도록하고, 상태를 직접 내려보내기보다는 상태를 조작하는 람다식을 내려보내고 있다)

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

}

주제와 동떨어진 내용이지만, 컴포즈를 배우면서 마진이라는 개념이 직접적으로 없어서 조금 헤맸었는데, 몇몇 자료들을 참고하면서 조금씩 자리가 잡히고 있다. 이 내용을 기록해두고자 한다.
만약 코드랩이 궁금한거라면 이 부분은 스킵하길 바란다.
우선, 패딩은 수정자를 통해 지정된다. 이때, 수정자에 padding이 어떤 다른 수정자 함수보다도 먼저 나오면 이 패딩은 마진의 효과를 주며, 백그라운드 색상 지정 다음 나오게 된다면 이것은 패딩의 효과를 주게 된다. 그 외에도 마진으로 Spacer라는 것을 이용할 수도 있다.

    Text(
        text = "Text 2",
        modifier = Modifier
            .padding(top = 32.dp) // margin
            .background(color = Color.Yellow)
            .padding(top = 16.dp) // padding
    )

이 외에도 offset을 통해 뷰의 위치를 직접적으로 조정할 수 있다.
참고: https://semicolonspace.com/jetpack-compose-padding-margin/

성능 지연 목록 만들기

다음과 같이 기존의 Greetings 함수를 수정하고 names에 약 1000개의 아이템들을 줘보자.

@Composable
fun Greetings(modifier: Modifier = Modifier, names: List<String> = List(1000) { "$it" }) {
    val scrollState = rememberScrollState()
    Column(modifier = Modifier.padding(vertical = 4.dp).verticalScroll(scrollState)) {
        names.forEach {
            Greeting(name = it)
        }
    }
}

스크롤은 되지만, 초기로딩이 꽤 오래 걸리는 모습을 확인할 수 있다. 이는 기존의 뷰시스템에서 리스트뷰와 동일하다. 즉, 현재 보이지 않는 뷰들도 그린다. 메모리 낭비이며 이는 ANR을 유발할 수 있다.

때문에 아이템 갯수가 약 10개를 넘어간다면 일반적으로는 LazyColumn이나 LazyRow 등을 활용해야한다.
이 둘은 기존 뷰시스템의 리사이클러뷰와 동일하다고 한다.

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

LayColumn의 특징

LazyColumn은 자식 요소들을 리사이클러뷰처럼 재활용하지 않는다. 스크롤될때 새 컴포저블들을 방출한다고 한다. 뷰 인스턴스화보다 이게 훨씬 비용이 적다고 한다. 때문에 위의 예제에서 항목 뷰의 상태가 단순 재구성에만 대응하는 remember로는 유지되지 않는 것을 알 수 있다. 이를 위해 rememberSaveable 로 expanded 여부를 기억해야 한다. rememberSaveable은 바로 뒤에서 나옴.

상태 유지

기존 앱에서는 앱을 시작하면 온보딩 화면으로 가고, 온보딩 화면에 있는 버튼을 클릭하면 목록 화면으로 이동했다. 그런데, 여기서 문제가 있다. remember는 컴포저블이 컴포지션에 유지되는 동안에만 작동한다고 한다. 기기를 회전하는 등의 구성변경이 발생하면 액티비티가 다시 시작되면서 모든 상태를 잃어버린다.
이때, remember 대신 rememberSaveable을 사용하면 된다.

var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

목록에 애니메이션 적용

항목이 펼쳐질때 애니메이션을 적용하고 싶다면 animateDpAsState를 활용하면 된다.
다음과 같이 적용하면 된다.

val extraPadding by animateDpAsState(
    if (expanded.value) 48.dp else 0.dp,
)

이미 기본적인 애니메이션은 적용됐지만 좀 더 구체적인 애니메이션을 주고 싶다면 다음과 같이 animationSpec을 지정하면 된다.

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

여기 나온 내용들을 하나씩 알아보자.

  • animateDpAsState: Dp 타입의 값을 애니메이션 가능한 상태로 만드는 것이다
    • 첫번째 인자로 48 혹은 0 dp의 값을 줘서 시작값을 할당한다.
    • 두번째 인자로 animationSpec을 지정하고 있다.
  • animationSpec으로 spring값을 주고 있다.
    • spring의 dampingRatio 는 애니메이션의 탄성정도라고 한다.
    • stiffness는 스프링이 종료값으로 변하는 속도라고 한다.

즉, 이 코드는 expanded값이 변경될 때마다 발생하는 재구성에서 두 값 사이에 스프링 애니메이션이다.
자세한 정보는 해당 공식문서를 보면 잘 나와있다.
https://developer.android.com/jetpack/compose/animation?hl=ko#animationspec

마지막으로, 해당 dp값을 활용할 때

extraPadding.coerceAtLeast(0.dp)

를 사용해서 최솟 dp값을 0보다 작을 수 없게 만들어야 앱이 터지지 않는다.

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

컴포즈 프로젝트를 만들 때, 해당 프로젝트 이름에 맞는 테마함수가 자동으로 만들어진다고 했다.
필자는 프로젝트 이름을 Compose Basic Codelab 이라고 지었다. 그래서 다음과 같은 함수가 존재하는데, 내부를 보면 마지막에 매터리얼 테마를 지정하는 것을 알 수 있다.

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

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

여기서 content는 우리가 지정하는 컴포즈함수의 시작지점이다. 즉, MaterialTheme라는 구성가능한 함수안에 우리가 작성한 구성가능한 함수를 호출해서 스타일 지정정보를 내부 구성요소들에 하향 적용시키는 것이다.

모든 하위 구성요소들에서 MaterialTheme의 세 가지 속성인 colorScheme, typography, shapes 가 있다.

MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold)

이런 식으로 copy함수를 활용해서 기존의 TextStyle을 조금 수정한 새로운 TextStyle객체를 만들 수 있다.

다크모드 미리보기

미리보기에서 다크모드도 보고 싶다면, @Preview의 인자에 uiMode로 UI_MODE_NIGHT_YES 를 주면 된다.

매터리얼 팔레트 색상 재지정하기

Color.kt 파일에 다음과 같이 원하는 색상들을 정의하고,

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

Theme.kt 파일에서 테마별로 팔레트 색상을 재지정한다.

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

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

이렇게 바꿔도 해당 속성들은 미리보기에서 반영되지 않는다고 한다.
이유는 ComposeBasicCodelab 함수를 보면 알 수 있다. 기본적으로 만들어진 이 함수는 dynamicColor 속성을 활용해서 colorScheme을 지정하고 해당 요소를 구성가능한 함수인 MaterialTheme의 첫번째 인자로 넣어준다.
즉, 프로그래밍적으로 동적으로 정의된다는 것이다.

또한 내부 조건문을 보면 이런식으로 API 버전 31미만의 경우에만 우리가 정의한 DarkColorScheme과 LightColorScheme을 활용하는 것을 알 수 있다.

    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
    }

만약 31이상 기기에서도 우리가 정의한 스킴을 사용하고 싶다면, 맨 처음 나오는 when 조건문 로직을 모두 제거하면 된다.

설정 완료

아이콘 추가

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

위의 의존성을 추가하고 매터리얼 제공 아이콘들을 사용할 수 있다.

문자열 리소스 활용

문자열을 그대로 코드에서 사용하는 것은 지역화나 유지보수성에서 좋지않다. 문자열 리소스 파일을 활용해서 텍스트를 여기에 정의하자.

이번 코드랩 결과물에 대한 자세한 소스코드는 여기서 보길 바란다. 여기서 Card와 IconButton 등 다양한 기본제공 구성가능함수를 활용해서 좀 더 다양한 UI 결과물을 만든 것을 알 수 있다. 애니메이션 또한 animateContentSize로 변경되었다.
https://developer.android.com/codelabs/jetpack-compose-basics?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fjetpack-compose-for-android-developers-1%3Fhl%3Dko&hl=ko#12


느낀점

코드를 따라치며하는 코드랩을 처음해봤는데, 생각보다 많은 것을 얻은 것 같아서 좋았다.
가장 큰 충격은 역시 예상은 했지만 LazyColumn으로 리사이클러뷰로부터 해방되었다는 것이다.
항상 보일러플레이트 코드라고 생각했던 리사이클러뷰를 안쓰니 확실히 좋은 것 같다.
다양한 애니메이션을 쉽게 제공하는 걸 보며 컴포즈에 대해 좀 더 긍정적인 면을 느낄 수 있었다.

profile
이것저것(안드로이드, 백엔드, AI, 인프라 등) 공부합니다

0개의 댓글