Compose의 상태

quokka·2022년 8월 6일
0
post-custom-banner

앱의 상태란 시간에 따라 바뀔 수 있는 값을 말한다. 이러한 정의는 앱의 DB부터 작은 변수까지 상당히 광범위하게 포함한다. 네트워크가 끊겼다는 메시지나, 입력창에 텍스트를 입력하고 제출하는 것, 버튼을 탭하는 것 모두 상태라고 할 수 있다.

Tip Time App

Intro to State in Compose Codelab

컴포지션은 Compose가 컴포저블을 실행할 때 빌드한 UI에 관한 설명을 말한다. Compose는 Composable 함수를 호출하여 데이터를 UI로 변환한다. 상태에 변경이 발생하면 Compose는 해당 Composable 함수를 새 상태로 다시 실행하고 이를 통해 리컴포지션이라는 업데이트된 UI가 만들어진다. 즉 리컴포지션은 데이터 변경사항에 따라 변경될 수 있는 Composable을 다시 실행하고, 변경사항을 반영하도록 컴포지션을 업데이트하는 것이다. 컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트될 수 있습니다. 리컴포지션을 하려면 Compose는 추적할 상태를 알아야 한다.

Compose에서는 StateMutableState 타입을 사용하여 상태를 관찰 가능하거나 추적 가능한 상태로 설정한다. State는 변경할 수 없어 값을 읽어올 수만 있고, MutableState는 변경할 수 있다.

Composable 함수는 remember를 사용하여 리컴포지션에서 객체를 저장할 수 있다.

TextField - remember로 상태 저장

금액을 입력하는 입력창은 TextField로 만든다.

@Composable
fun EditNumberField() {
   var amountInput = mutableStateOf("0")
   TextField(
       value = amountInput.value,
       onValueChange = { amountInput.value = it },
   )
}

위 코드에서는 onValueChange로 값이 입력되었을 때 amountInput 값을 업데이트하도록 되어있다. 예상되는 동작은 다음과 같다.

  1. amoutInput 값을 입력된 새 값으로 업데이트한다.
  2. 컴포즈가 amountInput의 상태를 추적하므로 값이 변경되는 즉시 리컴포지션이 예약된다.
  3. 예약된 리컴포지션에 따라 @Composable 한수인 EditNumberField()가 다시 실행된다.
  4. amountInput 값이 초기값 0으로 재설정된다.

문제는 4단계이다. amountInput의 값을 보존하고 있지 않기에 사용자가 어떠한 것을 입력해도 화면에 나타나는 값은 계속 0이다. 실제로는 사용자 입력에 따라 계속 리컴포지션 되고 있지만 amountInput값은 변하지 않으므로 사용자 입장에서는 아무것도 입력이 되지 않는 것처럼 보인다.

이는 remember로 상태를 저장하여 해결할 수 있다.

@Composable
fun EditNumberField() {
   var amountInput by remember { mutableStateOf("") }
   TextField(
       value = amountInput,
       onValueChange = { amountInput = it },
   )
}

팁 계산하기

금액과 팁 퍼센트를 입력받아 계산된 팁을 통화 형식에 맞게 변환하여 리턴하는 메서드를 만든다.

private fun calculateTip(
   amount: Double,
   tipPercent: Double = 15.0
): String {
   val tip = tipPercent / 100 * amount
   return NumberFormat.getCurrencyInstance().format(tip)
}

그리고 amoutInput의 문자열 값을 Double 타입으로 바꿔 팁을 계산한다.

@Composable
fun EditNumberField() {
   var amountInput by remember { mutableStateOf("") }

   val amount = amountInput.toDoubleOrNull() ?: 0.0
   val tip = calculateTip(amount)

   TextField(
       value = amountInput,
       onValueChange = { amountInput = it },
       label = { Text(stringResource(R.string.cost_of_service)) },
       modifier = Modifier.fillMaxWidth(),
       singleLine = true,
       keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
   )
}

문제는 계산된 tip 변수의 값을 사용하는 곳은 EditNumberField()가 아닌 다른 Composable이라는 것이다. EditNumberField()가 값을 기억하고, 상태를 추적하고 있더라도 다른 Composable에서 이 값을 사용할 수 없다. 이 문제를 해결하기위해 필요한 개념이 호이스팅이다.

상태 호이스팅

왼쪽 그림의 amoutInput을 오른쪽처럼 위로 끌어올려 Text 컴포저블로 사용할 수 있도록 하는 것을 호이스팅이라 한다.

EditNumberField()는 value와 사용자 입력에의해 상태가 변경되었을 때 실행될 콜백 람다 onValueChange를 매개변수로 전달받아서 사용한다.

remember 하고있는 amountInput은 위로 끌어올려서 EditNumberField()와 Text() 두 Composable이 모두 사용할 수 있도록 위에다 선언한다.

@Composable
    fun TipTimeScreen(){
        var amountInput by remember { mutableStateOf("") }
        val amount = amountInput.toDoubleOrNull() ?: 0.0
        val tip = calculateTip(amount)
        Column( ... ) {
            ...            
            EditNumberField(
                value = amountInput,
                onValueChange = { amountInput = it }
            )
            Spacer(modifier = Modifier.height(24.dp))
            Text(
                text = stringResource(R.string.tip_amount, tip),
                modifier = Modifier.align(Alignment.CenterHorizontally),
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold
            )
        }
    }

    @Composable
    fun EditNumberField(
        value: String,
        onValueChange: (String) -> Unit
    ){
        TextField(
            value = value,
            onValueChange = onValueChange,
            label = { Text(stringResource(id = R.string.cost_of_service)) },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
        )
    }

전체 코드

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

    @Composable
    fun TipTimeScreen(){
        var amountInput by remember { mutableStateOf("") }
        val amount = amountInput.toDoubleOrNull() ?: 0.0
        val tip = calculateTip(amount)
        Column(
            modifier = Modifier.padding(32.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text(
                text = stringResource(id = R.string.calculate_tip),
                fontSize = 24.sp,
                modifier = Modifier.align(Alignment.CenterHorizontally)
            )
            Spacer(modifier = Modifier.height(16.dp))
            EditNumberField(
                value = amountInput,
                onValueChange = { amountInput = it }
            )
            Spacer(modifier = Modifier.height(24.dp))
            Text(
                text = stringResource(R.string.tip_amount, tip),
                modifier = Modifier.align(Alignment.CenterHorizontally),
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold
            )
        }
    }

    @Composable
    fun EditNumberField(
        value: String,
        onValueChange: (String) -> Unit
    ){
        TextField(
            value = value,
            onValueChange = onValueChange,
            label = { Text(stringResource(id = R.string.cost_of_service)) },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
        )
    }

    private fun calculateTip(amount: Double, tipPercent: Double = 15.0): String{
        val tip = tipPercent/100*amount
        return NumberFormat.getCurrencyInstance().format(tip)
    }

    @Preview(showBackground = true)
    @Composable
    fun DefaultPreview() {
        TipTimewithComposeTheme {
            TipTimeScreen()
        }
    }
}

하위 요소 간격 고정

spacedBy()로 Column내 하위 요소의 간격을 8dp로 고정할 수 있다.

Column(
    modifier = Modifier.padding(32.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
)

KeyboardOptions

화면에 표시되는 키보드에 숫자, 이메일 주소, URL, 비밀번호 등을 입력하도록 keyboardOptions를 설정할 수 있다.
[KeyboardType 참고]

TextField(
   keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)

통화 형식

    private fun calculateTip(amount: Double, tipPercent: Double = 15.0): String{
        val tip = tipPercent/100*amount
        return NumberFormat.getCurrencyInstance().format(tip)
    }

기능 추가

Composable 재사용

EditNumberField를 재사용하여 Tip 입력창을 추가한다.
Tip도 컴포스가 추적하고 기억할 수 있도록 remember를 사용하고, onValueChange에 업데이트를 작성한다.

@Composable
fun TipTimeScreen() {
   var amountInput by remember { mutableStateOf("") }
   var tipInput by remember { mutableStateOf("") }

   val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
   val amount = amountInput.toDoubleOrNull() ?: 0.0
   val tip = calculateTip(amount, tipPercent)

   Column(
       ...
   ) {
       ...
       EditNumberField(
           label = R.string.bill_amount,
           value = amountInput,
           onValueChange = { amountInput = it }
       )
       EditNumberField(
           label = R.string.how_was_the_service,
           value = tipInput,
           onValueChange = { tipInput = it }
       )
       ...
   }
}

ImeAction

KeyboardOptions로 키보드 타입을 Number로 설정하는 것을 앞에서 다뤘다. KeyboardOptions에는 keyboardtype 외에 ImeAction도 설정할 수 있다.

ImeAction 설정으로는 키보드 끝에 있는 버튼을 설정할 수 있다. (일반적으로 엔터로 사용되는 키)

왼쪽은 ImeAction.Next 오른쪽은 ImeAction.Done
이 외에도 Search, Send, Go 등을 설정할 수 있다.

            EditNumberField(
                ...
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Number,
                    imeAction = ImeAction.Next
                ),
                ...
            )
            EditNumberField(
                ...
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Number,
                    imeAction = ImeAction.Done
                ),
                ...
            )

KeyboardActions, LocalFocusManager

키보드 작업을 설정하여 사용자 환경을 개선한다. Bill amount 입력창에 입력하고 엔터(Next)를 누르면 자동으로 Tip 입력창으로 넘어가고 Tip 입력 후 엔터(Done)를 누르면 키보드를 닫는다.

Compose에서 포커스를 제어할 때 LocalFocusManager를 사용한다.
onNext = { focusManager.moveFocus(FocusDirection.Down) } Next를 누르면 다음 Composable인 Tip 입력창으로 넘어간다.
onDone = { focusManager.clearFocus() }  Done 작업에서 clearFocus()로 포커스를 지우면 키보드가 닫힌다.

    @Composable
    fun TipTimeScreen(){
        ...
        val focusManager = LocalFocusManager.current
        Column(
            ...
        ) {
            ...
            EditNumberField(
                ...
                keyboardActions = KeyboardActions(
                    onNext = { focusManager.moveFocus(FocusDirection.Down) }
                )
            )
            EditNumberField(
                ...
                keyboardActions = KeyboardActions(
                    onDone = { focusManager.clearFocus() }
                )
            )
            ...
        }
    }

반올림 Switch

상태를 기억할 roundUp 변수를 추가하고, 텍스트와 스위치를 표시하는 Composable 함수를 추가한다.
onCheckedChange 콜백을 전달해서 스위치 클릭마다 콜백이 실행되도록 한다.(roundUp 설정)
흰 바탕에서 스위치가 잘 보이지 않아 colors도 설정하였다.
반올림은 kotlin.math에 있는 ceil 메서드를 사용한다.

    @Composable
    fun TipTimeScreen(){
        ...
        var roundUp by remember { mutableStateOf(false) }
        ...
        Column(
            ...
        ) {
            ...
            RoundTheTipRow(
                roundUp = roundUp,
                onRoundUpChanged = {roundUp = it}
            )
            ...
        }
    }
	...
    
    @Composable
    fun RoundTheTipRow(
        roundUp: Boolean,
        onRoundUpChanged: (Boolean) -> Unit,
        modifier: Modifier = Modifier,
    ){
        Row(
            modifier = modifier
                .fillMaxWidth()
                .size(48.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text( text = stringResource(id = R.string.round_up_tip) )
            Switch(
                modifier = modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
                checked = roundUp,
                onCheckedChange = onRoundUpChanged,
                colors = SwitchDefaults.colors( uncheckedThumbColor = Color.DarkGray )
            )
        }
    }

    private fun calculateTip(
        amount: Double,
        tipPercent: Double = 15.0,
        roundUp: Boolean
    ): String{
        var tip = tipPercent/100*amount
        if(roundUp){
            tip = kotlin.math.ceil(tip)
        }
        return NumberFormat.getCurrencyInstance().format(tip)
    }
    ...
post-custom-banner

0개의 댓글