

급하게 회사 플젝에서 Compose + MVI로 진행하기로 해서 작성하는 기록.
Android Developer의 Codelabs 내용.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeApplicationTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ComposeApplicationTheme {
Greeting("Android")
}
}
onCreate() 함수는 앱의 진입점.
다른 함수를 호출하여 사용자 인터페이스를 빌드함.
Greeting() 함수는 구성 가능한 함수.
위에는 @Composable 주석이 있으며, 구성 가능한 함수는 몇가지 입력을 받아 화면에 표시되는 내용을 생성함.
@Preview(showBackground = true, name = "Text Preview")
@Composable
fun GreetingPreview() {
ComposeApplicationTheme {
Greeting("Android")
}
}
이렇게 수정해도 나오는 결과는 아래와 같다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
}
Surface로 감싸 color을 적용하면 아래와 같이 변경된다.

텍스트 색상은 정의하지 않았는데 정의된다.
androidx.compose.material3.Surface 와 같은 Material 구성 요소들은 공통기능(텍스트에 적절한 색상 선택 등)을 처리하여 더 나은 환경을 만들도록 함.
Surface 및 Text와 같은 대부분의 요소들은 modifier요소를 선택적으로 허용한다.
modifier는 상위 요소 레이아웃 내에서 UI 요소가 배치되고 표시되고 동작하는 방식을 UI 요소에 알려준다.
아래와 같이 modifier를 사용하여 padding을 줄 수 있다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier.padding(24.dp)
)
}
}
결과는 아래와 같다.

UI에 추가하는 구성요소가 많을 수록 생성되는 중첩 레벨이 더 많아지며, 가독성에 영향을 줄 수 있다.
재사용하도록 만들면 UI요소의 라이브러리를 쉽게 만들 수 있다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeApplicationTheme {
MyApp(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
fun MyApp(modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background
) {
Greeting(name = "Android")
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier.padding(24.dp)
)
}
}
@Preview(showBackground = true, name = "Text Preview")
@Composable
fun GreetingPreview() {
ComposeApplicationTheme {
MyApp()
}
}
이와 같이 사용하게 되면 코드 중복을 피할 수 있으므로 onCreate 콜백과 미리보기를 정리할 수 있게 된다.
Compose의 세 가지 기본 표준 레이아웃 요소는 Column, Row, Box이다.
Column은 세로, Row는 가로, Box는 가로 세로 모두의 형태로 확인할 수 있다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Column(
modifier = modifier.padding(24.dp)
) {
Text(text = "Hello")
Text(text = "$name!")
}
}
}

구성 가능한 함수는 kotlin의 다른 함수처럼 사용가능하다.
for 루프를 사용하여 Column에 요소를 추가할 수 있다.
@Composable
fun MyApp(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(
modifier = modifier
) {
for (name in names) {
Greeting(name = name)
}
}
}

미리보기에 스마트폰의 일반적인 너비인 320으로 확인하도록.
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
ComposeApplicationTheme {
MyApp()
}
}

@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
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Column(
modifier = modifier.padding(24.dp).fillMaxWidth()
) {
Text(text = "Hello")
Text(text = "$name!")
}
}
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
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(text = "Show more")
}
}
}
}

weight는 fillMaxWidth와 중복된다.
버튼을 클릭하면 화면이 커지고 작아지게 하기 위해선, 항목이 펼쳐진 상태인지를 가리키는 값을 어딘가에 저장해야 한다.
이 값을 state 라고 한다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
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(text = if (expanded) "Show less" else "Show more")
}
}
}
}
boolean으로 값을 저장하고, 클릭 시 작동하지 않는다.
compose에서 이 값을 상태 변경으로 감지 하지 않기 때문에, 아무 일도 일어나지 않는다.
이 변수를 Compose에서 추적하고 있지 않으며, 값은 Greeting이 실행될 때마다 false로 재설정된다.
Composable에 내부 상태를 추가하려면 mutableStateOf 함수를 사용하면 된다.
그렇지만, Composable 내의 변수에 mutableStateOff를 할당하기만 할 수는 없다.
false 값을 가진 변경가능한 새 상태로 재설정하여 호출하게 되면 언제든 recomposition이 일어날 수 있기 때문이다.
이러한 recomposition 간에 상태를 유지하려면 remeber을 사용하여 변경 가능한 상태를 기억해야한다.
remember는 recomposition을 방지하는데 사용되므로, state가 재설정되지 않는다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
val expanded = remember { mutableStateOf(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.value = !expanded.value }) {
Text(text = if (expanded.value) "Show less" else "Show more")
}
}
}
}
extraPadding은 간단한 계산을 수행하므로, recomposition에 대비하여 이 값을 기억할 필요가 없다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
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(text = if (expanded.value) "Show less" else "Show more")
}
}
}
}
구성 가능한 함수에서 여러 함수가 읽거나 수정하는 상태는 공통의 상위 항목에 위치해야 한다.
이 프로세스를 상태 호이스팅이라고 한다. (State Hoisting, Hoisting: 들어올린다 or 끌어 올린다.)
상태를 호이스팅할 수 있게 만들면 상태가 중복되지 않고 버그가 발생하는 것을 방지할 수 있으며, Composable을 재사용할 수 있고 훨씬 쉽게 테스트할 수 있다.
반대로, Composable의 상위 요소에서 제어할 필요가 없는 상태는 호이스팅 되면 안된다.
@Composable
fun OnboardingScreen(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Welcome to the Basics Codelab")
Button(
onClick = { shouldShowOnboarding = false },
modifier = Modifier.padding(vertical = 24.dp)
) {
Text(text = "Continue")
}
}
}
@Preview(showBackground = true, widthDp = 320)
@Composable
fun OnboardingPreview() {
ComposeApplicationTheme {
OnboardingScreen()
}
}
OnboardingScreen이라는 새 Composable과 Preview 추가했다.
화면 중앙에 콘텐츠가 표시되도록 Column을 구성한다.
shouldShowOnboarding에 사용된 by는 매번 .value를 입력할 필요가 없도록 해주는 속성 위임이다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeApplicationTheme {
MyApp(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
fun MyApp(
modifier: Modifier = Modifier
) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier = modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false})
} else {
Greetings()
}
}
}
@Composable
fun OnboardingScreen(
modifier: Modifier = Modifier,
onContinueClicked: () -> Unit
) {
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
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)
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
val extraPadding = 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(text = if (expanded) "Show less" else "Show more")
}
}
}
}
@Preview(showBackground = true, widthDp = 320)
@Composable
fun OnboardingPreview() {
ComposeApplicationTheme {
OnboardingScreen(onContinueClicked = {})
}
}
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
ComposeApplicationTheme {
Greetings()
}
}
@Preview(showBackground = true, widthDp = 320)
@Composable
fun MyAppPreview() {
ComposeApplicationTheme {
MyApp(Modifier.fillMaxSize())
}
}
스크롤이 가능한 열을 표시하기 위해 LazyColumn을 사용한다.
LazyColumn은 화면에 보이는 항목만 렌더링하므로 항목이 많은 목록을 렌더링할 때 성능이 향상된다.
참고: LazyColumn과 LazyRow는 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)
}
}
}
LazyColumn은 RecyclerView와 같이 하위 요소를 재사용하지 않는다. -> 속도 훨씬 빠름
기기에서 앱을 실행하고 버튼을 클릭한 다음 회전하면 온보딩 화면이 다시 표시된다.
remember함수는 컴포저블이 컴포지션에 유지되는 동안에만 작동된다.
기기회전 시 전체 활동이 다시 시작되므로 모든 상태 손실된다.
-> remember 대신에 rememberSaveable을 사용하면 된다.
목록 항목을 펼친 다음 항목이 보이지 않을 때까지 목록을 스크롤할거나, 기기를 회전한 다음 펼쳐진 항목으로 되돌아가면 항목이 초기 상태로 돌아온 것을 확인할 수 있다.
해결방법: rememberSaveable 사용하기
Compose에서는 여러가지 방법으로 UI에 애니메이션을 지정할 수 있다.
animateDpAsState 컴포저블을 사용한다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
var expanded by rememberSaveable { mutableStateOf(false) }
val extraPadding by animateDpAsState(targetValue = 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(text = if (expanded) "Show less" else "Show more")
}
}
}
}
스프링 기반의 애니메이션 추가
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
var expanded by rememberSaveable { mutableStateOf(false) }
val extraPadding by animateDpAsState(
targetValue = 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(text = if (expanded) "Show less" else "Show more")
}
}
}
}
모든 하위 컴포저블에서 MaterialTheme의 세가지 속성인 colorScheme, typography, shapes를 사져올 수 있다.
Column(modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding.coerceAtLeast(0.dp))
) {
Text(text = "Hello, ")
Text(text = name, style = MaterialTheme.typography.headlineMedium)
}