Android

안드로이드 [Kotlin] - Jetpack Compose 코트랩 실습(2)

🤖 Play with Android 🤖 2023. 5. 21. 20:24
728x90


Material Theming

Material Theme

  • Jetpack Compose에서 테마 설정을 구현하는 핵심 요소는 MaterialTheme 컴포저블이다.
  • 이 컴포저블을 Compose 계층 구조에 배치하면 그 구성요소의 Color, Typography, Shape 등을 설정할 수 있다.

@Composable
fun MaterialTheme(
    colors: Colors,
    typography: Typography,
    shapes: Shapes,
    content: @Composable () -> Unit
) { ...
  • 나중에 colors, typography, shapes 속성을 노출하는 MaterialTheme object를 사용하여 이 컴포저블에 전달된 매개변수를 검색할 수 있다.

테마 만들기

  • 스타일을 중앙 집중화하려면 MaterialTheme을 래핑하고 구성하는 자체 컴포저블을 만드는 것이 좋다.
  • 이렇게 하면 테마 맞춤설정을 한곳에서 지정하고 여러 화면에서 재활용 할 수 있다.
  • 앱의 여러 섹션에 스타일을 다양하게 지원하려는 경우를 예로 들 수 있다.
-  MaterialTheme {
+  NewTheme {

색상 (Color)

  • Compose의 색상은 Color 클래스를 사용하여 정의한ㄷ,

  • 색상 지정을 위한 일반적인 '#dd0d3c' 형식에서 변환하려면 '#'을 '0xff'로 대체한다.(Ex : Color(0xffdd0d3c)).

    val Red700 = Color(0xffdd0d3c)
    val Red800 = Color(0xffd00036)
    val Red900 = Color(0xffc20029)
  • 색상을 정의할 때는 '의미론적'이 아닌 색상 값에 기반하여 '문자 그대로' 이름을 지정해야 한다.

  • 예를 들어 primary로 이름을 지정한다면 다크 모드나 다른 스타일의 화면에서의 primary와 이름이 겹칠 수 있기 때문이다.


  • 색상을 정의했으므로 이제 MaterialTheme에 필요한 Colors 객체로 함께 가져와 Material의 이름이 지정된 색상에 특정 색상을 할당하면 된다.
private val LightColors = lightColors(
    primary = Red700,
    primaryVariant = Red900,
    onPrimary = Color.White,
    secondary = Red700,
    secondaryVariant = Red900,
    onSecondary = Color.White,
    error = Red800
)
  • 여기서는 lightColors 함수를 사용하여 Colors를 빌드한다.

@Composable
fun NewTheme(content: @Composable () -> Unit) {
  MaterialTheme(
    colors = LightColors,
    content = content
  )
}

서체 (Typography)

image
  • Compose에서는 TextStyle 객체를 정의하여 일부 텍스트의 스타일을 지정하는 데 필요한 정보를 정의할 수 있다.
  • 속성 샘플은 다음과 같다.

val JetnewsTypography = Typography(
    h4 = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.W600,
        fontSize = 30.sp
    ),
    h5 = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.W600,
        fontSize = 24.sp
    ),
    subtitle1 = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.W600,
        fontSize = 16.sp
    ),
    body1 = TextStyle(
        fontFamily = Domine,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp
    ),
    button = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.W500,
        fontSize = 14.sp
    )
)
  • Typography 객체를 만들어 스타일에 관한 TextStyle을 지정한다.


@Composable
fun NewTheme(content: @Composable () -> Unit) {
    MeterialTheme(
        colors = LightColors,
        typography = NewTypography,
        content = content
    )
}
  • Theme.kt에서 만든 Typography를 사용하도록 NewTheme 컴포저블을 업데이트한다.

도형 (Shape)

val NewShapes = Shapes(
    small = CutCornerShape(topStart = 8.dp),
    medium = CutCornerShape(topStart = 24.dp),
    large = RoundedCornerShape(8.dp)
)
  • Compose는 도형 테마를 정의하는 데 사용할 수 있는 RoundedCornerShapeCutCornerShape 클래스를 제공한다.
  • theme 패키지에 새 파일 Shape.kt를 만들고 다음을 추가하였다.

@Composable
fun NewTheme(content: @Composable () -> Unit) {
  MaterialTheme(
    colors = LightColors,
    typography = NewTypography,
    shapes = NewShapes,
    content = content
  )
}
  • Theme.kt를 열고 다음 Shapes를 사용하도록 NewTheme 컴포저블을 업데이트한다.

다크모드

private val DarkColors = darkColors(
    primary = Red300,
    primaryVariant = Red700,
    onPrimary = Color.Black,
    secondary = Red300,
    onSecondary = Color.Black,
    error = Red200
)
  • 위 처럼 다크모드 색상을 추가해주고

fun NewTheme(
  darkTheme: Boolean = isSystemInDarkTheme(),
  content: @Composable () -> Unit
) {
  MaterialTheme(
    colors = if (darkTheme) DarkColors else LightColors,
    typography = NewTypography,
    shapes = NewShapes,
    content = content
  )
}
  • Boolean 타입의 isSystemInDarkTheme() 함수를 통해 현재 다크모드인지 체크한 뒤 색상을 적용해준다.

State In Compose

State란

  • state는 시간에 따라 변경 가능한 어떤 값이다.
  • 이는 매우 광범위한 정의로써 우리가 사용하는 로컬 데이터베이스부터 클래스의 작은 변수들 까지 모두 State에 해당한다.

모든 안드로이드 애플리케이션은 사용자에게 state를 보여준다.

  • 네트워크 연결이 끊겼을 때 보여주는 스낵바
  • 블로그 포스팅 및 연관된 댓글들
  • 사용자가 클릭할 때 실행되는 물결 애니메이션
  • 이미지 위에 사용자가 그릴 수 있도록 하는 스티커들

이 모든 것들이 State라고 할 수 있다.


Wellness 앱

마신 물을 계산하는 WaterCount 함수

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   val count = 0
   Text(
       text = "You've had $count glasses.",
       modifier = modifier.padding(16.dp)
   )
}
  • 모든 컴포저블 함수에 기본 Modifier를 제공하는 것이 좋다.
  • 재사용성이 높아지기 때문이다.

전체 화면을 표시하는 WellnessScreen 함수

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   WaterCounter(modifier)
}
  • 해당 화면에 2개의 섹션을 넣을 것이다.
    • WaterCounter
    • Wellness 작업 목록

현재까지 코드

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicStateCodelabTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background // Theme에 저장되어 있는 색상을 가져다 사용
                ) {
                    WellnessScreen()
                }
            }
        }
    }
}

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
    WaterCounter(modifier)
}

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    val count = 0
    Text(
        text = "You've had $count glasses.",
        modifier = modifier.padding(16.dp)
    )
}
  • 여기까지 중 WaterCounter 함수의 State는 count 변수이다.
  • 하지만 위 코드처럼 정적 State는 수정할 수 없기 때문에 유용하지 않다.

Button을 만들어 State에 변화를 줘 보자. 이 때, State가 수정되도록 하는 작업을 이벤트라고 한다.


이벤트

  • 상태는 시간이 지남에 따라 변화하는 값이다.
    • 예를 들어 카카오톡에서 가장 최근에 받은 메세지도 시간에 지남에 따라 변화하므로 상태라고 할 수 있다.
  • Android 앱에서는 이벤트에 대한 응답으로 상태가 업데이트된다.

UI 업데이트 순환

Event는 프로그램 일부에 무언가 발생하는 것을 통지한다. 예를 들어, 사용자가 버튼을 누르는 것은 클릭 event를 호출한다.


모든 안드로이드 애플리케이션에서 UI 업데이트 순환에 대한 핵심적인 내용은 다음과 같다.

image
  • Event – 사용자 또는 또 다른 프로그램에 의해 생성된 이벤트
  • Update State – Event 핸들러는 UI에서 사용하는 State를 변경한다.
  • Display State – UI는 새로운 State를 나타내기 위해 업데이트 된다.

Compose에서 State를 관리하는 것은 State 및 Event가 서로 어떻게 상호작용하는지에 이해하는 것이 핵심이다.


WaterCounter에 버튼 추가

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count = 0
        Text("You've had $count glasses.")
        Button(
            onClick = { count++ },
            Modifier.padding(top = 8.dp)
        ) {
            Text("Add one")
        }
    }
}
  • Column 을 사용하여 구성 요소들을 세로로 정렬하였다.
  • Button 컴포저블 함수는 onClick 람다 함수를 수신한다.
  • countval 에서 var 로 가변형으로 바꾸어준다.
  • 앱을 실행하고 버튼을 클릭해도 아무 일도 일어나지 않는다.
  • count 변수에 다른 값을 설정해도 Compose에서 이 값을 Staet 변경으로 감지하지 않으므로 아무 일도 일어나지 않는다.
  • 이는 State가 변경될 때 Compose에 화면을 다시 그려야 한다고(즉, 컴포저블 함수를 '재구성') 알리지 않았기 때문이다.

컴포저블 함수의 메모리

  • Compose 앱은 컴포저블 함수를 호출하여 데이터를 UI로 변환한다.
  • 컴포저블을 실행할 때 Compose에서 빌드한 UI에 관한 설명을 컴포지션이라고 합니다.
  • State가 변경되면 Compose는 영향을 받는 컴포저블 함수를 새로운 State로 다시 실행한다.
  • 그러면 리컴포지션이라는 업데이트된 UI가 만들어진다.
  • 또한 Compose는 데이터가 변경된 구성요소만 재구성하고 영향을 받지 않는 구성요소는 건너뛴다.

  • 컴포지션: 컴포저블을 실행할 때 Jetpack Compose에서 빌드한 UI
  • 초기 컴포지션: 처음 컴포저블을 실행하여 컴포지션을 만든다.
  • 리컴포지션: 데이터가 변경될 때 컴포지션을 업데이트하기 위해 컴포저블을 다시 실행하는 것을 말한다.

이렇게 UI가 구성되려면 전제 조건이 있다. 바로 Compose가 추적할 State를 알아야 한다는 것이다.


  • Compose에는 특정 State를 읽는 상태 추적 시스템이 있다.
  • 이를 통해 Compose가 세분화되어 전체 UI가 아닌 변경해야 하는 컴포저블 함수만 재구성할 수 있다.
  • 이 작업은 '쓰기'(상태 변경) 뿐 아니라 State에 대한 '읽기'도 추적하여 실행한다.

Compose의 StateMutableState 타입을 사용하여 State를 관찰할 수 있다.

Compose는 상태 value 속성을 읽는 각 컴포저블을 추적하고 그 value 가 변경되면 리컴포지션을 트리거하게 된다.

mutableStateOf 함수를 사용하여 관찰 가능한 MutableState 를 만들 수 있다. 이 함수는 초깃값을 State 객체에 래핑된 매개변수로 수신한 다음, value 의 값을 관찰 가능한 상태로 만든다.


@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
      // Compose에 의해 추적될 수 있도록 MutableState로 변경하였지만 여전히 동작하지 않음
       val count: MutableState<Int> = mutableStateOf(0)

       Text("You've had ${count.value} glasses.")
        Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}
  • count 가 초깃값이 0 인 mutableStateOf API를 사용하도록 WaterCounter 컴포저블을 업데이트한다.
  • count 가 변경되면 countvalue 를 자동으로 읽는 구성 가능한 함수의 리컴포지션이 일어난다.

하지만 앱을 실행하면 여전히 아무 일도 일어나지 않는다.

리컴포지션 예약은 잘 작동한다. 그러나 리컴포지션이 발생하면 count 변수가 다시 0으로 초기화되므로 리컴포지션 간에 이 값을 유지할 방법이 필요하다.


  • 이를 위해 컴포저블 인라인 함수 remember 를 사용할 수 있다.
  • remember 로 계산된 값은 초기 컴포지션 중에 컴포지션에 저장되고 저장된 값은 리컴포지션 간에 유지된다.
  • 일반적으로 remembermutableStateOf 는 구성 가능한 함수에서 함께 사용된다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        // remember와 mutableStateOf 함께 사용
        val count: MutableState<Int> = remember { mutableStateOf(0) }
        Text("You've had ${count.value} glasses.")
        Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
            Text("Add one")
        }
    }
}
  • 또한 여기서 by 키워드를 통해 count.value 를 간소화하여 사용할 수 있다.
  • 위임을 통해 gettersetter 가져오기를 추가하면 value를 속성을 명시적으로 참조하지 않고도 count를 간접적으로 읽고 변경할 수 있다.
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) } // mutableStateOf의 게터 세터 기능을 count에게 위임

       Text("You've had $count glasses.")
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

State 기반 UI

  • Compose는 선언형 UI 프레임워크이다.
  • 상태가 변경될 때 UI 구성요소를 삭제, 혹은 Visibility를 변경하는 대신 특정 상태의 조건에서 UI가 어떻게 존재하는지 설명하는 방식이다.
  • 이 접근 방식을 사용하면 View 를 수동으로 업데이트하는 복잡성을 방지할 수 있다.
  • 새 상태에 따라 View 를 자동으로 업데이트하므로 오류도 적게 발생한다.

컴포저블의 수명주기

image
  • 컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션(재구성)을 통해서만 업데이트될 수 있다.
  • 컴포지션을 수정하는 유일한 방법은 리컴포지션(재구성)을 통하는 것이다.

컴포저블의 수명 주기는 컴포지션 시작, 0회 이상 재구성 및 컴포지션 종료 이벤트로 정의된다.


컴포저블 트리

  • 리컴포지션(재구성)은 컴포저블을 다시 호출하여 새로운 입력값을 받고 컴포즈 트리를 업데이트 하는 것이다.
  • 만약 할 일 목록을 보여주는 화면(TodoScreen)이 새로운 할 일이 추가되어 다시 호출될 때, LazyColumn은 화면상의 모든 하위요소를 재구성하게 된다.

Compose는 컴포저블 트리를 생성한다. 위에서 말한 상황을 그림으로 표현하면 다음과 같다.

image

컴포지션의 Remember

  • Wellness 앱에서 화면이 갱신될 때마다, 즉 리컴지션이 이루어질 때마다 기존의 State가 변경되는 것은 바람직하지 않다.
  • remember 는 컴포저블 함수에 메모리를 제공한다.
  • remember 에 의해 계산된 값은 컴포지션 트리 내에 저장된다.

WellnessTaksItem

@Composable
fun WellnessTaskItem(
    taskName: String,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier, verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            modifier = Modifier.weight(1f).padding(start = 16.dp),
            text = taskName
        )
        IconButton(onClick = onClose) {
            Icon(Icons.Filled.Close, contentDescription = "Close")
        }
    }
}
  • 사용자가 물을 한 잔 이상 마셨을 때 사용자가 할 웰니스 작업을 표시하고 닫을 수 있도록 하고 싶다.
  • 컴포저블은 최대한 작고 재사용이 용이해야 하므로 새로운 컴포저블을 만들었다.
  • 이 컴포저블은 매개변수로 수신된 문자열을 기반하여 task를 표시하고 닫기 아이콘 버튼을 표시한다.
image

좀 더 기능을 추가해보자

  • count 가 0보다 크면 WellnessTaskItem 표시 여부를 결정하는 showTask 변수를 정의하고 true 로 초기화한다.
  • showTask 가 true인 경우 WellnessTaskItem 을 표시하도록 조건문을 추가한다.
@Composable
fun WaterCounter() {
   Column(modifier = Modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) }
       if (count > 0) {
           var showTask by remember { mutableStateOf(true) }
           if (showTask) {
               WellnessTaskItem(
                   onClose = { },
                   taskName = "Have you taken your 15 minute walk today?"
               )
           }
           Text("You've had $count glasses.")
       }

       Button(onClick = { count++ }, enabled = count < 10) {
           Text("Add one")
       }
   }
}
  • 닫기 버튼을 클릭하면 showTask 변수가 false로 변경되어 작업이 더 이상 표시되지 않도록 onClose 람다 함수 안을 설정한다.
WellnessTaskItem(
      onClose = { showTask = false },
      taskName = "Have you taken your 15 minute walk today?"
   )
  • 그런 다음 'Clear water count'라는 텍스트가 포함된 새 Button을 추가하고 'Add one' Button 옆에 배치한다.
  • Clear water count' 버튼을 누르면 count 변수가 다시 0으로 재설정된다.
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier.padding(16.dp)) {
        var count by remember { mutableStateOf(0) }
        if (count in 1..4) {
            var showTask by remember { mutableStateOf(true) }
            if (showTask) {
                WellnessTaskItem(
                    onClose = { showTask = false },
                    taskName = "Have you taken your 15 minute walk today?"
                )
            }
            Text("You've had $count glasses.")
        } else {
            Text("You've had $count glasses.")
        }

        Row {
            Button(onClick = { count++ }, enabled = count < 10) {
                Text("Add one")
            }

            Button(onClick = { count = 0 }, modifier = modifier.padding(start = 30.dp)) {
                Text("Clear water count")
            }
        }
    }
}

여기까지 동작 실제 애뮬레이터와 컴포저블 그래프로 설명

image

Add one 버튼을 누르면 count가 증가하고(리컴포지션이 발생함) WellnessTaskItem 및 카운터 Text가 표시된다.

image

WellnessTaskItem 구성요소의 X를 누른다(또 다른 리컴포지션이 발생함). 이제 showTask가 false이므로 WellnessTaskItem이 더 이상 표시되지 않는다.

image

Add one 버튼을 누른다(또 다른 리컴포지션 발생). showTask는 잔 개수를 계속 추가되지만 WellnessTaskItem을 닫은 상태가 유지된다.

image

Clear water count 버튼을 눌러 count를 0으로 재설정하면 리컴포지션이 발생한다. count를 표시하는 Text와 WellnessTaskItem과 관련된 모든 코드가 호출되지 않는다.

image

Compose에서 상태 복원

  • count를 올리고 기기 회전과 같은 Configuration Change이 일어나면 어떻게 될까?

Activity는 Configuration Change와 함께 Destroy 후에 다시 Create 되므로 저장된 State는 삭제된다. 따라서 count가 0으로 출력된다.

  • remember 를 사용하면 리컴포지션 간에 상태를 유지하지만 Configuration Change 에는 State가 유지되지 않는다.
  • 이를 위해서는 remember 대신 rememberSaveable 을 사용해야 한다.
  • rememberSabeableBundle 에 저장할 수 있는 값을 자동으로 저장한다.
    • 따라서 커스텀 모델을 저장하고 보여주려면 Parcelize 혹은 Serialize 을 사용해야 할 것 같다.

추가 : Bundle에 추가할 수 없는 항목을 저장하려는 경우

  1. Parcelize
@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

  1. MapSaver
  • 어떤 이유로 @Parcelize가 적합하지 않을 경우 mapSaver를 사용하여 시스템이 Bundle에 저장할 수 있는 값 집합으로 객체를 변환할 수 있다.
data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

  1. ListSaver
    data class City(val name: String, val country: String)
    

val CitySaver = listSaver<City, Any>(
save = { listOf(it.name, it.country) },
restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}


<br>

### State Hoisting

- Stateful은 상태가 있고, Stateless는 상태가 없는 것을 의미한다.
- 선언형 UI 프레임워크인 Compose는 `Stateless` 한 특징이 장점이다.
- UI에 대한 UI 상태의 상호 의존성을 끊어 재사용성을 높이고 UI에 대한 테스트도 간편해지기 때문이다.

하지만 가끔 상태가 UI에 저장되어야 하는 경우가 존재한다. 예를 들어 `TextFilel` 의 경우 어떤 텍스트가 입력되었는지 저장해야하기 때문에 상태를 가져야한다.

<br>

#### **State Hoisting 이란**

컴포즈에서는 `remember` 를 사용하여 객체를 저장하는 컴포저블은 `stateful` 하다고 말할 수 있다. `stateful` 한 컴포저블은 호출자가 상태를 제어 및 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용하다. 그러나 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 더 어려운 경향이 있다.

- **State Hoisting이란 Staeful 한 컴포저블을 Stateless 하도록 만들기 위한 디자인패턴**이다.
- 직역해보면 "상태(State)를 끌어올리기(Hoisting)" 라는 뜻이다.
- State Hoisting을 통해 자식 컴포저블의 State를 해당 컴포저블을 호출하는 컴포저블 쪽으로 끌어올림으로써 자식 컴포저블을 Stateless 하게 만드는 것이다.

> 정리하면 State Hoisting은 자식 컴포저블의 State를 호출부로 끌어올리는 것을 의미한다.
> 

<br>

#### **State Hoisting 하는 법**

State Hoisting에서는 State를 두 변수로 나누는 방식으로 끌어올린다.

- **value: T**  -  표시할 현재 값
- **onValueChange: (T) -> Unit** -  새 값이 들어왔을 때 값을 변경하도록 요청하는 함수(이벤트) 

<br>

끌어올려진 state는 몇가지 중요한 속성을 갖게 된다.

- **단일 소스 저장소**
    - 중복된 상태를 갖는 것 대신 state를 옮김으로써, text에 대해 단일 소스임을 확신한다. 
    - 이는 버그 발생을 방지한다.
- **공유 가능성(Shareable)**
    - 끌어올려진 state는 다양한 컴포저블과 함께 변경가능한 값으로써 공유된다. 
    - 여기에서는 TodoInputTextField 및 TodoEditButton 내에서 함께 state를 사용한다.
- **가로채기(Interceptable)**
    - TodoItemInput은 state가 변경되기 전에 이벤트를 무시하거나 수정할 수 있다. 
    - 예를 들면, TodoItemInput은 사용자가 입력 할 때 :emoji-codes:를 이모지로 변환 할 수 있다.
- **분리하기(Decoupled)**
    - TodoInputTextField에 대한 state는 아마 어딘가에 저장된다. 
    - 예를 들어, TodoInputTextField를 수정하지 않고 문자를 입력할 때 마다 업데이트 되는 상태를 Room 데이터베이스에 저장하도록 선택할 수 있다.

<br>

### State Hoisting 코드 실습

스테이트풀(Stateful)과 스테이트리스(Stateless) 카운터라는 두 부분으로 분할하여 WaterCounter 컴포저블을 리팩터링해보자.


**StatelessCounter**
```kotlin
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       if (count > 0) {
           Text("You've had $count glasses.")
       }
       Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
           Text("Add one")
       }
   }
}
  • StatelessCounter의 역할은 count를 표시하고 count를 늘릴 때 함수를 호출하는 것이다.
  • count는 컴포저블 함수의 매개변수로, onIncrement는 count(상태)가 증가해야 할 때 호출하도록 하자.

StatefulCounter

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
   var count by rememberSaveable { mutableStateOf(0) }
   StatelessCounter(count, { count++ }, modifier)
}
  • StatefulCounter는 상태를 소유합니다.
  • 즉, count 상태를 보유하고 StatelessCounter 함수를 호출할 때 상태를 수정한다.

이렇게 하면 countStatelessCounter 에서 StatefulCounter 로 끌어올린 것이다.


Stateless 컴포저블 재사용

@Composable
fun StatefulCounter() {
    var waterCount by remember { mutableStateOf(0) }

    var juiceCount by remember { mutableStateOf(0) }

    StatelessCounter(waterCount, { waterCount++ })
    StatelessCounter(juiceCount, { juiceCount++ })
}
  • 물과 주스의 잔 개수를 계산하려면 waterCount와 juiceCount를 기억하고 StatelessCounter 컴포저블 함수를 사용하여 서로 다른 두 가지 독립 상태를 표시한다.

image
  • juiceCount가 수정되면 StatefulCounter가 재구성된다.
  • 리컴포지션중에 Compose는 juiceCount를 읽는 함수를 식별하고 이러한 함수의 리컴포지션만 트리거합니다.

image
  • 사용자가 juiceCount를 늘리면 StatefulCounter가 재구성되고 juice를 읽는 StatelessCounter도 재구성된다.
  • 하지만 waterCount를 읽는 StatelessCounter는 재구성되지 않는다.

따라서 각각 독립된 Stateless 컴포저블 함수를 가질 수 있고 이는 각각을 따로 재사용할 수 있음을 의미한다.


Stateful 컴포저블은 여러 컴포저블 함수에 동일한 State 제공 가능하다.

@Composable
fun StatefulCounter() {
   var count by remember { mutableStateOf(0) }

   StatelessCounter(count, { count++ })
   AnotherStatelessMethod(count, { count *= 2 })
}
  • 이 경우 개수가 StatelessCounter 또는 AnotherStatelessMethod에 의해 업데이트되면 예상대로 모든 항목이 재구성된다.
  • 끌어올린 상태는 공유할 수 있으므로 불필요한 리컴포지션을 방지하고 재사용성을 높이려면 컴포저블에 필요한 상태만 전달해야 한다.
  • 따라서 공식문서에서는 컴포저블 디자인에서 필요한 매개변수만 전달하는 것이 핵심이라고 한다.

List 사용

다음 두 기능을 추가해보자

  • 작업을 완료로 표시하려면 체크박스를 체크한다.
  • 완료하는 데 관심이 없는 작업을 목록에서 삭제한다.

리스트를 다 만들었으면 한 가지 문제가 생긴다.


다음 단계에 따라 시도해 보자.

  • 이 목록 상단에 있는 요소(예: 요소 1, 2)를 선택한다.
  • 화면 밖으로 나가도록 목록 하단으로 스크롤한다.
  • 앞서 선택한 항목까지 상단으로 다시 스크롤한다
  • 선택이 해제되어 있는 것을 볼 수 있다.

항목이 컴포지션을 종료하면 기억된 상태가 삭제된다는 문제가 있다. LazyColumn 에 있는 항목의 경우 스크롤하면서 항목을 지나치면 항목이 컴포지션을 완전히 종료하므로 더 이상 항목이 표시되지 않는다.

이 문제는 rememberSaveable 을 사용해서 해결할 수 있다. rememberSaveableLazyList 와 함께 작동하면 항목은 컴포지션을 종료해도 유지될 수 있다.

var checkedState by rememberSaveable { mutableStateOf(false) }

관찰 가능한 MutableList

  • 목록에서 작업을 삭제하는 동작을 추가하려면 List를 변경 가능한 List로 만들어야 한다.
  • 이를 위해 일반적으로 우리가 많이 쓰는 MutableList 혹은 ArrayList 를 사용하면 작동하지 않는다.
  • 이러한 유형의 List는 변경을 Compose에게 알리지 않기 때문이다.

따라서 Compose에서 관찰할 수 있는 MutableList를 만들어야한다. 이 구조를 사용하면 Compose가 항목이 추가되거나 목록에서 삭제될 때 변경사항을 추적하여 UI를 재구성할 수 있다.

  • 확장 함수 toMutableStateList() 를 사용하면 변경 가능하거나 변경 불가능한 초기 Collection(예: List)에서 관찰 가능한 MutableList를 만들 수 있다.
  • 또는 메서드 mutableStateListOf 를 사용하여 관찰 가능한 MutableList를 만들고 요소를 추가할 수도 있다.

WellnessScreen

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()

       val list = remember { getWellnessTasks().toMutableStateList() }
       WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
   }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
  • getWellnessTasks() 를 호출하고 이전에 배운 확장 함수 toMutableStateList 를 사용하여 리스트를 만든다.
  • WellnessTaskList 컴포저블 함수의 매개변수에 listonCloseTask 를 추가한다.

WellnessTaskList

fun WellnessTasksList(
   list: List<WellnessTask>,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(modifier = modifier) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
       }
   }
}
  • items 메서드는 key 매개변수를 수신한다.
  • 변경 가능한 List에서는 내부 데이터가 변경될 때 고유값인 id와 같은 값을 가지고 있어야 한다.

WellnessTaskItem

@Composable
fun WellnessTaskItem(
   taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
   var checkedState by rememberSaveable { mutableStateOf(false) }

   WellnessTaskItemDetail(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = onClose,
       modifier = modifier,
   )
}

@Composable
fun WellnessTaskItemDetail(
    taskName: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier, verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 16.dp),
            text = taskName
        )
        Checkbox(
            checked = checked,
            onCheckedChange = onCheckedChange
        )
        IconButton(onClick = onClose) {
            Icon(Icons.Filled.Close, contentDescription = "Close")
        }
    }
}

image


ViewModel 에서의 State

  • State는 View 단에서 뿐 아니라 다른 레이어와 연결된다.

ViewModel은 UI State와 다른 레이어에 대한 접근 권한을 갖는다. 또한 ViewModel은 Configuration Change 시에도 유지되므로 컴포저블보다 생명주기가 길다.

주의점

  • ViewModel은 컴포지션의 일부가 아니다.
  • 따라서 컴포저블 함수에서 만든 State를 ViewModel에서 보유해서는 안된다.
    • 메모리 누수가 발생할 수 있다.

WellnessViewModel

  • '데이터 소스' getWellnessTasks()를 WellnessViewModel로 이동한다.
  • toMutableStateList를 사용하여 내부 _tasks 변수를 정의하고 tasks를 목록으로 노출하여 ViewModel 외부에서 수정할 수 없도록 한다.
class WellnessViewModel : ViewModel() {
    private val _tasks = getWellnessTasks().toMutableStateList()
    val tasks: List<WellnessTask>
        get() = _tasks

   fun remove(item: WellnessTask) {
       _tasks.remove(item)
   }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") 

WellnessScreen

@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier,
    wellnessViewModel: WellnessViewModel = viewModel()
) {
   Column(modifier = modifier) {
       StatefulCounter()

       WellnessTasksList(
           list = wellnessViewModel.tasks,
           onCloseTask = { task -> wellnessViewModel.remove(task) })
   }
}
  • 매개변수로 viewModel()를 호출하여 WellnessViewModel을 인스턴스화한다.
  • 이 인스턴스가 WellnessTaskList에 리스트를 제공하고 onCloseTask에 remove 함수를 제공한다.