[Android - Kotlin] Jetpack Compose - 3

민채·2024년 2월 5일
0

Android - Codelab

목록 보기
3/10

Compose의 상태

  • Compose는 선언형 UI 프레임워크로, UI의 모습을 코드로 선언하는 것
  • 앱이 실행되는 동안 또는 사용자가 앱과 상호작용할 때 UI를 변경하고자 하면 리컴포지션이라는 프로세스를 사용하여 앱의 컴포지션을 업데이트 한다.

Composition

  • 컴포저블 함수를 실행하면 생성되는 것으로 UI를 그리는 역할
  • Compose 앱은 구성 가능한 함수를 호출하여 데이터를 UI로 변환한다.
    상태가 변경되면 Compose는 영향을 받는 구성 가능한 함수를 새 상태로 다시 실행한다. 그러면 리컴포지션이라는 업데이트된 UI가 만들어진다.
  • 컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트될 수 있다.
  • 컴포지션을 수정하는 유일한 방법은 리컴포지션을 통하는 것

Recomposition

  • 데이터가 변경될 때 컴포지션을 업데이트 하기 위해 컴포저블을 다시 실행하는 것으로, 컴포저블의 입력값이 변경될 때만 실행된다.
  • Compose에서 State 및 MutableState를 사용해 앱의 상태를 관찰/추적 가능한 상태로 설정할 수 있다.
    • State : 변경 불가, 값을 읽을 수만 있다.
    • MutableState : 변경 가능, 값을 읽고 쓸 수 있다.
      • mutableStateOf()를 사용해 관찰 가능한 MutableState를 만들 수 있다. -> 초깃값을 State 객체에 래핑된 매개변수로 수신한 다음, value의 값을 관찰 가능한 상태로 만든다.

mutableStateOf()

  • 상태를 보유함
  • 변경 가능함
  • 관찰 가능하므로, Compose는 값의 변경을 관찰하고 리컴포지션을 트리거하여 UI를 업데이트함

remember

  • 리컴포지션에서 객체를 저장할 수 있다.
  • remember 함수로 계산된 값은 초기 컴포지션 중에 컴포지션에 저장되고 저장된 값은 리컴포지션 중에 반환된다.
  • 상태와 업데이트가 UI에 적절하게 반영되도록 일반적으로 remembermutableStateOf 함수가 함께 사용된다.

상태 호이스팅(State Hoisting)

  • 구성요소를 스테이트리스(Stateless)로 만들기 위해 상태를 호출자로 이동하는 패턴
  • 컴포저블이 가능한 한 적게 상태를 소유하고 컴포저블의 API에 상태를 노출하여 상태를 호이스팅할 수 있도록 컴포저블을 디자인해야 한다.

스테이트리스(Stateless) 컴포저블

  • 상태가 없는 컴포저블
  • 새 상태를 보유하거나 정의하거나 수정하지 않는다.

스테이트풀(Stateful) 컴포저블

  • 시간이 지남에 따라 변할 수 있는 상태를 소유하는 컴포저블

상태 호이스팅을 해야 하는 경우

  • 상태를 여러 구성 가능한 함수와 공유하는 경우
  • 앱에서 재사용할 수 있는 스테이트리스(Stateless) 컴포저블을 만드는 경우

상태 호이스팅이 컴포저블에 적용되는 경우 컴포저블 매개변수에 주로 다음 2개가 추가된다.

  • value: T 매개변수: 표시할 현재 값
  • onValueChange: (T) -> Unit : 사용자가 텍스트 상자에 텍스트를 입력하는 경우 등 값이 변경될때 상태가 업데이트 되도록 트리거되는 콜백 람다

팁 계산기 앱 만들기

상태 호이스팅 적용 전 코드

@Composable
fun TipTimeLayout() {
    Column(
        modifier = Modifier
            .statusBarsPadding()
            .padding(horizontal = 40.dp)
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = stringResource(R.string.calculate_tip),
            modifier = Modifier
                .padding(bottom = 16.dp, top = 40.dp)
                .align(alignment = Alignment.Start)
        )

        EditNumberField(modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth())

        Text(
            text = stringResource(R.string.tip_amount, "$0.00"),
            style = MaterialTheme.typography.displaySmall
        )

        Spacer(modifier = Modifier.height(150.dp))
    }
}

// 15% 팁 금액을 계산
private fun calculateTip(amount: Double, tipPercent: Double = 15.0): String {
    val tip = tipPercent / 100 * amount
    return NumberFormat.getCurrencyInstance().format(tip)
}

@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
    var amountInput by remember { mutableStateOf("") }

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

    TextField(
        value = amountInput,
        onValueChange = { amountInput = it },
        singleLine = true,
        label = { Text(stringResource(R.string.bill_amount)) },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), // 숫자만 입력 가능하게 함
        modifier = modifier
    )
}

Text 컴포저블이 amountInput 변수에서 계산된 amount 변수에 액세스해야 하는데 해당 구조에서는 변수에 접근할 수 없기 때문에 Text 컴포저블에 팁 금액을 표시할 수 없다. (amount 변수는 EditNumberField가 가지고 있음)

상태 호이스팅 적용 후 코드

@Composable
fun TipTimeLayout() {
    // 상태를 TipTimeLayout 함수로 호이스팅 함
    var amountInput by remember { mutableStateOf("") }

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

    Column(
        modifier = Modifier
            .statusBarsPadding()
            .padding(horizontal = 40.dp)
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = stringResource(R.string.calculate_tip),
            modifier = Modifier
                .padding(bottom = 16.dp, top = 40.dp)
                .align(alignment = Alignment.Start)
        )

        // 상태 호이스팅을 했기 때문에 해당 함수 부분도 업데이트 해줌
        // EditNumberField가 스테이트리스가 됨
        EditNumberField(
            value = amountInput,
            onValueChange = { amountInput = it },
            modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
        )

        Text(
            text = stringResource(R.string.tip_amount, tip), // 팁 금액 표시
            style = MaterialTheme.typography.displaySmall
        )

        Spacer(modifier = Modifier.height(150.dp))
    }
}

// 15% 팁 금액을 계산
private fun calculateTip(amount: Double, tipPercent: Double = 15.0): String {
    val tip = tipPercent / 100 * amount
    return NumberFormat.getCurrencyInstance().format(tip)
}

// (String 값을 입력으로 사용하고 반환 값이 없는 함수로 정의)
@Composable
fun EditNumberField(
    // 상태 호이스팅을 위해 추가
    value: String,
    onValueChange: (String) -> Unit, // TextField 컴포저블에 전달된 onValueChange 콜백으로 사용
    modifier: Modifier = Modifier
) {
    TextField(
        value = value,
        onValueChange = onValueChange,
        singleLine = true,
        label = { Text(stringResource(R.string.bill_amount)) },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), // 숫자만 입력 가능하게 함
        modifier = modifier
    )
}

UI 상태를 상위 TipTimeLayout()으로 호이스팅 함 -> TipTimeLayout()이 상태(amountInput) 소유자가 됨

실행 화면

맞춤 팁 계산하기

작업 버튼 추가하기

  • TextField의 keyboardOptions 속성을 추가

스위치 추가하기

  • 스위치는 단일 항목의 상태를 켜거나 끔
  • 결정을 입력하거나 설정과 같은 환경설정을 선언하는 데 사용할 수 있는 선택 컨트롤
  • Switch 컴포저블을 사용

전체 코드

// UI 상태를 상위 TipTimeLayout()으로 호이스팅 함 -> TipTimeLayout()이 상태(amountInput) 소유자
@Composable
fun TipTimeLayout() {
    // 상태를 TipTimeLayout 함수로 호이스팅 함

    /* mutableStateOf로 만들어져 변경 가능한 상태이며 리컴포지션이 예약됨
    remember를 사용하므로 변경사항은 리컴포지션이 끝나도 초기화 되지 않고 유지된다 */
    var amountInput by remember { mutableStateOf("") }
    val amount = amountInput.toDoubleOrNull() ?: 0.0

    var tipInput by remember { mutableStateOf("") }
    val tipPercent = tipInput.toDoubleOrNull() ?: 0.0

    var roundUp by remember { mutableStateOf(false) }

    val tip = calculateTip(amount, tipPercent, roundUp)


    Column(
        modifier = Modifier
            .statusBarsPadding()
            .padding(horizontal = 40.dp)
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = stringResource(R.string.calculate_tip),
            modifier = Modifier
                .padding(bottom = 16.dp, top = 40.dp)
                .align(alignment = Alignment.Start)
        )

        // 상태 호이스팅을 했기 때문에 해당 함수 부분도 업데이트 해줌
        // EditNumberField가 스테이트리스가 됨
        EditNumberField(
            label = R.string.bill_amount,
            keyboardOptions = KeyboardOptions.Default.copy( //  Next 작업 버튼 : 사용자가 현재 입력을 완료했고 다음 텍스트 상자로 이동하려고 함을 나타냄
                keyboardType = KeyboardType.Number,
                imeAction = ImeAction.Next
            ),
            value = amountInput,
            onValueChange = { amountInput = it }, // 입력값이 it에 들어옴
            modifier = Modifier
                .padding(bottom = 32.dp)
                .fillMaxWidth()
        )

        // 팁 퍼센트 입력 필드
        EditNumberField(
            label = R.string.how_was_the_service,
            keyboardOptions = KeyboardOptions.Default.copy( // Done 작업 버튼 : 사용자가 입력을 완료했음을 나타냄
                keyboardType = KeyboardType.Number,
                imeAction = ImeAction.Done
            ),
            value = tipInput,
            onValueChange = { tipInput = it },
            modifier = Modifier
                .padding(bottom = 32.dp)
                .fillMaxWidth()
        )

        // 팁 반올림 스위치
        RoundTheTipRow(
            roundUp = roundUp,
            onRoundUpChanged = { roundUp = it },
            modifier = Modifier.padding(bottom = 32.dp)
        )

        // 팁 금액 표시
        Text(
            text = stringResource(R.string.tip_amount, tip),
            style = MaterialTheme.typography.displaySmall
        )

        Spacer(modifier = Modifier.height(150.dp))
    }
}

// 15% 팁 금액을 계산
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)
}

// TextField를 만들 때 재사용 가능
// (String 값을 입력으로 사용하고 반환 값이 없는 함수로 정의)
@Composable
fun EditNumberField(
    @StringRes label: Int, // 라벨 추가
    keyboardOptions: KeyboardOptions, // 입력 필드에 따라 키보드 옵션을 다르게 설정하기 위해 변수로 받음
    // 상태 호이스팅을 위해 추가
    value: String,
    onValueChange: (String) -> Unit, // TextField 컴포저블에 전달된 onValueChange 콜백으로 사용
    modifier: Modifier = Modifier
) {
    TextField(
        value = value, // 입력창에 표시되는 값
        onValueChange = onValueChange,
        singleLine = true,
        label = { Text(stringResource(label)) }, // 타입 구분을 위해 변수로 얻은 id를 넣음
        keyboardOptions = keyboardOptions, // 키보드 타입 설정
        modifier = modifier
    )
}

@Composable
fun RoundTheTipRow(
    roundUp: Boolean,
    onRoundUpChanged: (Boolean) -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .size(48.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(text = stringResource(R.string.round_up_tip))

        Switch(
            // Switch 컴포저블을 화면 끝에 맞춤
            modifier = modifier
                .fillMaxWidth()
                .wrapContentWidth(Alignment.End),
            checked = roundUp, // 스위치 선택 여부
            onCheckedChange = onRoundUpChanged, // 스위치 값이 변경될 때 호출되는 콜백
        )
    }
}

실행 화면

참조

profile
코딩계의 떠오르는 태양☀️

0개의 댓글