본문 바로가기
Android/Jetpack Compose App

JETPACK COMPOSE: 팁 계산기 만들기 - 3

by 개발자J의일상 2022. 2. 8.
반응형

지난 시간 팁 계산기 만들기 2편

JETPACK COMPOSE: 팁 계산기 만들기 - 2

 

JETPACK COMPOSE: 팁 계산기 만들기 - 2

지난 시간 팁 계산 만들기 1편 JETPACK COMPOSE: 팁 계산기 만들기 - 1 JETPACK COMPOSE: 팁 계산기 만들기 - 1 앱을 만들기 전에 몇가지에 대해서 배워보겠습니다. 아래 코드는 jetpack으로 default Activity를..

mypark.tistory.com

 

이제 구현해야 될 것은 숫자가 입력되었을 때 SplitTip을 계산하는 것입니다. 

 

숫자가 입력되기 전에 Split과 Tip은 보이지 않아야 합니다. 그래서 우리는 validState을 이용하여 이를 구현하려고 합니다.

 

InputField아래에 if문을 추가하여 validState일 때만 Text를 보여주고 아닐 때는 빈 Box를 설정합니다.

 

        Column() {
            InputField(valueState = totalBillState,
                labelId = "Enter Bill",
                enabled = true,
                isSingleLine = true,
                onAction = KeyboardActions {
                    if(!validState) return@KeyboardActions
                    onValChange(totalBillState.value.trim())
                    keyboardController?.hide()
                })
            if (validState) {
                Text(text = "Valid")
            } else {
                Box() {}
            }
        }

 

OutlinedTextField에 값이 입력되었을 때 입력했던 Text의 "Valid"가 출력되는 것을 볼 수 있습니다.

 

 

이제 Split Buttons를 만들어 봅시다.

Split buttons은 Split이라는 Text와 -/+ 버튼, 그리고 얼마나 눌렸는지 숫자로 이루어져 있습니다.

 

이 구성요소들은 같은 라인에 들어있기 때문에 Row를 사용하여 구성해줘야 합니다.

 

 

그럼 Split buttons부분을 구현해봅시다. validState부분의 Text를 Row로 바꿔 구현하면 됩니다.

 

            if (validState) {
                Row(modifier = Modifier.padding(3.dp),
                    horizontalArrangement = Arrangement.Start) {
                    Text("Split",
                        modifier = Modifier.align(
                            alignment = Alignment.CenterVertically))
                    Spacer(modifier = Modifier.width(120.dp))
                    Row(modifier = Modifier.padding(horizontal = 3.dp),
                        horizontalArrangement = Arrangement.End) {
                        RoundIconButtons(
                            imageVector = Icons.Default.Remove,
                            onClick = { /*TODO*/ })
                        RoundIconButtons(
                            imageVector = Icons.Default.Add,
                            onClick = { /*TODO*/ })
                    }
                }
            } else {
                Box() {}
            }

 

Row안에 Text를 만들고 "Split"으로 값을 넣어주고 text가 vertical 방향에서 중앙에 올 수 있도록 Alignment.CenterVertically으로 align을 해줍니다.

 

그다음 Split 다음 -/+가 오기 전에 공간을 주기 위해 Spacer를 생성하고 width를 120.dp로 하여 공간을 확보합니다.

다음으로 -와 + Icon을 생성하는데 아이콘을 만드는 부분은 공통인 부분이 많기 때문에 따로 package를 만들고 함수를 만들어 사용합니다.

RoundIconButtons를 생성하고 imageVector로 Icons.Default.RemoveIcons.Default.Add를 각각 넣어주어 -/+ Icon을 생성합니다.

 

RoundIconButtons는 아래와 같이 구현을 하면 됩니다.

여기에도 클릭이 되면 숫자가 증가되고 감소되는 기능을 구현해야 되기 때문에 callback형식의 onClick lambda 함수를 매개변수로 넣어줍니다. 

 

val IconbuttonSizeModifier = Modifier.size(40.dp)
@Composable
fun RoundIconButtons(
    modifier: Modifier = Modifier,
    imageVector: ImageVector,
    onClick: () -> Unit,
    tint: Color = Color.Black.copy(alpha = 0.8f),
    backgroundColor: Color = MaterialTheme.colors.background,
    elevation: Dp = 4.dp,
    ) {
    Card(modifier = modifier.padding(all = 4.dp)
        .clickable { onClick.invoke() }.then(IconbuttonSizeModifier),
    shape = CircleShape,
    backgroundColor = backgroundColor,
    elevation = elevation) {
        Icon(imageVector = imageVector, contentDescription = "Plus or Minus icon",
            tint = tint)
    }
}

 

Card를 만드는 데 .clickable로 하고 클릭이 되면 onClick lambda를 실행시키는 invoke()를 수행합니다. 

Card안에 Icon을 생성하고 매개변수로 받아온 imageVectortint를 넣어줍니다. 

 

Enter Bill 창이 앱에 꽉 차지 않는 문제가 있는데 이 것은 InputField의 modifier에 .fillMaxWidth()를 추가해주면 OutlinedTextField가 앱 화면에 horizontal로 꽉 차게 됩니다.

 

@Composable
fun InputField(
    modifier: Modifier = Modifier,
    valueState: MutableState<String>,
    labelId: String,
    enabled : Boolean,
    isSingleLine: Boolean,
    keyboardType: KeyboardType = KeyboardType.Number,
    imeAction: ImeAction = ImeAction.Next,
    onAction: KeyboardActions = KeyboardActions.Default
    ) {

    OutlinedTextField(value = valueState.value,
        onValueChange = { valueState.value = it },
                    label = { Text(text = labelId)},
                    leadingIcon = { Icon(imageVector = Icons.Rounded.AttachMoney,
                        contentDescription = "Money Icon")},
        singleLine = isSingleLine,
        textStyle = TextStyle(fontSize = 18.sp,
            color = MaterialTheme.colors.onBackground),
        modifier = modifier
            .padding(bottom = 10.dp, start = 10.dp, end = 10.dp)
            .fillMaxWidth(),
        enabled = enabled,
        keyboardOptions = KeyboardOptions(keyboardType = keyboardType,
            imeAction = imeAction),
        keyboardActions = onAction
    )

 

-/+안에 숫자는 간단하게 RoundIconButtons 사이에 Text를 생성해주면 됩니다.

일단은 그냥 2로 만들어 놓고 나중에 연결하려고 합니다. - 아이콘과 + 아이콘 사이에 padding을 줘서 어느 정도 간격을 디자인 적으로 줍니다.

 

            if (validState) {
                Row(modifier = Modifier.padding(3.dp),
                    horizontalArrangement = Arrangement.Start) {
                    Text("Split",
                        modifier = Modifier.align(
                            alignment = Alignment.CenterVertically))
                    Spacer(modifier = Modifier.width(120.dp))
                    Row(modifier = Modifier.padding(horizontal = 3.dp),
                        horizontalArrangement = Arrangement.End) {
                        RoundIconButtons(
                            imageVector = Icons.Default.Remove,
                            onClick = { /*TODO*/ })
                        Text(text = "2",
                            modifier = Modifier.align(Alignment.CenterVertically)
                                .padding(start = 9.dp, end = 9.dp))
                        RoundIconButtons(
                            imageVector = Icons.Default.Add,
                            onClick = { /*TODO*/ })
                    }
                }
            } else {
                Box() {}
            }

 

이제 total tip 부분을 구현해 봅시다.

다시 Row로 구현을 해주면 됩니다. Total tip 부분도 결국 text들이 Row형태로 배치되기 때문에 위에서 구현했던 Split과 같이 Row로 구현하면 됩니다.

 

//Tip Row
Row {
    Text(text = "Tip",
        modifier = Modifier.align(alignment = Alignment.CenterVertically))
    Spacer(modifier = Modifier.width(200.dp))
    Text(text = "$33.00",
        modifier = Modifier.align(alignment = Alignment.CenterVertically))
}

 

text를 alignment를 CenterVertically로 해서 vertical 방향의 중앙에 오게 align을 해주고 중간에 Spacer를 넣어서 일정 부분 공간을 주도록 합니다.

아래와 같이 구현이 완료된 것을 볼 수 있습니다.

 

 

이제 마지막으로 Tip PercentageTip Slider를 구현해보도록 하겠습니다.

 

두 가지는 하나로 묶여 있어야 하기 때문에 Column으로 묶으면 아래 위로 위치하게 할 수 있습니다.

33%가 중앙에 있어야 하기 때문에 horizontalAlignment를 Alignment.CenterHorizontally로 설정해 주고 

Spacer로 height를 지정해 주어 Slider와 일정 공간을 만들어 줍니다.

Slider는 value와 onValueChange를 매개변수로 받는데 값을 받을 value를 mutableStateOf로 double 형태로 초기화해주고 onValueChange에서 sliderPositionState.value를 계속 업데이트해줍니다.

 

//Tip Row
Row(modifier = Modifier
    .padding(horizontal = 3.dp, vertical = 12.dp)
    ) {
    Text(text = "Tip",
        modifier = Modifier.align(alignment = Alignment.CenterVertically))
    Spacer(modifier = Modifier.width(200.dp))
    Text(text = "$33.00",
        modifier = Modifier.align(alignment = Alignment.CenterVertically))
}
Column(verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally) {
    Text(text = "33%")
    Spacer(modifier = Modifier.height(14.dp))

    //Slider
    Slider(value = sliderPositionState.value,
        onValueChange = { newVal ->
            sliderPositionState.value = newVal
        })
}
val sliderPositionState = remember {
    mutableStateOf(0f)
}

 

아래와 같이 % 와 slider가 생성됨을 확인할 수 있습니다.

 

 

이제 slider에 일정한 간격으로 눈금이 생기도록 만들어 봅시다.

 

padding을 start를 16.dp end를 16.dp로 주어 디자인적으로 적당한 간격을 주고

steps를 5로 설정하여 전체 slider에서 5+1 steps정도 이동할 수 있을 만큼 눈금이 생기고 한번 클릭하면 전체가 100이라고 가정하면 16.6 정도 이동이 됩니다.

 

//Slider
Slider(value = sliderPositionState.value,
    onValueChange = { newVal ->
        sliderPositionState.value = newVal
    },
    modifier = Modifier.padding(start = 16.dp, end = 16.dp),
    steps = 5,
    onValueChangeFinished = {
        
    })

 

이제 각각의 값을 계산할 수 있도록 연결해봅시다. 

 

1. slider가 바뀔 때마다 total Tip이 얼마인지 update가 되어야 하고

2. slider가 바뀔 때마다 total per person이 얼마를 내야 하는지가 update가 되어야 합니다.

 

그래서 1번을 위한 calculateTotalTip 함수를 만들고

2번을 위한 calculateTotalPerPerson 함수를 만듭니다. 

 

total Tip은 현재 총 내야 하는 값에 %를 곱하고 100으로 나눠주면 계산을 할 수 있습니다.

내야 하는 값(totalBillState), tipPercentagesliderPositionState.value에 100을 곱한 값입니다.

 

totalPerPersonState는 splitBy가 몇 명인지에 따라 나눠지는 게 달라지기 때문에 splitByState를 받아와야 합니다.

 

//Slider
Slider(value = sliderPositionState.value,
    onValueChange = { newVal ->
        sliderPositionState.value = newVal
        tipAmountState.value = calculateTotalTip(totalBillState.value.toDouble(), tipPercentage)
        totalPerPersonState.value = calculateTotalPerPerson(totalBillState.value.toDouble(),
            splitBy = splitByState.value,
            tipPercentage)
    },
    modifier = Modifier.padding(start = 16.dp, end = 16.dp),
    steps = 5,
    onValueChangeFinished = {

    })
val tipPercentage = (sliderPositionState.value * 100).toInt()

 

calculateTotalTip은 totalBill이 1보다 크고 empty가 아니면 아까 위에서 설명한 계산식으로 total tip을 계산합니다.

 

calculateTotalPerPerson은 calculateTotalTip에서 계산된 tip에 현재 입력된 totalBill을 더하면 총 내야 하는 값이 계산이 됩니다. 여기에 splitBy만큼 나누면 N빵이 계산이 가능합니다!

 

fun calculateTotalTip(totalBill: Double,
                      tipPercentage: Int): Double {
    return if (totalBill > 1 && totalBill.toString().isNotEmpty())
        (totalBill * tipPercentage) / 100 else 0.0
}

fun calculateTotalPerPerson(totalBill: Double,
                            splitBy: Int,
                            tipPercentage: Int): Double {
    val bill = calculateTotalTip(totalBill = totalBill, tipPercentage = tipPercentage) + totalBill
    return (bill / splitBy)
}

 

또한 우리는 -/+를 누르게 되면 splitBy가 변경되어 total per person의 값이 변경되어야 하므로 매번 -/+가 클릭될 때마다 totalPerPersonState를 계산해줍니다.

 

RoundIconButtons(
    imageVector = Icons.Default.Remove,
    onClick = {
        splitByState.value = if(splitByState.value > 1) splitByState.value - 1
        else 1
        totalPerPersonState.value = calculateTotalPerPerson(totalBillState.value.toDouble(),
            splitBy = splitByState.value,
            tipPercentage)
    })
Text(text = "${splitByState.value}",
    modifier = Modifier.align(Alignment.CenterVertically)
        .padding(start = 9.dp, end = 9.dp))
RoundIconButtons(
    imageVector = Icons.Default.Add,
    onClick = {
        if (splitByState.value < range.last) {
            splitByState.value = splitByState.value + 1
        }
        totalPerPersonState.value = calculateTotalPerPerson(totalBillState.value.toDouble(),
            splitBy = splitByState.value,
            tipPercentage)
    })

 

16 line에 splitByState에서 range는 아래와 같이 정의됩니다.

1~100까지만 설정이 가능하도록 하고 싶을 때 IntRange를 설정해 줍니다.

 

val range = IntRange(start = 1, endInclusive = 100)

 

마지막으로 State Hoisting을 해보겠습니다.

State Hoisting에 대해 잘 정리된 글이 있어서 가져와 봤습니다. 

 

Composable 함수가 재호출 되고 recomposing의 여부를 결정하는 상태를 함수 내부가 아닌 외부로 노출시키는 작업을 state hoisting이라고 합니다. state를 hoisting 시키면 불필요하게 상태가 중복되는 걸 막을 수 있고 이에 따라 발생되는 버그도 방지할 수 있습니다. 또한 recomposing의 대상의 되는 state를 함수 외부에서 관리하여 테스트를 쉽게 할 수 있도록 만들고 composable 함수의 재 사용성도 높일 수 있습니다.

출처: https://tourspace.tistory.com/403 [투덜이의 리얼 블로그]

 

우리는 BillForm() 안에 많은 State들을 생성했습니다. 하지만 이 것들은 재 호출되고 함수 내부에 있기 때문에 외부로 이동시키는 것이 좋습니다.

 

@ExperimentalComposeUiApi
@Composable
fun BillForm(modifier: Modifier = Modifier,
            onValChange: (String) -> Unit = {},
            ) {
    val totalBillState = remember {
        mutableStateOf("")
    }
    val validState = remember(totalBillState.value) {
        totalBillState.value.trim().isNotEmpty()
    }
    val keyboardController = LocalSoftwareKeyboardController.current

    val sliderPositionState = remember {
        mutableStateOf(0f)
    }
    val tipPercentage = (sliderPositionState.value * 100).toInt()

    val tipAmountState = remember {
        mutableStateOf(0.0)
    }

    val splitByState = remember {
        mutableStateOf(1)
    }
    val totalPerPersonState = remember {
        mutableStateOf(0.0)
    }
    val range = IntRange(start = 1, endInclusive = 100)

 

아래와 같이 BillForm의 매개변수로 State를 넘겨주는 것으로 코드를 변경하였습니다.

이제 MainContent에서 해당 State들을 선언하게 됩니다.

 

@ExperimentalComposeUiApi
@Preview
@Composable
fun MainContent() {
    val tipAmountState = remember {
        mutableStateOf(0.0)
    }

    val splitByState = remember {
        mutableStateOf(1)
    }
    val totalPerPersonState = remember {
        mutableStateOf(0.0)
    }
    val range = IntRange(start = 1, endInclusive = 100)
    BillForm(splitByState = splitByState,
        tipAmountState = tipAmountState,
        totalPerPersonState = totalPerPersonState,
        range = range)
}
@ExperimentalComposeUiApi
@Composable
fun BillForm(modifier: Modifier = Modifier,
             range: IntRange = 1..100,
             splitByState: MutableState<Int>,
             tipAmountState: MutableState<Double>,
             totalPerPersonState: MutableState<Double>,
            onValChange: (String) -> Unit = {},
            ) {

 

이것으로 모든 구현이 완료되었습니다.

 

Slider, Space, Row, Column, KeyboardAction, OutlinedTextField, Icon 등 많은 개념을 배웠습니다.

 

차근차근 정리해보시기 바랍니다.

 

감사합니다.

300x250

댓글