Reputation: 3338
I'm building an android application to calculate the user's body mass index (BMI):
I'm not sure what would be the best way to handle the state using a view model as a state holder. For you to help me I will describe my problem as much as possible, starting with the files:
HomeUiState.kt
Responsible for storing the UI state.
data class HomeUiState(
val weight: String,
val height: String,
val currentBmiCalculated: String,
)
HomeViewModel.kt
Responsible to control the UI state and handle actions to composable, like show a toast:
class HomeViewModel(
private val firstState: HomeUiState,
private val stringToFloatConvertor: StringToFloatConvertorUseCase,
private val calculateBmi: CalculateBmiUseCase,
) : ViewModel() {
companion object {
private const val FIRST_VALUE = ""
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
HomeViewModel(
stringToFloatConvertor = StringToFloatConvertorUseCaseImpl(),
calculateBmi = CalculateImcUseCaseImpl(),
firstState = HomeUiState(
weight = FIRST_VALUE,
height = FIRST_VALUE,
currentBmiCalculated = "Not calculated yet",
)
)
}
}
}
private val _uiState: MutableStateFlow<HomeUiState> = MutableStateFlow(firstState)
private val _uiAction = MutableSharedFlow<HomeUiAction>()
val uiState = _uiState.asStateFlow()
val uiAction = _uiAction.asSharedFlow()
fun dispatchUiEvent(uiEvent: HomeUiEvent) {
when (uiEvent) {
is HomeUiEvent.OnEnterHeightValue -> _uiState.update { it.copy(height = uiEvent.value) }
is HomeUiEvent.OnEnterWeightValue -> _uiState.update { it.copy(weight = uiEvent.value) }
is HomeUiEvent.OnCalculateButtonClick -> onCalculateButtonClick()
is HomeUiEvent.OnClearButtonClick -> {
_uiState.update { firstState }
emitAction(HomeUiAction.MoveCursorToHeight)
}
is HomeUiEvent.OnProfileIconClick -> emitAction(HomeUiAction.NavigateToProfileScreen)
}
}
private fun onCalculateButtonClick() {
stringToFloatConvertor(uiState.value.weight)?.let { weight ->
stringToFloatConvertor(uiState.value.height)?.let { height ->
_uiState.update {
it.copy(currentImcCalculated = "${calculateBmi(weight, height)}")
}
} ?: showErrorToast(R.string.invalid_height_value)
} ?: showErrorToast(R.string.invalid_weight_value)
emitAction(HomeUiAction.HideKeyboard)
}
private fun showErrorToast(@StringRes message: Int) =
emitAction(HomeUiAction.ShowErrorToast(message))
private fun emitAction(action: HomeUiAction) {
viewModelScope.launch {
_uiAction.emit(action)
}
}
}
And so here I have my differences because I'm not sure if I should transmit my viewmodel entirely to the HomeContent
or if I should do according to the recommendations of the documentation itself of not transmitting the view model to the descendant functions. But it seems to me that when breaking the ui state into several parameters for the HomeContent
, when changing just 1 parameter would all HomeContent
components be affected or not?
Just to clarify the options I'm considering:
That way, by breaking the UI state into multiple parameters, the entire function would be recomposed if 1 single parameter changes, right?
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(
navController: NavController,
viewModel: HomeViewModel,
) {
val context = LocalContext.current
val focusRequester = remember { FocusRequester.Default }
val keyboardController = LocalSoftwareKeyboardController.current
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(key1 = Unit) {
viewModel.uiAction.collectLatest { action ->
when (action) {
is HomeUiAction.ShowErrorToast -> Toast
.makeText(context, context.getText(action.messageId), Toast.LENGTH_SHORT)
.show()
is HomeUiAction.MoveCursorToHeight -> focusRequester.requestFocus()
is HomeUiAction.NavigateToProfileScreen -> navController.navigate("profile")
is HomeUiAction.HideKeyboard -> keyboardController?.hide()
}
}
}
Scaffold(
topBar = {
HomeAppBar(
onProfileClick = { viewModel.dispatchUiEvent(HomeUiEvent.OnProfileIconClick) }
)
}
) {
HomeContent(
height = uiState.height,
weight = uiState.weight,
onHeightChange = {
viewModel.dispatchUiEvent(HomeUiEvent.OnEnterHeightValue(it))
},
onWeightChange = {
viewModel.dispatchUiEvent(HomeUiEvent.OnEnterWeightValue(it))
},
onClear = {
viewModel.dispatchUiEvent(HomeUiEvent.OnClearButtonClick)
},
onCalculate = {
viewModel.dispatchUiEvent(HomeUiEvent.OnCalculateButtonClick)
},
focusRequester = focusRequester,
bmiResult = uiState.currentImcCalculated
)
}
}
@Composable
private fun HomeContent(
height: String,
weight: String,
bmiResult: String,
onHeightChange: (String) -> Unit,
onWeightChange: (String) -> Unit,
onClear: () -> Unit,
onCalculate: () -> Unit,
focusRequester: FocusRequester,
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "BMI Calculator",
style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
)
Spacer(modifier = Modifier.height(32.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
BmiEditText(
value = height,
label = "Height (m)",
onValueChange = onHeightChange,
modifier = Modifier
.fillMaxWidth(2 / 5f)
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.fillMaxWidth(1 / 3f))
BmiEditText(
value = weight,
label = "Weight (kg)",
onValueChange = onWeightChange,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onCalculate) {
Text(text = "Calculate")
}
Button(onClick = onClear) {
Text(text = "Clear")
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "BMI result: $bmiResult")
}
}
That way, as the view model instance remains the same, the function would no longer be recomposed, right?
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(
navController: NavController,
viewModel: HomeViewModel,
) {
val context = LocalContext.current
val focusRequester = remember { FocusRequester.Default }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(key1 = Unit) {
viewModel.uiAction.collectLatest { action ->
when (action) {
is HomeUiAction.ShowErrorToast -> Toast
.makeText(context, context.getText(action.messageId), Toast.LENGTH_SHORT)
.show()
is HomeUiAction.MoveCursorToHeight -> focusRequester.requestFocus()
is HomeUiAction.NavigateToProfileScreen -> navController.navigate("profile")
is HomeUiAction.HideKeyboard -> keyboardController?.hide()
}
}
}
Scaffold(
topBar = {
HomeAppBar(
onProfileClick = { viewModel.dispatchUiEvent(HomeUiEvent.OnProfileIconClick) }
)
}
) {
HomeContent(
viewModel = viewModel,
focusRequester = focusRequester,
)
}
}
@Composable
private fun HomeContent(
viewModel: HomeViewModel,
focusRequester: FocusRequester,
) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "BMI Calculator",
style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
)
Spacer(modifier = Modifier.height(32.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
BmiEditText(
value = uiState.height,
label = "Height (m)",
onValueChange = {
viewModel.dispatchUiEvent(HomeUiEvent.OnEnterHeightValue(it))
},
modifier = Modifier
.fillMaxWidth(2 / 5f)
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.fillMaxWidth(1 / 3f))
BmiEditText(
value = uiState.weight,
label = "Weight (kg)",
onValueChange = {
viewModel.dispatchUiEvent(HomeUiEvent.OnEnterWeightValue(it))
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
viewModel.dispatchUiEvent(HomeUiEvent.OnCalculateButtonClick)
}
) {
Text(text = "Calculate")
}
Button(
onClick = {
viewModel.dispatchUiEvent(HomeUiEvent.OnClearButtonClick)
}
) {
Text(text = "Clear")
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "BMI result: ${uiState.currentBmiCalculated}")
}
}
With these options in mind, which of the two would be the best to avoid unnecessary recompositions following good community practices?
Upvotes: 1
Views: 2763
Reputation: 140
Option 1 would be the one you should be using. Because you should never be passing the ViewModels down. The reason is that viewModels are marked as unstable
by compose compiler and it causes the composable function non-skippable
. The problem with this, recomposition will not be skipped and your UI elements will be redrawn even though the parameters you passed were not changed.
With Option 1, you are right that It will be recomposed if 1 parameters changed but since Compose can intelligently recompose only the components that changed, you would not have problem.
For detailed information you can check the following resources.
https://twitter.github.io/compose-rules/rules/
https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
Upvotes: 3