๊นจ์•Œ ๊ฐœ๋… ๐Ÿ“‘/Android

[Android] Jetpack Compose - ๊ฐ์ฒด ์ง€ํ–ฅ์  UI ๋ ˆ์ด์–ด ์„ค๊ณ„

interfacer_han 2024. 9. 11. 17:54

#1 ๊ฐœ์š”

UI ๋ ˆ์ด์–ด  |  Android Developers

์ด ํŽ˜์ด์ง€๋Š” Cloud Translation API๋ฅผ ํ†ตํ•ด ๋ฒˆ์—ญ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. UI ๋ ˆ์ด์–ด ์ปฌ๋ ‰์…˜์„ ์‚ฌ์šฉํ•ด ์ •๋ฆฌํ•˜๊ธฐ ๋‚ด ํ™˜๊ฒฝ์„ค์ •์„ ๊ธฐ์ค€์œผ๋กœ ์ฝ˜ํ…์ธ ๋ฅผ ์ €์žฅํ•˜๊ณ  ๋ถ„๋ฅ˜ํ•˜์„ธ์š”. UI์˜ ์—ญํ• ์€ ํ™”๋ฉด์— ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ฐ์ดํ„ฐ๋ฅผ

developer.android.com

์•ˆ๋“œ๋กœ์ด๋“œ UI ์„ค๊ณ„์— ๋Œ€ํ•œ ๊ณต์‹ ๊ฐ€์ด๋“œ์— ๊ธฐ๋ฐ˜ํ•ด, ์ƒ˜ํ”Œ Jetpack Compose ์•ฑ์„ ๋งŒ๋“ค์–ด๋ณธ๋‹ค.
 

#2 ์•ฑ ๋ฏธ๋ฆฌ๋ณด๊ธฐ

#2-1 ์ž‘๋™ ํ™”๋ฉด

๊ตญ๊ฐ€, ๋Œ€๋ฅ™ ๋ณ„๋กœ ๋‹ค๋ฅธ ์‹ ๋ฐœ ์‚ฌ์ด์ฆˆ๋ฅผ ์ƒํ˜ธ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ„๋‹จํ•œ ์•ฑ์ด๋‹ค. 4๊ฐœ์˜ TextField ์ค‘ ์•„๋ฌด TextField์— ๊ฐ’์ด ์ž…๋ ฅ๋˜๋ฉด "๋ณ€ํ™˜" ๋ฒ„ํŠผ์ด ํ™œ์„ฑํ™”๋˜์–ด ํด๋ฆญ์ด ๊ฐ€๋Šฅํ•ด์ง„๋‹ค. 4๊ฐœ์˜ TextField ์ค‘ ์˜ค์ง ํ•˜๋‚˜์—๋งŒ ๊ฐ’ ์ž…๋ ฅ์ด ๊ฐ€๋Šฅํ•˜๊ธฐ์—, ์˜ˆ๋ฅผ ๋“ค์–ด ํ•œ๊ตญ์— ๊ฐ’์„ ์ž…๋ ฅํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ๋ฏธ๊ตญ์— ๊ฐ’์„ ์ž…๋ ฅํ•˜๋ฉด ํ•œ๊ตญ์— ์ž…๋ ฅ๋˜์—ˆ๋˜ ๊ฐ’์€ ์‚ฌ๋ผ์ง„๋‹ค. ๋ณ€ํ™˜ ์™„๋ฃŒ ํ›„์—๋Š” ๋ชจ๋“  TextField๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋˜์–ด ๊ฐ’์„ ์ž…๋ ฅํ•  ์ˆ˜ ์—†๊ฒŒ๋˜๊ณ , "๋ณ€ํ™˜" ๋ฒ„ํŠผ์˜ ํ…์ŠคํŠธ๊ฐ€ "์ดˆ๊ธฐํ™”"๋กœ ๋ณ€ํ•œ๋‹ค. "์ดˆ๊ธฐํ™”" ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์ดˆ๊ธฐ ํ™”๋ฉด์œผ๋กœ ๋Œ์•„๊ฐ„๋‹ค.
 

#2-2 ์—๋Ÿฌ ์ฒ˜๋ฆฌ

์‚ฌ์šฉ์ž๊ฐ€ "๋ณ€ํ™˜" ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„ ๋•Œ ๋ณ€ํ™˜์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค๋ฉด, ๋ณ€ํ™˜์ด ๋ถˆ๊ฐ€๋Šฅํ•œ ์ด์œ ๋ฅผ Snackbar๋กœ ํ‘œ์‹œํ•œ๋‹ค.
 

#3 ๊ตฌ์กฐ

#3-1 ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„

[Android] Jetpack Compose - State Hoisting

#1 ๊ฐœ์š” ์ƒํƒœ๋ฅผ ํ˜ธ์ด์ŠคํŒ…ํ•  ๋Œ€์ƒ ์œ„์น˜  |  Jetpack Compose  |  Android Developers์ด ํŽ˜์ด์ง€๋Š” Cloud Translation API๋ฅผ ํ†ตํ•ด ๋ฒˆ์—ญ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ƒํƒœ๋ฅผ ํ˜ธ์ด์ŠคํŒ…ํ•  ๋Œ€์ƒ ์œ„์น˜ ์ปฌ๋ ‰์…˜์„ ์‚ฌ์šฉํ•ด ์ •๋ฆฌํ•˜๊ธฐ ๋‚ด ํ™˜๊ฒฝ

kenel.tistory.com

State Hoisting ํŒจํ„ด์€ ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„(Unidirectional Data Flow)์„ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ๋„๊ตฌ์˜€๋‹ค. ์—ฌ๊ธฐ์—์„œ๋„ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„์„ ๊ตฌํ˜„ํ•  ๊ฒƒ์ด๋‹ค. State Hoisting์˜ ํšจ๊ณผ๋ฅผ ๊ทน๋Œ€ํ™”์‹œํ‚ค๊ธฐ ์œ„ํ•ด ViewModel์„ ์‚ฌ์šฉํ•  ๊ฑด๋ฐ, ์ด๋Š” ViewModel์—์„œ View๋กœ State๋ฅผ ๋ณด๋‚ด๊ณ  View์—์„œ๋Š” ViewModel๋กœ Event๋ฅผ ๋ณด๋‚ด๋Š” ๊ตฌ์กฐ๋ฅผ ํ˜•์„ฑํ•  ๊ฒƒ์ด๋‹ค.
 

#3-2 ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„์˜ ๋„์‹๋„ - ๊ณต์‹ ๋ฌธ์„œ

https://developer.android.com/topic/architecture/ui-layer#state-holders

๊ฐ„๋‹จํ•œ ์•ฑ์„ ๋งŒ๋“ค ๊ฑฐ๋ผ์„œ Data Layer๋Š” ๊ตฌํ˜„ํ•˜์ง€ ์•Š์„ ๊ฒƒ์ด๋‹ค. ์ฆ‰, ์ƒ˜ํ”Œ ์•ฑ์—๋Š” ViewModel๊ณผ View๋งŒ ์กด์žฌํ•  ๊ฒƒ์ด๋‹ค.
 

#3-3 ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„์˜ ๋„์‹๋„ - ๋‚ด๊ฐ€ ๋งŒ๋“ค ์•ฑ

#3-2์„ ์‹ค์ œ ์‚ฌ์šฉํ•  ํด๋ž˜์Šค์ด๋ฆ„ ๋ฐ ์ปดํฌ์ €๋ธ” ํ•จ์ˆ˜ ์ด๋ฆ„์œผ๋กœ ๋ฐ”๊ฟจ๋‹ค. ์—ฌ๊ธฐ์„œ ์ด๋ฒคํŠธ๋Š” sealed class๋กœ ๊ตฌํ˜„(#4-2 ์ฐธ์กฐ)ํ•  ๊ฒƒ์ธ๋ฐ, ์ด๋Š” ํ”„๋กœ์ ํŠธ์˜ ๊ฐ์ฒด ์ง€ํ–ฅ์„ฑ์„ ๋“œ๋†’ํ˜€์ค„ ๊ฒƒ์ด๋‹ค.
 

#4 ๊ตฌํ˜„

#4-1 State ํด๋ž˜์Šค

๋”๋ณด๊ธฐ
// package com.example.objectorienteduilayer

data class MainScreenState(
    var inputtedKrSize: String,
    var inputtedUsSize: String,
    var inputtedJpSize: String,
    var inputtedEuSize: String,
    var isInputEnabled: Boolean,

    var buttonText: String,
    var isButtonEnabled: Boolean,
)

State ํด๋ž˜์Šค์—๋Š” ํ™”๋ฉด(View)์— ํ‘œ์‹œ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ๊ฒƒ์„ ์ •์˜ํ•œ๋‹ค. 4๊ฐœ์˜ TextField์— ๋“ค์–ด๊ฐˆ Text ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ, ํ•ด๋‹น TextField๋“ค์˜ ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” ์—ฌ๋ถ€ ๊ทธ๋ฆฌ๊ณ  Button์˜ ๊ฒฝ์šฐ์—๋„ ๋ฒ„ํŠผ ๋‚ด๋ถ€ ํ…์ŠคํŠธ ๋‚ด์šฉ๊ณผ ๋ฒ„ํŠผ ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” ์—ฌ๋ถ€๋ฅผ ์ •์˜ํ•œ๋‹ค.
 

#4-2 (sealed class๋กœ ๊ตฌํ˜„ํ•˜๋Š”) ์ด๋ฒคํŠธ ํด๋ž˜์Šค

๋”๋ณด๊ธฐ
// package com.example.objectorienteduilayer.event

import com.example.objectorienteduilayer.CountryInfo

sealed class MainViewModelEvent {
    // ์ธ์ˆ˜๋ฅผ ๊ฐ€์ง€๊ธฐ์—, ๊ฐ ์ธ์Šคํ„ด์Šค๋Š” ์„œ๋กœ ๊ตฌ๋ณ„๋จ. ๋”ฐ๋ผ์„œ data class๋กœ ๋‘์–ด ๋ณต์ˆ˜ ๊ฐœ์˜ ์ธ์Šคํ„ด์Šค๋ฅผ ๊ฐ€์ง€๊ฒŒ ํ•ด์•ผ ํ•จ
    data class SizeInputted(val country: CountryInfo, val inputtedSize: String) : MainViewModelEvent()

    // ์ธ์ˆ˜๋ฅผ ๊ฐ€์ง€์ง€ ์•Š๊ธฐ์—, ๊ฐ ์ธ์Šคํ„ด์Šค๋Š” ์ „๋ถ€ ๋˜‘๊ฐ™์Œ. ๋”ฐ๋ผ์„œ data object๋กœ ๋‘์–ด ํ•˜๋‚˜์˜ ์ธ์Šคํ„ด์Šค๋งŒ ๊ฐ€์ง€๊ฒŒ ํ•ด์•ผ ํ•จ (์ž์› ์ ˆ์•ฝ)
    data object ConvertShoeSize : MainViewModelEvent()
    data object ResetData : MainViewModelEvent()
}

์•ฑ ์‚ฌ์šฉ์ž๊ฐ€ View์— ๋ฌด์–ธ๊ฐ€๋ฅผ ์กฐ์ž‘(= Event ๋ฐœ์ƒ)ํ•˜๋ฉด, ํ•ด๋‹น ์ด๋ฒคํŠธ๋ฅผ ViewModel์— ๋ณด๋‚ด์ฃผ์–ด์•ผ ํ•œ๋‹ค (๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„ ๊ตฌํ˜„). ์ด ์ด๋ฒคํŠธ ํด๋ž˜์Šค๋“ค์„ "MainViewModelEvent"๋ผ๋Š” ์ด๋ฆ„์„ ๊ฐ€์ง„ ํด๋ž˜์Šค์— ๋„ฃ์Œ์œผ๋กœ์จ ๊ฐ™์€ ๊ณณ์—์„œ ์ฒ˜๋ฆฌํ•  ์ด๋ฒคํŠธ์ž„์„ ๊ตฌ์กฐ ๊ทธ ์ž์ฒด๋กœ (๋‚˜, ๊ทธ๋ฆฌ๊ณ  ๋‚ด ์ฝ”๋“œ๋ฅผ ๋ณผ ๋™๋ฃŒ์—๊ฒŒ) ์ง๊ด€์ ์œผ๋กœ ๋ณด์ธ๋‹ค. ๋˜, MainViewModelEvent๋Š” ๊ทธ๋ƒฅ ํด๋ž˜์Šค๊ฐ€ ์•„๋‹ˆ๋ผ ์ œํ•œ๋œ ์ž์‹๋งŒ์„ ๊ฐ€์ง€๋Š” sealed class๋กœ ์„ ์–ธ๋˜์–ด์žˆ๋‹ค. sealed class๋Š” enum class์™€ ๋น„์Šทํ•˜๋‹ค. ๋ฐ”๋กœ, ์œ ํ•œํ•˜๊ณ  ๊ณ ์ •๋œ ๊ฐฏ์ˆ˜์˜ ์ง‘ํ•ฉ์„ ํ‘œํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ์žฅ์ ์„ ๊ณต์œ ํ•œ๋‹ค๋Š” ์ ์—์„œ ๊ทธ๋ ‡๋‹ค. ๊ทธ๋ ‡๋‹ค๋ฉด ์ด๋ ‡๊ฒŒ ๊ตฌํ˜„๋œ sealed class๋Š” ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌ๋˜๋Š”๊ฐ€?
 

// in ViewModel

fun onEvent(event: MainViewModelEvent) {
    when (event) {
        is MainViewModelEvent.SizeInputted -> {
            ...
        }

        is MainViewModelEvent.ConvertShoeSize -> {
            ...
        }

        is MainViewModelEvent.ResetData -> {
            ...
        }
    }
}

๋ฐ”๋กœ ์œ„์™€ ๊ฐ™์ด ViewModel์— ์ •์˜๋ , ์ด๋ฒคํŠธ ๊ตฌํ˜„์šฉ ํด๋ž˜์Šค๋ฅผ ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ๋ฐ›๋Š” ํ•จ์ˆ˜์ธ onEvent() ์† ๋ถ„๊ธฐ๋ฌธ(when)์„ ํ†ตํ•ด sealed class MainViewModelEvent์˜ ์ž์‹ ํด๋ž˜์Šค๋“ค์ด ์ฒ˜๋ฆฌ๋œ๋‹ค.
 

#4-3 ViewModel

๋”๋ณด๊ธฐ
// package com.example.objectorienteduilayer

import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.objectorienteduilayer.event.MainScreenEvent
import com.example.objectorienteduilayer.event.MainViewModelEvent
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch

class MainViewModel : ViewModel() {

    // (1) ํ™”๋ฉด ํ‘œ์‹œ์šฉ State
    private val _mainScreenState = mutableStateOf(
        MainScreenState(
            inputtedKrSize = "",
            inputtedUsSize = "",
            inputtedJpSize = "",
            inputtedEuSize = "",
            isInputEnabled = true,

            buttonText = "๋ณ€ํ™˜",
            isButtonEnabled = false
        )
    )
    val mainScreenState: State<MainScreenState>
        get() = _mainScreenState

    // (2) MainScreenEvent: View์—์„œ ์‚ฌ์šฉํ•  ์ด๋ฒคํŠธ์ง€๋งŒ, ํ•ด๋‹น ์ด๋ฒคํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐ(๋ฐœ์ƒ)์‹œํ‚ค๊ธฐ ์œ„ํ•ด์„œ ํ•ด๋‹น ์ด๋ฒคํŠธ๋ฅผ ๋‹ด๋Š” Flow ์ธ์Šคํ„ด์Šค๋งŒ ์„ ์–ธ (์ดํ›„์—, View์—์„œ ์ด Flow๋ฅผ collect()ํ•˜์—ฌ ์ด๋ฒคํŠธ๋ฅผ ๊ตฌํ˜„ํ•  ๊ฒƒ์ž„)
    private val _mainScreenEventFlow = MutableSharedFlow<MainScreenEvent>()
    val mainScreenEventFlow: SharedFlow<MainScreenEvent>
        get() = _mainScreenEventFlow.asSharedFlow()

    // (3) MainViewModelEvent: View๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ์ด๋ฒคํŠธ์™€ ๊ทธ ์ฒ˜๋ฆฌ
    fun onEvent(event: MainViewModelEvent) {
        when (event) {
            is MainViewModelEvent.SizeInputted -> {
                _mainScreenState.value = when (event.country) {
                    CountryInfo.KR -> _mainScreenState.value.copy(
                        inputtedKrSize = event.inputtedSize,
                        inputtedUsSize = "",
                        inputtedJpSize = "",
                        inputtedEuSize = "",
                    )

                    CountryInfo.US -> _mainScreenState.value.copy(
                        inputtedKrSize = "",
                        inputtedUsSize = event.inputtedSize,
                        inputtedJpSize = "",
                        inputtedEuSize = "",
                    )

                    CountryInfo.JP -> _mainScreenState.value.copy(
                        inputtedKrSize = "",
                        inputtedUsSize = "",
                        inputtedJpSize = event.inputtedSize,
                        inputtedEuSize = "",
                    )

                    CountryInfo.EU -> _mainScreenState.value.copy(
                        inputtedKrSize = "",
                        inputtedUsSize = "",
                        inputtedJpSize = "",
                        inputtedEuSize = event.inputtedSize,
                    )
                }

                // '๋ณ€ํ™˜' ๋ฒ„ํŠผ ํ™œ์„ฑํ™” ์—ฌ๋ถ€ ๊ฒฐ์ •
                _mainScreenState.value.isButtonEnabled =
                    !(_mainScreenState.value.inputtedKrSize.isEmpty() &&
                    _mainScreenState.value.inputtedUsSize.isEmpty() &&
                    _mainScreenState.value.inputtedJpSize.isEmpty() &&
                    _mainScreenState.value.inputtedEuSize.isEmpty())
            }

            is MainViewModelEvent.ConvertShoeSize -> {
                // ๊ฐ’ ํ•˜๋‚˜๋งŒ ์ถ”๋ฆฌ๊ธฐ
                val inputtedSizes: Map<CountryInfo, String> = mapOf(
                    CountryInfo.KR to _mainScreenState.value.inputtedKrSize,
                    CountryInfo.US to _mainScreenState.value.inputtedUsSize,
                    CountryInfo.JP to _mainScreenState.value.inputtedJpSize,
                    CountryInfo.EU to _mainScreenState.value.inputtedEuSize
                ).filter { // ๋นˆ ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ๊ฐ’๋งŒ ํ•„ํ„ฐ๋ง (์ด๋กœ ์ธํ•ด ๋ถ„๋ช… ํ•˜๋‚˜์˜ element๋งŒ ๋‚จ์„ ๊ฒƒ์ž„, ํ•˜๋‚˜์˜ TextField์—๋งŒ ๊ฐ’์ด ๋“ค์–ด๊ฐ€๋„๋ก ์ด๋ฏธ ์กฐ์น˜๊ฐ€ ๋˜์–ด์žˆ๊ธฐ ๋•Œ๋ฌธ)
                    it.value != ""
                }

                // (๊ฐ’ ๊ฒ€์ฆ - 1) ๊ฐ’์ด ํ•˜๋‚˜์ธ์ง€  (์‚ฌ์‹ค์ƒ ๋‚˜์˜ค์ง€ ์•Š์„ ๋ถ„๊ธฐ โˆต ํ•˜๋‚˜์˜ TextField์—๋งŒ ๊ฐ’์ด ๋“ค์–ด๊ฐ€๋„๋ก ์ด๋ฏธ ์กฐ์น˜๊ฐ€ ๋˜์–ด์žˆ๊ธฐ ๋•Œ๋ฌธ)
                if (inputtedSizes.size != 1) {
                    viewModelScope.launch {
                        _mainScreenEventFlow.emit(
                            MainScreenEvent.ShowSnackbar("์‹ ๋ฐœ ์‚ฌ์ด์ฆˆ๊ฐ€ 2๊ตฐ๋ฐ ์ด์ƒ ์ž…๋ ฅ๋˜์—ˆ์–ด์š”.")
                        )
                    }
                    return
                }

                // (๊ฐ’ ๊ฒ€์ฆ - 2) Doubleํ˜•์œผ๋กœ ๋ณ€ํ™˜ ๊ฐ€๋Šฅํ•œ ์ง€
                val inputtedSize = inputtedSizes.entries.first()
                val inputtedSizeValue = inputtedSize.value.toDoubleOrNull()

                if (inputtedSizeValue == null) {
                    viewModelScope.launch {
                        _mainScreenEventFlow.emit(
                            MainScreenEvent.ShowSnackbar("์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฐ’์ด ์ž…๋ ฅ๋˜์—ˆ์–ด์š”.")
                        )
                    }
                    return
                }

                // ๊ฐ’ ๋ณ€ํ™˜๊ณผ ๋™์‹œ์— State ์—…๋ฐ์ดํŠธ
                try {
                    _mainScreenState.value = _mainScreenState.value.copy(
                        inputtedKrSize = ConversionFormula.formatToTwoDecimalPlaces( // ๊ฐ’ ๋ฐ˜์˜ฌ๋ฆผ ํ•จ์ˆ˜
                            ConversionFormula.sizeToOtherSize(inputtedSize.key, inputtedSizeValue, CountryInfo.KR)), // ๊ฐ’ ๋ณ€ํ™˜ ํ•จ์ˆ˜
                        inputtedUsSize = ConversionFormula.formatToTwoDecimalPlaces(
                            ConversionFormula.sizeToOtherSize(inputtedSize.key, inputtedSizeValue, CountryInfo.US)),
                        inputtedJpSize = ConversionFormula.formatToTwoDecimalPlaces(
                            ConversionFormula.sizeToOtherSize(inputtedSize.key, inputtedSizeValue, CountryInfo.JP)),
                        inputtedEuSize = ConversionFormula.formatToTwoDecimalPlaces(
                            ConversionFormula.sizeToOtherSize(inputtedSize.key, inputtedSizeValue, CountryInfo.EU)),
                        isInputEnabled = false,

                        buttonText = "์ดˆ๊ธฐํ™”",
                        isButtonEnabled = true
                    )

                // (๊ฐ’ ๊ฒ€์ฆ - 3) ๊ฐ’ ๋ณ€ํ™˜ ๊ณผ์ •์—์„œ ์‚ฌ์šฉ๋˜๋Š” ConversionFormula์—์„œ ๋ฐœ์ƒํ•  ์—ฌ์ง€๊ฐ€ ์žˆ๋Š” Exception๋ฅผ catch
                } catch (e: ConversionFormula.SizeOutOfBoundsException) {
                    viewModelScope.launch {
                        _mainScreenEventFlow.emit(
                            MainScreenEvent.ShowSnackbar("${inputtedSize.key.countryName} ์‚ฌ์ด์ฆˆ์˜ ๋ฒ”์œ„๋Š” ${e.minSize} ~ ${e.maxSize} ์ด๋‚ด์—ฌ์•ผ ํ•ด์š”.\n(๋‚ด๋ถ€ ๋ณ€ํ™˜์‹์˜ ํ•œ๊ณ„)")
                        )
                    }
                    return
                }
            }

            is MainViewModelEvent.ResetData -> {
                _mainScreenState.value = _mainScreenState.value.copy(
                    inputtedKrSize = "",
                    inputtedUsSize = "",
                    inputtedJpSize = "",
                    inputtedEuSize = "",
                    isInputEnabled = true,

                    buttonText = "๋ณ€ํ™˜",
                    isButtonEnabled = false
                )
            }
        }
    }
}

(1) ํ™”๋ฉด ํ‘œ์‹œ์šฉ State
Model์ด '์žฌ๋ฃŒ'๊ณ  View๊ฐ€ '์š”๋ฆฌ'๋ผ๋ฉด, View Model์€ 'ํ•„์š”ํ•œ ์žฌ๋ฃŒ๊ฐ€ ์˜ฌ๋ผ๊ฐ€์žˆ๋Š” ๋„๋งˆ'๋‹ค. ๋”ฐ๋ผ์„œ, ViewModel์—์„œ State๋ฅผ ๋ณด์œ ํ•ด์•ผํ•˜๋ฉฐ ์ด๋ฅผ View์—์„œ ์ฐธ์กฐํ•œ๋‹ค. ํ˜น์‹œ ์ด๋ฆ„ ์•ž์— ์“ฐ์ธ ์–ธ๋”๋ฐ”(_)๊ฐ€ ์“ฐ์ธ ๋ณ€์ˆ˜์™€ ์–ธ๋”๋ฐ”๊ฐ€ ์—†๋Š” ๋ณ€์ˆ˜์˜ ์ฐจ์ด๋ฅผ ๋ชจ๋ฅด๊ฒ ๋‹ค๋ฉด, ์ด ๊ฒŒ์‹œ๊ธ€์˜ #2์„ ์ฐธ์กฐํ•œ๋‹ค.
 
(2) MainScreenEvent
View์—์„œ ๋ฐœ์ƒํ•  ์ด๋ฒคํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐ(๋ฐœ์ƒ)์‹œํ‚ค๊ธฐ ์œ„ํ•œ Flow ์ธ์Šคํ„ด์Šค๋ฅผ ์„ ์–ธํ•œ๋‹ค. ์ฃผ์˜ํ•  ์ ์€ ์ด ์ฝ”๋“œ๋Š” ์ด๋ฒคํŠธ๋ฅผ ๊ตฌํ˜„ํ•œ ์ฝ”๋“œ๊ฐ€ ์•„๋‹ˆ๋ผ๋Š” ์ ์ด๋‹ค. #3-2์˜ ๋„์‹๋„์—์„œ๋„ ViewModel์—์„œ View๋กœ Event๋ฅผ ๋ณด๋‚ด๋Š” ๊ฑด ์ฐพ์•„๋ณผ ์ˆ˜ ์—†์ง€ ์•Š์€๊ฐ€. ์ด๋ฒคํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐํ•˜๊ธฐ ์œ„ํ•œ ์ฝ”๋“œ์™€ ์ด๋ฒคํŠธ๊ฐ€ ๋ฌด์Šจ ๋™์ž‘์„ ํ•˜๋Š” ์ง€๋ฅผ ๊ตฌํ˜„ํ•œ ์ฝ”๋“œ๋Š” ๋‹ค๋ฅด๋‹ค. ํ›„์ž์˜ ์ฝ”๋“œ์—์„œ ๋งํ•˜๋Š” '๊ตฌํ˜„'์ด๋ž€, onEvent() ํ•จ์ˆ˜์™€ ๊ฐ™์€ ํ˜•ํƒœ๋ฅผ ๋งํ•˜๋Š” ๊ฒƒ์ด๋‹ค.
 
(3) MainViewModelEvent
#4-2์—์„œ ๋…ผํ–ˆ๋˜ onEvent()๋‹ค.
 

#4-4 Activity

๋”๋ณด๊ธฐ
// package com.example.objectorienteduilayer

// import๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์•„ ์ƒ๋žต

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ObjectOrientedUILayerTheme {
                MainScreen()
            }
        }
    }
}

@Composable
fun MainScreen(
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel<MainViewModel>()
) {

    // (1) ์Šค๋‚ต๋ฐ” ํ‘œ์‹œ๋ฅผ ์œ„ํ•œ ์ฝ”๋ฃจํ‹ด ์˜์—ญ ๊ทธ๋ฆฌ๊ณ  ์Šค๋‚ต๋ฐ”์˜ ์ƒํƒœ ์„ ์–ธ
    val scope = rememberCoroutineScope()
    val snackbarHostState = remember { SnackbarHostState() }

    // (2) MainScreen์—์„œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์ด๋ฒคํŠธ ๊ตฌํ˜„. ViewModel์—์„œ ํŠธ๋ฆฌ๊ฑฐ(๋ฐœ์ƒ)์‹œํ‚ฌ ๊ฒƒ์ด๋ฏ€๋กœ, ViewModel์˜ Flow๋ฅผ collect()ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ด๋ฒคํŠธ๋ฅผ ์ž‘์„ฑ
    LaunchedEffect(key1 = true) { // ์ด ์กฐ๊ฑด์‹(key1 = true)์˜ ์˜๋ฏธ: MainScreen์˜ ์ƒ์•  ์ฃผ๊ธฐ์—์„œ ๋”ฑ ํ•œ๋ฒˆ ์‹คํ–‰๋˜๊ณ , MainScreen์ด ์ฃฝ์„ ๋•Œ๊นŒ์ง€ collect๊ฐ€ ์œ ์ง€๋จ
        viewModel.mainScreenEventFlow.collectLatest { event ->
            when (event) {
                is MainScreenEvent.ShowSnackbar -> {
                    scope.launch {
                        snackbarHostState.showSnackbar(
                            message = event.message,
                            duration = SnackbarDuration.Short
                        )
                    }
                }
            }
        }
    }

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        snackbarHost = {
            SnackbarHost(
                // ์•ž์—์„œ ์„ ์–ธํ–ˆ๋˜ '์Šค๋‚ต๋ฐ”์˜ ์ƒํƒœ'๋ฅผ ์ฃผ์ž…
                hostState = snackbarHostState,
                // ์Šค๋‚ต๋ฐ”์˜ ๋ชจ์–‘ (์™ธํ˜• ์ •์˜)
                snackbar = { snackbarData ->
                    Snackbar(
                        modifier = Modifier.padding(12.dp),
                        dismissAction = { // ๋‹ซ๊ธฐ ๋ฒ„ํŠผ๋„ ๋‹ฌ์•„์ค€๋‹ค
                            TextButton(
                                onClick = { snackbarData.dismiss() }
                            ) {
                                Text(text = "๋‹ซ๊ธฐ")
                            }
                        }
                    ) {
                        Text(text = snackbarData.visuals.message)
                    }
                }
            )
        }
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(horizontal = 16.dp)
                .padding(bottom = 56.dp), // bottom์— ํ‘œ์‹œ๋  ์Šค๋‚ต๋ฐ”์˜ ๋†’์ด๋งŒํผ์˜ ๊ณต๊ฐ„์„ ์ถ”๊ฐ€๋กœ ํ™•๋ณด
            verticalArrangement = Arrangement.SpaceEvenly,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            val screenState = viewModel.mainScreenState.value

            Text(text = "์‹ ๋ฐœ ์‚ฌ์ด์ฆˆ ๋ณ€ํ™˜๊ธฐ", fontSize = 40.sp)

            // (3) State Hoisting ํŒจํ„ด์œผ๋กœ ํ•˜์œ„ ์ปดํฌ์ €๋ธ” ๊ตฌ์„ฑ
            SizeInputField(CountryInfo.KR, screenState.inputtedKrSize, screenState.isInputEnabled) { inputtedSize ->
                viewModel.onEvent(MainViewModelEvent.SizeInputted(CountryInfo.KR, inputtedSize))
            }

            SizeInputField(CountryInfo.US, screenState.inputtedUsSize, screenState.isInputEnabled) { inputtedSize ->
                viewModel.onEvent(MainViewModelEvent.SizeInputted(CountryInfo.US, inputtedSize))
            }

            SizeInputField(CountryInfo.JP, screenState.inputtedJpSize, screenState.isInputEnabled) { inputtedSize ->
                viewModel.onEvent(MainViewModelEvent.SizeInputted(CountryInfo.JP, inputtedSize))
            }

            SizeInputField(CountryInfo.EU, screenState.inputtedEuSize, screenState.isInputEnabled) { inputtedSize ->
                viewModel.onEvent(MainViewModelEvent.SizeInputted(CountryInfo.EU, inputtedSize))
            }

            // (4) ๋ฒ„ํŠผ ๊ตฌํ˜„
            Button(
                onClick = {
                    when (screenState.buttonText) {
                        "๋ณ€ํ™˜" -> { viewModel.onEvent(MainViewModelEvent.ConvertShoeSize) }
                        "์ดˆ๊ธฐํ™”" -> { viewModel.onEvent(MainViewModelEvent.ResetData) }
                    }
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 16.dp),
                enabled = screenState.isButtonEnabled
            ) {
                Text(text = screenState.buttonText, fontSize = 30.sp)
            }
        }
    }
}

@Composable
fun SizeInputField(
    country: CountryInfo,
    valueParam: String,
    enabledParam: Boolean,
    onValueChangeParam: (String) -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(text = "${country.countryName} ", fontSize = 30.sp)

        OutlinedTextField(
            value = valueParam,
            onValueChange = { inputtedSize ->
                // 0 ~ 9์™€ ์ (.)๋งŒ ์ž…๋ ฅ๋˜๋„๋ก ํ•„ํ„ฐ๋ง
                var filteredInput = inputtedSize.replace(Regex("[^0-9.]"), "")
                // ์ ์ด ํ•˜๋‚˜ ์ด์ƒ ์ž…๋ ฅ๋˜์ง€ ์•Š๋„๋ก ํ•„ํ„ฐ๋ง
                val dotCount = filteredInput.count { it == '.' }
                filteredInput = if (dotCount > 1) {
                    val firstDotIndex = filteredInput.indexOf('.')
                    val secondDotIndex = filteredInput.indexOf('.', firstDotIndex + 1)
                    filteredInput.substring(0, secondDotIndex) // ๋‘ ๋ฒˆ์งธ ์  ์ด์ „๊นŒ์ง€์˜ ๋ฌธ์ž์—ด๋งŒ ์‚ฌ์šฉ
                } else {
                    filteredInput
                }
                // ํ•„ํ„ฐ๋ง๋œ ๊ฐ’ ViewModel์— ์ „๋‹ฌ
                onValueChangeParam(filteredInput)
            },
            modifier = Modifier.weight(1f), // Row์—์„œ 2๊ฐœ์˜ Text()๋ฅผ ํ‘œ์‹œํ•˜๊ณ  ๋‚จ์€ ์ž๋ฆฌ๋ฅผ, OutlinedTextField()๊ฐ€ ์ „๋ถ€ ์ฐจ์ง€ํ•˜๊ฒŒ ๋งŒ๋“ฆ
            textStyle = TextStyle(
                fontSize = 30.sp,
                fontWeight = FontWeight.Bold
            ),
            enabled = enabledParam,
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Number
            ),
            singleLine = true
        )

        Text(text = " ${country.shoeSizeUnit}", fontSize = 30.sp)
    }
}

(1) ์Šค๋‚ต๋ฐ” ํ‘œ์‹œ๋ฅผ ์œ„ํ•œ ์ฝ”๋ฃจํ‹ด ์˜์—ญ ๊ทธ๋ฆฌ๊ณ  ์Šค๋‚ต๋ฐ”์˜ ์ƒํƒœ ์„ ์–ธ
Scaffold์˜ Snackbar์— ๋Œ€ํ•ด ๋‹ค๋ฃฌ ์ด ๊ฒŒ์‹œ๊ธ€์˜ #5 ์ฐธ์กฐ.
 
(2) MainScreen์—์„œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์ด๋ฒคํŠธ ๊ตฌํ˜„
ViewModel์˜ onEvent()์™€ ๋™์ผํ•œ ๋™์ž‘์„ ํ•˜๋Š” ์ฝ”๋“œ๋‹ค. ์ฆ‰, ์ด๋ฒคํŠธ๋ฅผ ์‹ค์ œ๋กœ ๊ตฌํ˜„ํ•˜๋Š” ๋ถ€๋ถ„์ด๋‹ค. ์™œ ๋‹ค๋ฅธ Flow๋“ค์„ ์ œ์ณ๋‘๊ณ  SharedFlow๋ฅผ ํ†ตํ•ด ์ด๋ฒคํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐ(๋ฐœ์ƒ)์‹œํ‚ค๋Š” ์ง€๊ฐ€ ๊ถ๊ธˆํ•˜๋‹ค๋ฉด ์ด ๊ฒŒ์‹œ๊ธ€์˜ #4์„ ์ฐธ์กฐํ•˜์ž. ์•„๋‹ˆ๋ฉด ์•„์˜ˆ, ์™œ ๊ตณ์ด Flow๋ฅผ ์“ฐ๋Š” ์ง€๊ฐ€ ๊ถ๊ธˆํ•˜๋‹ค๋ฉด ์ด ๊ฒŒ์‹œ๊ธ€์˜ #4๋ฅผ ์ฐธ์กฐํ•œ๋‹ค. ์ด ์ด๋ฒคํŠธ ๋˜ํ•œ sealed class๋กœ ๊ตฌํ˜„ํ•œ๋‹ค. ํ•ด๋‹น ํด๋ž˜์Šค์˜ ์†Œ์Šค ์ฝ”๋“œ๋Š” #7์— ์žˆ๋Š” ๊นƒํ—ˆ๋ธŒ ๋งํฌ์—์„œ ํ™•์ธํ•˜์ž.
 
(3) State Hoisting ํŒจํ„ด์œผ๋กœ ํ•˜์œ„ ์ปดํฌ์ €๋ธ” ๊ตฌ์„ฑ
ViewModel์„ ํ†ตํ•œ State Hoisting ํŒจํ„ด ๊ตฌํ˜„์— ๋Œ€ํ•ด ๋‹ค๋ฃฌ ์ด ๊ฒŒ์‹œ๊ธ€ ์ฐธ์กฐ.
 
(4) ๋ฒ„ํŠผ ๊ตฌํ˜„
(3)๊ณผ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ, ViewModel๋กœ๋ถ€ํ„ฐ State๋ฅผ ๋ฐ›๊ณ  Event๋Š” ์—ญ์œผ๋กœ ViewModel์—๊ฒŒ ๋ณด๋‚ธ๋‹ค.
 

#4-5 ConversionFormula (์ˆ˜ํ•™ ๊ณ„์‚ฐ์šฉ)

๋”๋ณด๊ธฐ
// package com.example.objectorienteduilayer

import org.apache.commons.math3.analysis.interpolation.SplineInterpolator
import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction
import java.text.DecimalFormat

object ConversionFormula {

    fun sizeToOtherSize(
        sourceCountry: CountryInfo, sourceSize: Double, targetCountry: CountryInfo
    ): Double {
        if (sourceCountry == targetCountry) {
            return sourceSize
        } else {
            // (1) ๊ฐ’ ๋ณ€ํ™˜์„ ์œ„ํ•œ ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜๋ฅผ ์•„๋ž˜ ์ชฝ ์ฝ”๋“œ์—์„œ ์ •์˜ํ•œ Map์—์„œ ๊บผ๋‚ด์˜ด
            val spline = splineMap[Pair(sourceCountry, targetCountry)]!!

            // (2) sourceSize๊ฐ€ ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜๊ฐ€ ์ง€์›ํ•˜๋Š” input ๋ฒ”์œ„ ๋‚ด์— ์žˆ๋Š” ์ง€ ๊ฒ€์ฆ
            val minSize = spline.knots.first()
            val maxSize = spline.knots.last()
            if (sourceSize !in minSize..maxSize) {
                throw SizeOutOfBoundsException(
                    minSize,
                    maxSize,
                    "($sourceCountry to $targetCountry) ์ž…๋ ฅ๊ฐ’์ด (${minSize}..${maxSize}) ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค (์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜์˜ ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚จ)."
                )
            }

            // (3) ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ sourceSize๋ฅผ ๋ณ€ํ™˜๊ณผ ๋™์‹œ์— return
            return spline.value(sourceSize)
        }
    }

    // sizeToOtherSize()์—์„œ ๋ฐœ์ƒ์‹œํ‚ฌ ์‚ฌ์šฉ์ž ์ •์˜ ์—๋Ÿฌ
    class SizeOutOfBoundsException(
        val minSize: Double,
        val maxSize: Double,
        message: String // val์ด ์•„๋‹Œ ๊ฒƒ ๊ฐ™์ง€๋งŒ val์ž„. message๋Š” IllegalArgumentException์—์„œ ์ด๋ฏธ ์ •์˜๋œ ํ”„๋กœํผํ‹ฐ์ž„. ์ฆ‰, ๋ถ€๋ชจ์˜ ์ƒ์„ฑ์ž์— ์žˆ๋˜ val message๋ฅผ ๊ทธ๋Œ€๋กœ ๊ฐ€์ ธ์˜จ ๊ฒƒ
    ) : IllegalArgumentException(message)

    // ์†Œ์ˆ˜์  ์…‹์งธ์ž๋ฆฌ'์—์„œ' ๋ฐ˜์˜ฌ๋ฆผ ๋ฐ ํ˜•์‹์„ x.xx๋กœ ๊ณ ์ •
    fun formatToTwoDecimalPlaces(value: Double): String {
        val decimalFormat = DecimalFormat("0.00")
        return decimalFormat.format(value)
    }

    // ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜ ์ œ์ž‘์„ ์œ„ํ•œ ๋ฐ์ดํ„ฐ
    private val krSizesForSpline = doubleArrayOf(225.0, 230.0, 235.0, 240.0, 245.0, 250.0, 252.5, 255.0, 260.0, 265.0, 270.0, 275.0, 280.0, 282.5, 285.0, 290.0, 295.0, 300.0, 305.0, 307.5, 310.0, 315.0, 320.0)
    private val usSizesForSpline = doubleArrayOf(4.0, 4.5, 5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 10.5, 11.0, 11.5, 12.0, 12.5, 13.0, 13.5, 14.0, 14.5, 15.0)
    private val jpSizesForSpline = doubleArrayOf(22.5, 23.0, 23.5, 24.0, 24.5, 25.0, 25.25, 25.5, 26.0, 26.5, 27.0, 27.5, 28.0, 28.25, 28.5, 29.0, 29.5, 30.0, 30.5, 30.75, 31.0, 31.5, 32.0)
    private val euSizesForSpline = doubleArrayOf(36.0, 37.0, 37.5, 38.0, 39.0, 39.5, 40.0, 40.5, 41.5, 42.0, 42.5, 43.5, 44.0, 44.5, 45.0, 46.0, 46.5, 47.0, 48.0, 48.5, 49.0, 49.5, 50.5)

    // ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„ ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜๋ฅผ ์ œ์ž‘ํ•˜๋Š” ํ•จ์ˆ˜
    private val splineInterpolator = SplineInterpolator()

    // ์ œ์ž‘๋œ ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜๋“ค์„ ์ €์žฅํ•  Map
    private val splineMap : Map<Pair<CountryInfo, CountryInfo>, PolynomialSplineFunction> = mapOf(
        // kr size to other sizes
        Pair(CountryInfo.KR, CountryInfo.US) to splineInterpolator.interpolate(krSizesForSpline, usSizesForSpline),
        Pair(CountryInfo.KR, CountryInfo.JP) to splineInterpolator.interpolate(krSizesForSpline, jpSizesForSpline),
        Pair(CountryInfo.KR, CountryInfo.EU) to splineInterpolator.interpolate(krSizesForSpline, euSizesForSpline),

        // us size to other sizes
        Pair(CountryInfo.US, CountryInfo.KR) to splineInterpolator.interpolate(usSizesForSpline, krSizesForSpline),
        Pair(CountryInfo.US, CountryInfo.JP) to splineInterpolator.interpolate(usSizesForSpline, jpSizesForSpline),
        Pair(CountryInfo.US, CountryInfo.EU) to splineInterpolator.interpolate(usSizesForSpline, euSizesForSpline),

        // jp size to other sizes
        Pair(CountryInfo.JP, CountryInfo.KR) to splineInterpolator.interpolate(jpSizesForSpline, krSizesForSpline),
        Pair(CountryInfo.JP, CountryInfo.US) to splineInterpolator.interpolate(jpSizesForSpline, usSizesForSpline),
        Pair(CountryInfo.JP, CountryInfo.EU) to splineInterpolator.interpolate(jpSizesForSpline, euSizesForSpline),

        // eu size to other sizes
        Pair(CountryInfo.EU, CountryInfo.KR) to splineInterpolator.interpolate(euSizesForSpline, krSizesForSpline),
        Pair(CountryInfo.EU, CountryInfo.US) to splineInterpolator.interpolate(euSizesForSpline, usSizesForSpline),
        Pair(CountryInfo.EU, CountryInfo.JP) to splineInterpolator.interpolate(euSizesForSpline, jpSizesForSpline),
    )
}

๊ฒŒ์‹œ๊ธ€์— ์“ธ ์ง€ ์•„๋‹ˆ๋ฉด ์ƒ๋žตํ•  ์ง€ ๊ณ ๋ฏผํ–ˆ๋˜ ํด๋ž˜์Šค๋‹ค. ์ด ๋ถ€๋ถ„(#4-5)๋Š” ์ฝ์ง€ ์•Š์•„๋„ ๋ณธ ๊ฒŒ์‹œ๊ธ€์˜ ์ฃผ์ œ์ธ "๊ฐ์ฒด ์ง€ํ–ฅ์  UI ๋ ˆ์ด์–ด ์„ค๊ณ„"๋ฅผ ์ดํ•ดํ•˜๋Š” ๋ฐ ์•„๋ฌด๋Ÿฐ ์ง€์žฅ์ด ์—†๋‹ค.
 
(1) ๊ฐ’ ๋ณ€ํ™˜์„ ์œ„ํ•œ ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜...
4๊ฐœ์˜ ์‹ ๋ฐœ ์‚ฌ์ด์ฆˆ๋Š” ์„œ๋กœ ์„ ํ˜•์ ์ธ ๊ด€๊ณ„๊ฐ€ ์•„๋‹ˆ๋‹ค. ๋ฌด์Šจ ๋ง์ด๋ƒ๋ฉด, ์–ด๋–ค ์‚ฌ์ด์ฆˆ ๋‹จ์œ„ X์™€ ๋˜ ๋‹ค๋ฅธ ์‚ฌ์ด์ฆˆ ๋‹จ์œ„ Y๊ฐ€ ์žˆ์„ ๋•Œ aX + b = Y์ธ ๊ด€๊ณ„๊ฐ€ ์„ฑ๋ฆฝํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ๋ง์ด๋‹ค. ๋”ฐ๋ผ์„œ ๊ต‰์žฅํžˆ ๋จธ๋ฆฌ์•„ํ”ˆ ๊ณ„์‚ฐ์ด ์š”๊ตฌ๋  ๊ฒƒ์œผ๋กœ ๋ณด์˜€๋‹ค. ์ƒ˜ํ”Œ ์•ฑ์ด๋ผ์„œ ๊ทผ์‚ฌ๊ฐ’์œผ๋กœ ํ‰์น ๊นŒ ์ƒ๊ฐํ•ด๋ดค์ง€๋งŒ, ๋Œ€์ถฉ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒŒ ๋งˆ์Œ์— ๋“ค์ง„ ์•Š์•˜๋‹ค. ๋‹คํ–‰ํžˆ ChatGPT์—๊ฒŒ ๋ฌผ์–ด๋ณด๋‹ˆ Spline ํ•จ์ˆ˜๋ฅผ ์ด์šฉํ•˜๋ฉด, ๋น„์„ ํ˜•์ ์ธ ๊ด€๊ณ„์— ๋Œ€ํ•œ ๊ด€๊ณ„์‹์„ ์‰ฝ๊ฒŒ ์–ป์„ ์ˆ˜ ์žˆ์Œ์„ ์•Œ๊ฒŒ ๋˜์—ˆ๋‹ค.
 
Spline ํ•จ์ˆ˜๋Š” ์‚ด๋ฉด์„œ ์ฒ˜์Œ ๋“ค์–ด๋ณด๋Š” ํ•จ์ˆ˜๋ผ์„œ ์š”์•ฝํ•˜๊ธฐ ์กฐ์‹ฌ์Šค๋Ÿฌ์›Œ์ง„๋‹ค. ๊ทธ๋ž˜๋„ ์š”์•ฝํ•ด๋ณด์ž๋ฉด "์–ด๋–ค ์‚ฌ์ด์ฆˆ ๋‹จ์œ„ X์— ์†ํ•˜๋Š” ์–ด๋–ค ๊ฐ’ x์™€ ๋˜ ๋‹ค๋ฅธ ์‚ฌ์ด์ฆˆ ๋‹จ์œ„ Y์— ์†ํ•˜๋Š” ์–ด๋–ค ๊ฐ’ y์— ๋Œ€ํ•ด, Y ๋‹จ์œ„๋กœ ๋ณ€ํ™˜ํ•œ x = y์ธ (x, y) ๋ฐ์ดํ„ฐ์…‹์„ ๋ช‡ ๊ฐœ ๋„ฃ์–ด์ฃผ๋ฉด ์–ผ์ถ” ๋น„์Šทํ•˜๊ฒŒ ๋“ค์–ด๋งž๋Š” ๊ด€๊ณ„์‹์„ ๋ฝ‘์•„์ฃผ๋Š” ํ•จ์ˆ˜"์ธ ๊ฒƒ์œผ๋กœ ๋ณด์ธ๋‹ค.
 
(2) sourceSize๊ฐ€ ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜๊ฐ€ ์ง€์›ํ•˜๋Š” input ๋ฒ”์œ„...
์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜๋Š”, ํ•ด๋‹น ํ•จ์ˆ˜๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ๋„ฃ์€ ๋ฐ์ดํ„ฐ์…‹์˜ ์ตœ์†Ÿ๊ฐ’ ~ ์ตœ๋Œ“๊ฐ’์˜ ๋ฒ”์œ„์—์„œ๋งŒ ๊ฐ’์„ ๋„์ถœํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚˜๋ฉด ์—๋Ÿฌ๋ฅผ ๋‚ด๋ฑ‰๋Š”๋‹ค. ๋”ฐ๋ผ์„œ, ์ด ์—๋Ÿฌ๋ฅผ throwํ•˜๋„๋ก ๋งŒ๋“ ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์—๋Ÿฌ๋Š” ViewModel์—์„œ try-catch๋ฌธ์œผ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค.
 
(3) ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ sourceSize...
(2)๋ฅผ ๋ฌด์‚ฌํžˆ ํ†ต๊ณผํ–ˆ๋‹ค๋ฉด, ๋ณ€ํ™˜๋œ ์‹ ๋ฐœ ์‚ฌ์ด์ฆˆ ๊ฐ’์„ returnํ•œ๋‹ค.
 

#5 Unit Test

๋”๋ณด๊ธฐ
// package com.example.objectorienteduilayer

import com.google.common.truth.Truth
import org.junit.Before
import org.junit.Test

class ConversionFormulaTest {

    private lateinit var testCases: List<Map<CountryInfo, Double>>

    @Before
    fun setUp() {
        testCases = listOf(
            mapOf(CountryInfo.KR to 225.0, CountryInfo.US to 4.0, CountryInfo.JP to 22.5, CountryInfo.EU to 36.0), // 1
            mapOf(CountryInfo.KR to 230.0, CountryInfo.US to 4.5, CountryInfo.JP to 23.0, CountryInfo.EU to 37.0), // 2
            mapOf(CountryInfo.KR to 235.0, CountryInfo.US to 5.0, CountryInfo.JP to 23.5, CountryInfo.EU to 37.5), // 3
            mapOf(CountryInfo.KR to 240.0, CountryInfo.US to 5.5, CountryInfo.JP to 24.0, CountryInfo.EU to 38.0), // 4
            mapOf(CountryInfo.KR to 245.0, CountryInfo.US to 6.0, CountryInfo.JP to 24.5, CountryInfo.EU to 39.0), // 5
            mapOf(CountryInfo.KR to 250.0, CountryInfo.US to 6.5, CountryInfo.JP to 25.0, CountryInfo.EU to 39.5), // 6
            mapOf(CountryInfo.KR to 252.5, CountryInfo.US to 7.0, CountryInfo.JP to 25.25, CountryInfo.EU to 40.0), // 7
            mapOf(CountryInfo.KR to 255.0, CountryInfo.US to 7.5, CountryInfo.JP to 25.5, CountryInfo.EU to 40.5), // 8
            mapOf(CountryInfo.KR to 260.0, CountryInfo.US to 8.0, CountryInfo.JP to 26.0, CountryInfo.EU to 41.5), // 9
            mapOf(CountryInfo.KR to 265.0, CountryInfo.US to 8.5, CountryInfo.JP to 26.5, CountryInfo.EU to 42.0), // 10
            mapOf(CountryInfo.KR to 270.0, CountryInfo.US to 9.0, CountryInfo.JP to 27.0, CountryInfo.EU to 42.5), // 11
            mapOf(CountryInfo.KR to 275.0, CountryInfo.US to 9.5, CountryInfo.JP to 27.5, CountryInfo.EU to 43.5), // 12
            mapOf(CountryInfo.KR to 280.0, CountryInfo.US to 10.0, CountryInfo.JP to 28.0, CountryInfo.EU to 44.0), // 13
            mapOf(CountryInfo.KR to 282.5, CountryInfo.US to 10.5, CountryInfo.JP to 28.25, CountryInfo.EU to 44.5), // 14
            mapOf(CountryInfo.KR to 285.0, CountryInfo.US to 11.0, CountryInfo.JP to 28.5, CountryInfo.EU to 45.0), // 15
            mapOf(CountryInfo.KR to 290.0, CountryInfo.US to 11.5, CountryInfo.JP to 29.0, CountryInfo.EU to 46.0), // 16
            mapOf(CountryInfo.KR to 295.0, CountryInfo.US to 12.0, CountryInfo.JP to 29.5, CountryInfo.EU to 46.5), // 17
            mapOf(CountryInfo.KR to 300.0, CountryInfo.US to 12.5, CountryInfo.JP to 30.0, CountryInfo.EU to 47.0), // 18
            mapOf(CountryInfo.KR to 305.0, CountryInfo.US to 13.0, CountryInfo.JP to 30.5, CountryInfo.EU to 48.0), // 19
            mapOf(CountryInfo.KR to 307.5, CountryInfo.US to 13.5, CountryInfo.JP to 30.75, CountryInfo.EU to 48.5), // 20
            mapOf(CountryInfo.KR to 310.0, CountryInfo.US to 14.0, CountryInfo.JP to 31.0, CountryInfo.EU to 49.0), // 21
            mapOf(CountryInfo.KR to 315.0, CountryInfo.US to 14.5, CountryInfo.JP to 31.5, CountryInfo.EU to 49.5), // 22
            mapOf(CountryInfo.KR to 320.0, CountryInfo.US to 15.0, CountryInfo.JP to 32.0, CountryInfo.EU to 50.5), // 23
        )
    }

    @Test
    fun sizeToOtherSize_givenTestCases_returnExpectedResult() {
        val countries = arrayOf(CountryInfo.KR, CountryInfo.US, CountryInfo.JP, CountryInfo.EU)

        for (map in testCases) {
            for (sourceCountry in countries) {
                for (targetCountry in countries) {
                    val result = ConversionFormula.sizeToOtherSize(
                        sourceCountry, map[sourceCountry]!!, targetCountry
                    )
                    Truth.assertThat(result).isEqualTo(map[targetCountry]!!)
                }
            }
        }
    }
}

#4-5์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ์ฝ์ง€ ์•Š๊ณ  ๋„˜๊ฒจ๋„ ๋ณธ ๊ฒŒ์‹œ๊ธ€์˜ ์ฃผ์ œ์ธ "๊ฐ์ฒด ์ง€ํ–ฅ์  UI ๋ ˆ์ด์–ด ์„ค๊ณ„"๋ฅผ ์ดํ•ดํ•˜๋Š” ๋ฐ ์•„๋ฌด๋Ÿฐ ์ง€์žฅ์ด ์—†๋‹ค. Spline ํ•จ์ˆ˜๊ฐ€ ์ œ๋Œ€๋กœ ์ž‘๋™ํ•˜๋Š”์ง€ ์˜๊ตฌ์‹ฌ์ด ๋“ค์–ด ๋งŒ๋“  ConversionFormula์— ๋Œ€ํ•œ ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๋‹ค. ๋‹คํ–‰ํžˆ ํ…Œ์ŠคํŠธ๋ฅผ ์ž˜ ํ†ต๊ณผํ•œ๋‹ค.
 

#6 ์š”์•ฝ

๊ฐ™์€ sealed class์— ๋„ฃ์Œ์œผ๋กœ์จ ๋ณต์ˆ˜์˜ ์ด๋ฒคํŠธ๋“ค์„ ๋ฒ”์ฃผํ™”ํ•˜๋ฉฐ, ๋™์‹œ์— ๊ฐ์ฒด ์ง€ํ–ฅํ™”ํ•œ๋‹ค.
 

#7 ์™„์„ฑ๋œ ์•ฑ

android-practice/jetpack-compose/ObjectOrientedUILayer at master ยท Kanmanemone/android-practice

Contribute to Kanmanemone/android-practice development by creating an account on GitHub.

github.com

์ƒ˜ํ”Œ ์•ฑ์˜ ์ „์ฒด ์†Œ์Šค ์ฝ”๋“œ๋‹ค. ์‹ฌ์‹ฌํ•œ ์‚ฌ๋žŒ์€ ObjectOrientedUILayer/docs/KR_to_US_Size_Conversion_(Spline).png์— ์žˆ๋Š” ์Šคํ”Œ๋ผ์ธ ํ•จ์ˆ˜์˜ ์‹ ๊ธฐํ•œ x-y ๊ทธ๋ž˜ํ”„๋„ ํ™•์ธํ•ด๋ณด์ž.