[Compose] compose의 이모저모1

5 분 소요

해당 게시글은 compose camp codelab 1을 진행하며 알게된 점을 정리한 내용입니다.


🙋‍♀️ 컴포저블 재사용

  • 함수가 매우 커지면 가독성에 영향을 줄 수 있다.
    • 재사용 할 수 있는 작은 구성요소를 만들면 앱에서 사용하는 ui 요소의 라이브러리를 쉽게 만들 수 있다.
    • 각 요소는 화면의 작은 부분을 담당하며 독립적으로 수정할 수 있다.
  • 함수는 기본적으로 빈 수정자가 할당되는 수정자 매개변수를 포함하는 것이 좋다.


🙋‍♀️ 상태 변화와 리컴포지션

항목이 펼쳐진 상태인지에 따라 항목의 크기를 달리하는 작업을 진행하려고 한다. 우선 항목의 상태를 저장할 변수가 필요하다.

@Composable
private fun Greeting(name: String) {
    var expanded = 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 = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

🙂 하지만 이렇게 하면 expanded 값을 상태 변경으로 감지하지 않으므로 아무 일도 일어나지 않는다.

  • 따라서 컴포저블에 내부 상태를 추가하려면 mutableStateOf 함수를 사용해 해당 State를 읽는 함수를 재구성하게 된다.
  • StateMutableState는 어떤 값을 보유하고 그 값이 변경될 때마다 UI 없데이트(리컴포지션)를 트리거하는 인터페이스이다.
@Composable
fun Greeting() {
    val expanded = mutableStateOf(false) // Don't do this!
}

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

  • 위의 경우에는 false 값을 가진 변경 가능한 새 상태로 상태를 재설정하여 컴포저블을 다시 호출할 때는 언제든지 리컴포지션이 일어날 수 있기 때문이다.

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

@Composable
fun Greeting() {
		//내부 상태는 클래스의 비공개 변수로 보면 된다.
    val expanded = remember { mutableStateOf(false) }
    ...
}
  • remember리컴포지션을 방지하는 데 사용되므로 상태가 재설정되지 않는다.

🙂 여기서 잠깐!! remember 함수는 컴포저블이 컴포지션에 유지되는 동안에만 작동한다. 이를 해결하기 위해 rememberSaveable을 사용한다.

val expanded by rememberSaveable { mutableStateOf(false) }
  • rememberSaveable을 통해 회전과 같은 구성 변경 및 프로세스 중단에도 상태가 저장된다.
  • = 대신 by 키워드를 사용한 속성 위임을 통해 매번 .value를 입력할 필요를 없앤다.


🙋‍♀️ 성능 지연 목록 만들기

수천 개의 요소를 column에 배치하고자 할 때 어떻게 해야 할까?

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

만약 이를 통해 Column을 만든다면 화면에 나타나는 것을 포함해 총 1000개의 목록이 생성되고, 이는 성능 기준에 맞지 않는다.

🙂 따라서 우리가 사용할 것은 Compose에서의 RecyclerViewLazyColumnLazyRow이다.

@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)
        }
    }
}
  • 기본적으로 LazyColumn API는 범위 내에서 items 요소를 제공하며, 여기서 로직을 개별 항목마다 적용한다.
  • LazyColumn은 RecyclerView와 같이 하위 요소를 재활용하지 않는다.
    • 컴포저블을 방출하는 것은 Android Views를 인스턴스화하는 것보다 상대적으로 비용이 적게 들기 때문에 스크롤 할 때 새 컴포저블을 방출하고 계속 성능을 유지하게 된다.


🙋‍♀️ ⭐상태 호이스팅⭐

구성 가능한 함수에서 여러 함수가 읽거나 수정하는 상태공통의 상위 항목에 위치해야 한다.

  • 이 프로세스를 상태 호이스팅이라고 한다.
  • 호이스팅: 들어/끌어올린다.

장점

  • 상태를 호이스팅할 수 있게 만들면 상태가 중복되지 않아 버그가 발생하는 것을 방지할 수 있다.
  • 컴포저블을 재사용할 수 있고 훨씬 쉽게 테스트할 수 있다.

주의

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

온보딩 화면을 처음 한 번만 보여주고 continue 버튼을 누르면 온보딩 화면이 숨겨지는 과정을 진행해보자! 여기서 숨겨진다는 것은 단지 컴포지션에 UI 요소를 추가하지 않으면 된다.

@Composable
fun OnboardingScreen() {
    // TODO: This state should be hoisted⭐
    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(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen()
    }
}
  • 위와 같이 하면 shouldShowOnboarding다른 컴포저블 함수에서 액세스할 수 없다.

🙂 이때 상태값을 상위 요소와 공유하는 대신 상태를 호이스팅한다. 즉, 상태 값에 액세스해야 하는 공통 상위 요소로 상태 값을 이동하기만 하면 된다.

@Composable
fun MyApp() {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

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

🙂 그렇다면 이벤트를 어떻게 전달할까? 아래로 콜백을 전달하면 된다.

  • 콜백: 다른 함수에 인수로 전달되는 함수이벤트가 발생하면 실행된다.
@Composable
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")
            }
        }
    }
}
  • 이 방법은 상태가 아닌 함수를 OnboardingScreen에 전달하는 방식으로 컴포저블의 재사용 가능성을 높이고 다른 컴포저블이 해당 상태를 변경하지 않도록 보호하고 있다.

🙂 이렇게 될 경우, 온보딩 화면의 미리보기를 어떻게 수정할 수 있을까?

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

빈 람다 표현식을 할당해 아무 작업도 하지 않는 것이 미리보기를 위해 완벽한 방식이다.


🙋‍♀️ 목록에 애니메이션 적용하기

애니메이션을 적용하는 방식은 정말 다양하다. compose animation 공식 문서를 통해서 보다 다양한 방식을 학습할 수 있다.

여기에서는 간단한 하나의 예시를 진행해보고자 한다.

⌨️ animateDpAsState

  • 해당 컴포저블은 애니메이션이 완료될 때까지 애니메이션에 의해 객체의 value가 계속 업데이트되는 상태 객체를 반환한다.
val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp
    )

🙂 하지만 이렇게만 진행하면 스크롤을 한 후 다시 돌아왔을 때 해당 목록이 다시 닫혀있는 것을 확인할 수 있다. 이를 해결하기 위해서는 expanded를 rememberSaveable 함수를 사용해 상태를 기억해야 한다.

var expanded by rememberSaveable {mutableStateOf(false)}
val extraPadding by animateDpAsState(
        if(expanded) 48.dp else 0.dp,
    )

⌨️ animationSpec 매개변수

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

  • 스프링 기반의 애니메이션의 경우, 애니메이션 도중 타겟 값이 변경될 때 속도의 연속성을 보장하므로 기간 기반 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))

    ...

    )
}
  • 패딩의 경우 음수가 되지 않도록 해야 한다. coerceAtLeast를 통해 최소값을 정한다.


🙋‍♀️ 기본 테마 사용하기

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

ui/theme/Theme.kt 파일을 열면 기본 테마를 확인할 수 있다. 또한, 기본 테마에서 MaterialTheme를 래핑하는 것을 확인할 수 있다.

  • MaterialTheme은 Material 디자인 사양의 스타일 지정 원칙을 반영한 구성 가능한 함수이다.
  • 기본 테마로 래핑된 모든 하위 컴포저블에서는 MaterialTheme의 세 가지 속성, colors, typography, shapes를 가져올 수 있다.
  • 일반적으로 MaterialTheme 내부의 색상, 모양, 글꼴 스타일을 유지하는 것이 훨씬 좋다.

⌨️ 정의된 스타일 수정

  • copy 함수를 사용해 미리 정의된 스타일을 수정할 수 있다.
Text(
	text = name, 
	style = MaterialTheme.typography.headlineMedium.copy(
		fontWeight = FontWeight.ExtraBold
	)
)


📝 참고 자료

댓글남기기