I've read through a couple of developer.android.com articles trying to understand how to structure UI with elements, states, events, and so on, but I don't really get how to implement it exactly. I've created a ViewModel for my activity and an uiState by mutatableStateOf()
with private setter and used that in my ComponentActivity.onCreate
. But changing the UI state from inside the ViewModel doesn't make the UI update. I've tried to copy the Android article as good as possible, but, for example, I can't find the viewModel()
function used there. I only have viewModels<? extends ViewModel>()
. How do I make the UI update automatically. Also, is this how you use Flow (val result
) (I'm not really familiar with Kotlin)?
data class CreateGameUIState(
val types: List<GameType>,
val fetching: Boolean = false
)
class CreateGameViewModel() : ViewModel() {
var uiState by mutableStateOf(CreateGameUIState(emptyList()))
init {
viewModelScope.launch {
uiState = uiState.copy(fetching = true);
val result = MyApp.getInstance().database.gameTypeDao().getGameTypes();
Log.i("", "returned")
uiState = uiState.copy(
fetching = false,
types = result.first()
)
result.onEach {
uiState = uiState.copy(types = it)
}.collect()
}
}
}
class CreateGameActivity: ComponentActivity() {
private lateinit var uiState: CreateGameUIState;
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
uiState = viewModels<CreateGameViewModel>().value.uiState
setContent {
MyAppTheme { Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.act_creategame_name)) })
}
) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { Row {
if (uiState.fetching)
Text("Loading data...")
else
for ((index, gameType) in uiState.types.withIndex()) {
val id = resources.getIdentifier(gameType.icon_name, "drawable", packageName)
if (id != 0)
Image(
modifier = Modifier.width(10.dp).height(10.dp).clickable {
Toast.makeText(baseContext, "", Toast.LENGTH_LONG).show();
},
painter = painterResource(id = id),
contentDescription = null
)
}
} } } }
}
}
}
I've ensured that ViewModel.uiState
is actually updated. It is set on fetching = true
, the UI is stuck on Loading data...
and it also changes to fetching = false
, types = result.first()
. This change, however, does not trigger a rerender of the UI elements.
This is not how Flows are supposed to be used with Compose. Sadly, there are still some outdated official tutorials, so you might have accidentally picked up the wrong one.
In the following I will show how and why your code should be refactored.
Keep your Activity as short as possible. You don't need more than that:
class CreateGameActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
MyGameApp()
}
}
}
}
The view model doesn't belong in the Activity (as long as it isn't specific to the Activity, which yours is not), and you should extract all composables into a dedicated function and call that instead:
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun MyGameApp() {
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.act_creategame_name)) })
},
) { innerPadding ->
CreateGameScreen(Modifier.padding(innerPadding))
}
}
Your Compose app will usually be structured in several Screens. That is just a convention, but you should probably stick with it for now. Later on you will want to navigate between the various screens and not simply call them all in the Scaffold. In that case you would wrap the CreateGameScreen (and the other screens) with a NavHost
which would then apply the padding. For more, read the documentation on Navigation with Compose.
For more on CreateGameScreen see point 4.
Instead of your ui state containing a flag for the loading status, you can more elegantly model this by making CreateGameUIState a sealed interface:
sealed interface CreateGameUIState {
data object Loading : CreateGameUIState
data class Success(
val types: List<GameType>,
) : CreateGameUIState
}
What previously was CreateGameUIState
is now CreateGameUIState.Success
and it doesn't need the fetching
property anymore. Instead you have an entirely new object for that, CreateGameUIState.Loading
. Your ui state will still be CreateGameUIState
, but now it can either be Loading
or Success
(with the list of game types). You can even add more types, like an Error type when something went wrong.
See point 4. for how you can differentiate between the different types.
The view model should not contain MutableState. Also, Flows should not be collected in the view model, flows should only be passed through while possibly being transformed:
class CreateGameViewModel : ViewModel() {
private val gameTypeDao: GameTypeDao = MyApp
.getInstance()
.database
.gameTypeDao()
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<CreateGameUIState> = gameTypeDao.getGameTypes()
.mapLatest(CreateGameUIState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = CreateGameUIState.Loading,
)
}
First off, the Dao instance is now a local property of the view model. You can even make it a parameter, that way you could more easily create unit tests for the view model that use a mocked Dao. Parameters for the view model are best handled with a dependecy injection framework like Hilt. For now we'll leave it as a property.
Now, instead of collecting the database flow in the init
block (as you did with first
and then again with collect
) the flow is transformed from the Flow<List<GameType>>
to a Flow<CreateGameUIState>
by calling mapLatest(CreateGameUIState::Success)
. The ::
syntax is a lambda reference, it is just a syntactical shortcut for the more verbose mapLatest { CreateGameUIState.Success(it) }
.
The resulting flow is then converted to a StateFlow. That is a specially configured flow that always has a value. That's why you need to provide an initialValue
that will be used until the upstream flow from the database emitted its first value (which may take some time because it involves file system access). After that, whenever anything changes in the database the StateFlow will automatically update its value.
You usually want to use one view model per screen. You could have more view models if you wanted, and you can even share view models for multiple screens, but for now you should keep it simple and only use one view model per screen:
@Composable
fun CreateGameScreen(modifier: Modifier = Modifier) {
val viewModel: CreateGameViewModel = viewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column(modifier = modifier) {
when (val currentUiState = uiState) {
is CreateGameUIState.Loading -> Text("Loading data...")
is CreateGameUIState.Success -> GameTypes(currentUiState.types)
}
}
}
To be able to use the viewModel()
function, you need the Gradle dependency androidx.lifecycle:lifecycle-viewmodel-compose
in your module level gradle file.
Now you can access the view model's StateFlow and finally collect it, using collectAsStateWithLifecycle
. You need the Gradle dependency androidx.lifecycle:lifecycle-runtime-compose
for that. This function converts the Flow into a Compose State. The State will automatically update whenever anything changes in the database and trigger a recomposition with the new value. This way the UI will always be up-to-date, you do not need to do anything more.
Depending on the specific type of CreateGameUIState you can now decide what to do. In the Success
case the list of game types is passed to another composable, GameTypes
. You should only pass what is really necessary. You should never pass entire view model instances around, and even the whole ui state object shouldn't be passed, just what is really necessary.
GameTypes
is now a simple composable that just receives a List<GameType>
. It doesn't concern itself with the database or flows or the current loading state:
@Composable
fun GameTypes(list: List<GameType>) {
val context = LocalContext.current
Row {
list.forEachIndexed { index, gameType ->
val id = remember(gameType.icon_name) {
context.resources.getIdentifier(
gameType.icon_name,
"drawable",
context.packageName,
)
}
if (id != 0) {
Image(
modifier = Modifier
.width(10.dp)
.height(10.dp)
.clickable {
Toast
.makeText(context, "", Toast.LENGTH_LONG)
.show()
},
painter = painterResource(id = id),
contentDescription = null,
)
}
}
}
}
This code was previously placed in the Activity and relied on the Activity's resources
, packageName
and baseContext
property. In Compose you can retrieve an appropriate context object with LocalContext.current
that you can use instead.
I also simplified the for
loop (although it seems like a simple forEach
would suffice, the index
is never used) and made sure the result of getIdentifier()
is remembered. getIdentifier()
is an expensive operation, so it should only be recalculated when gameType.icon_name
changes, not on every recomposition (which might be a lot, depending on the remaining content of the composable).
When you want to display more than a simple Image you should probably extract the entire content of the if (id != 0)
block into a separate composable.
That's all, I hope it wasn't too much to take in. The basic principle throughout all of this was to decouple the separate parts so they can work independently from each other.
If you want to learn more about the state-of-the-art way to write Compose apps you should have a look at the official sample app from Google, Now in Android. It is frequently updated and usually adheres to the architectural recommendations and employs the most current APIs.