#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 ๋จ๋ฐฉํฅ ๋ฐ์ดํฐ ํ๋ฆ์ ๋์๋ - ๊ณต์ ๋ฌธ์

๊ฐ๋จํ ์ฑ์ ๋ง๋ค ๊ฑฐ๋ผ์ 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 ๊ทธ๋ํ๋ ํ์ธํด๋ณด์.
'๊นจ์ ๊ฐ๋ ๐ > Android' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[Android] Jetpack Compose - Navigation์ Destination ๊ฐ ๋ฐ์ดํฐ ์ ๋ฌ (NavBackStackEntry. (0) | 2024.09.13 |
---|---|
[Android] Jetpack Compose - Navigation ๊ธฐ์ด (0) | 2024.09.12 |
[Android] Jetpack Compose - Side-effects (0) | 2024.09.06 |
[Android] Jetpack Compose - Scaffold (0) | 2024.09.05 |
[Android] LiveData - Flow๋ก ๋ง์ด๊ทธ๋ ์ด์ (0) | 2024.08.29 |