앱의 상태란 시간에 따라 바뀔 수 있는 값을 말한다. 이러한 정의는 앱의 DB부터 작은 변수까지 상당히 광범위하게 포함한다. 네트워크가 끊겼다는 메시지나, 입력창에 텍스트를 입력하고 제출하는 것, 버튼을 탭하는 것 모두 상태라고 할 수 있다.
Intro to State in Compose Codelab
컴포지션은 Compose가 컴포저블을 실행할 때 빌드한 UI에 관한 설명을 말한다. Compose는 Composable 함수를 호출하여 데이터를 UI로 변환한다. 상태에 변경이 발생하면 Compose는 해당 Composable 함수를 새 상태로 다시 실행하고 이를 통해 리컴포지션이라는 업데이트된 UI가 만들어진다. 즉 리컴포지션은 데이터 변경사항에 따라 변경될 수 있는 Composable을 다시 실행하고, 변경사항을 반영하도록 컴포지션을 업데이트하는 것이다. 컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트될 수 있습니다. 리컴포지션을 하려면 Compose는 추적할 상태를 알아야 한다.
Compose에서는 State
및 MutableState
타입을 사용하여 상태를 관찰 가능하거나 추적 가능한 상태로 설정한다. State는 변경할 수 없어 값을 읽어올 수만 있고, MutableState는 변경할 수 있다.
Composable 함수는 remember
를 사용하여 리컴포지션에서 객체를 저장할 수 있다.
금액을 입력하는 입력창은 TextField로 만든다.
@Composable
fun EditNumberField() {
var amountInput = mutableStateOf("0")
TextField(
value = amountInput.value,
onValueChange = { amountInput.value = it },
)
}
위 코드에서는 onValueChange로 값이 입력되었을 때 amountInput 값을 업데이트하도록 되어있다. 예상되는 동작은 다음과 같다.
문제는 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)
)
화면에 표시되는 키보드에 숫자, 이메일 주소, 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)
}
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 }
)
...
}
}
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
),
...
)
키보드 작업을 설정하여 사용자 환경을 개선한다. 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() }
)
)
...
}
}
상태를 기억할 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)
}
...