I am using compose LazyColumn with viewModel updating the list items by having inside my viewModel:
data class ContactsListUiState(
val contacts: MutableList<Contact>
)
#HiltViewModel
class ContactsViewModel #Inject constructor(savedStateHandle: SavedStateHandle) : ViewModel() {
private val _contactsListUiState = MutableStateFlow(ContactsListUiState(mutableListOf()))
val contactsListUiState: StateFlow<ContactsListUiState> = _contactsListUiState.asStateFlow()
private fun updateContactsList(newContacts: MutableList<Contact>) {
_contactsListUiState.update{ currentState ->
currentState.copy(
contacts = newContacts
)
}
}
such that my updateContactsList() function updates the stateFlow and I am supposed to collect the contacts list with collectAsStateWithLifecycle() inside my composable function inside MainActivity
#OptIn(ExperimentalLifecycleComposeApi::class, ExperimentalFoundationApi::class)
#Composable
fun ContactsListScreen(
navController: NavController,
modifier: Modifier = Modifier
) {
Log.d("ViewModel", "ContactsListScreen recomposed")
val uiState by contactsViewModel.contactsListUiState.collectAsStateWithLifecycle()
Box(modifier = modifier) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
Column {
TextField(
value = searchText,
singleLine = true,
onValueChange = {
searchText = it
Log.d("MainActivity", "calling filter with $it")
contactsViewModel.filterContactsByString(it)
},
leadingIcon = if (searchText.isNotBlank()) searchLeadingIcon else null,
trailingIcon = if (searchText.isBlank()) searchTrailingIcon else null,
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
)
LazyColumn(
Modifier.fillMaxWidth(),
state = listState,
contentPadding = PaddingValues(bottom = 80.dp)
) {
items(uiState.contacts) { contact ->
ContactListItem(
navController,
contact = contact,
modifier = Modifier.fillMaxWidth()
)
Divider(thickness = 0.5.dp, color = colorResource(id = R.color.blue))
}
}
...
where contactsListViewModel is declared on top of the activity which I inject using hilt :
#AndroidEntryPoint
class MainActivity : ComponentActivity(), LoaderManager.LoaderCallbacks<Cursor> {
private val contactsViewModel: ContactsViewModel by viewModels()
For some reason uiState.contacts is empty inside the composable function, but it does contain items when I logged it inside the viewModel function and therefore my list stays empty..
Any suggestions on what could have gone wrong?
Thanks
Please use SnapshotStatelist/(mutableStateListOf()) instead of an ordinary list (mutableList).
private val _contactsListUiState = MutableStateFlow(ContactsListUiState(mutableStateListOf()))
Also please check this post this one and this one
Related
I am writing a small gallery app for my cat. It has a button by clicking on which a new PhotoItem is added to the displayed list, but it appears only after phone rotation and I want it to appear on the screen right after button was clicked.
Right now everything is stored in a mutableList inside savedStateHandle.getStateFlow but I also tried regular MutableStateFlow and mutableStateOf and it didn't help. I havent really used jatpack compose and just can't figure what to do (
App
#SuppressLint("UnusedMaterialScaffoldPaddingParameter")
#Composable
fun BebrasPhotosApp() {
val galaryViewModel = viewModel<GalaryViewModel>()
val allPhotos by galaryViewModel.loadedPics.collectAsState()
Scaffold(topBar = { BebraTopAppBar() }, floatingActionButton = {
FloatingActionButton(
onClick = { galaryViewModel.addPicture() },
backgroundColor = MaterialTheme.colors.onBackground
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = "Add Photo",
tint = Color.White,
)
}
}) {
LazyColumn(modifier = Modifier.background(color = MaterialTheme.colors.background)) {
items(allPhotos) {
PhotoItem(bebra = it)
}
}
}
}
ViewModel
class GalaryViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val loadedPics = savedStateHandle.getStateFlow(
"pics", initialValue = mutableListOf<Bebra>(
Bebra(R.string.photo_1, R.string.desc_1, R.drawable.bebra_pic_1, R.string.add_desc_1),
Bebra(R.string.photo_2, R.string.desc_2, R.drawable.bebra_pic_2, R.string.add_desc_2),
Bebra(R.string.photo_3, R.string.desc_3, R.drawable.bebra_pic_3, R.string.add_desc_3)
)
)
fun addPicture() {
val additionalBebraPhoto = Bebra(
R.string.photo_placeholder,
R.string.desc_placeholder,
R.drawable.placeholder_cat,
R.string.add_desc_placeholder
)
savedStateHandle.get<MutableList<Bebra>>("pics")!!.add(additionalBebraPhoto)
}
}
PhotoItem
#Composable
fun PhotoItem(bebra: Bebra, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
Card(elevation = 4.dp, modifier = modifier
.padding(8.dp)
.clickable { expanded = !expanded }) {
Column(
modifier = modifier
.padding(8.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
) {
Text(
text = stringResource(id = bebra.PicNumber),
style = MaterialTheme.typography.h1,
modifier = modifier.padding(bottom = 8.dp)
)
Text(
text = stringResource(id = bebra.PicDesc),
style = MaterialTheme.typography.body1,
modifier = modifier.padding(bottom = 8.dp)
)
Image(
painter = painterResource(id = bebra.Picture),
contentDescription = stringResource(id = bebra.PicDesc),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(256.dp)
.clip(RoundedCornerShape(12))
)
if (expanded) {
BebraAdditionalDesc(bebra.additionalDesc)
}
}
}
}
Bebra Data class
data class Bebra(
#StringRes val PicNumber: Int,
#StringRes val PicDesc: Int,
#DrawableRes val Picture: Int,
#StringRes val additionalDesc: Int
)
So, I am also not super familiar with JC, but from first glance it looks like your method, addPicture() - which is called when the user taps on the button, does not update the state, therefore there's no recomposition happening, so the UI does not get updated.
Check:
fun addPicture() {
// ...
savedStateHandle.get<MutableList<Bebra>>("pics")!!.add(additionalBebraPhoto)
}
So here you are basically adding a new item to savedStateHandle, which I assume does not trigger a recomposition.
What I think you need to do, is to update loadedPics, somehow.
However, loadedPics is a StateFlow, to be able to update it you would need a MutableStateFlow.
For simplicity, this is how you would do it if you were operating with a list of strings:
// declare MutableStateFlow that can be updated and trigger recomposition
val _loadedPics = MutableStateFlow(
savedStateHandle.get<MutableList<String>>("pics") ?: mutableListOf()
)
// use this in the JC layout to listen to state changes
val loadedPics: StateFlow<List<String>> = _loadedPics
// addPicture:
val prevList = _loadedPics.value
prevList.add("item")
_loadedPics.value = prevList // triggers recomposition
// here you probably will want to save the item in the
// `savedStateHandle` as you already doing.
In order to share settings among of compose functions, I create a class AboutState() and a compose fun rememberAboutState() to persist settings.
I don't know if I can wrap Modifier with remember in the solution.
The Code A can work well, but I don't know if it maybe cause problem when I wrap Modifier with remember, I think Modifier is special class and it's polymorphic based invoked.
Code A
#Composable
fun ScreenAbout(
aboutState: AboutState = rememberAboutState()
) {
Column() {
Hello(aboutState)
World(aboutState)
}
}
#Composable
fun Hello(
aboutState: AboutState
) {
Text("Hello",aboutState.modifier)
}
#Composable
fun World(
aboutState: AboutState
) {
Text("World",aboutState.modifier)
}
class AboutState(
val textStyle: TextStyle,
val modifier: Modifier=Modifier
) {
val rowSpace: Dp = 20.dp
}
#Composable
fun rememberAboutState(): AboutState {
val aboutState = AboutState(
textStyle = MaterialTheme.typography.body1.copy(
color=Color.Red
),
modifier=Modifier.padding(start = 80.dp)
)
return remember {
aboutState
}
}
There wouldn't be a problem passing a Modifier to a class. What you actually defined above, even if named State, is not class that acts as a State, it would me more appropriate name it as HelloStyle, HelloDefaults.style(), etc.
It would be more appropriate to name a class XState when it should have internal or public MutableState that can trigger recomposition or you can get current State of Composable or Modifier due to changes. It shouldn't contain only styling but state mechanism either to change or observe state of the Composble such as ScrollState or PagerState.
When you have a State wrapper object common way of having a stateful Modifier or Modifier with memory or Modifiers with Compose scope is using Modifier.composed{} and passing State to Modifier, not the other way around.
When do you need Modifier.composed { ... }?
fun Modifier.composedModifier(aboutState: AboutState) = composed(
factory = {
val color = remember { getRandomColor() }
aboutState.color = color
Modifier.background(aboutState.color)
}
)
In this example even if it's not practical getRandomColor is created once in recomposition and same color is used.
A zoom modifier i use for zooming in this library is as
fun Modifier.zoom(
key: Any? = Unit,
consume: Boolean = true,
clip: Boolean = true,
zoomState: ZoomState,
onGestureStart: ((ZoomData) -> Unit)? = null,
onGesture: ((ZoomData) -> Unit)? = null,
onGestureEnd: ((ZoomData) -> Unit)? = null
) = composed(
factory = {
val coroutineScope = rememberCoroutineScope()
// Current Zoom level
var zoomLevel by remember { mutableStateOf(ZoomLevel.Min) }
// Rest of the code
},
inspectorInfo = {
name = "zoom"
properties["key"] = key
properties["clip"] = clip
properties["consume"] = consume
properties["zoomState"] = zoomState
properties["onGestureStart"] = onGestureStart
properties["onGesture"] = onGesture
properties["onGestureEnd"] = onGestureEnd
}
)
Another practical example for this is Modifier.scroll that uses rememberCoroutineScope(), you can also remember object too to not intantiate another object in recomposition
#OptIn(ExperimentalFoundationApi::class)
private fun Modifier.scroll(
state: ScrollState,
reverseScrolling: Boolean,
flingBehavior: FlingBehavior?,
isScrollable: Boolean,
isVertical: Boolean
) = composed(
factory = {
val overscrollEffect = ScrollableDefaults.overscrollEffect()
val coroutineScope = rememberCoroutineScope()
// Rest of the code
},
inspectorInfo = debugInspectorInfo {
name = "scroll"
properties["state"] = state
properties["reverseScrolling"] = reverseScrolling
properties["flingBehavior"] = flingBehavior
properties["isScrollable"] = isScrollable
properties["isVertical"] = isVertical
}
)
I'm new on jetpack compose and I'm sure that I'm missing something but I don't know what?
my State model:
data class ChoiceSkillsState(
val isLoading: Boolean = false,
val errorWD: ErrorWD? = null,
val skills: List<Skill> = emptyList(),
)
The Skill model:
#Parcelize
data class Skill(
val id: Int,
val name: String,
val imageUrl: String? = null,
var children: List<SkillChild>? = null,
) : Parcelable {
#Parcelize
data class SkillChild(
val id: Int,
val name: String,
val imageUrl: String? = null,
var note: Int? = null,
) : Parcelable
}
fun Skill.asChildNoted(): Boolean {
if (!children.isNullOrEmpty()) {
children!!.forEach {
if (it.note != null) return true
}
}
return false
}
on my viewModel
private val _state = mutableStateOf(ChoiceSkillsState())
val state: State<ChoiceSkillsState> = _state
On some event I update my skillList on my state : ChoiceSkillState.
When I log, my data is updated correctly but my view is not recomposed..
There is my LazyColumn:
#Composable
private fun LazyColumnSkills(
skills: List<Skill>,
onClickSkill: (skill: Skill) -> Unit,
) {
LazyColumn(
contentPadding = PaddingValues(bottom = MaterialTheme.spacing.medium),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small),
) {
items(
items = skills,
) { skill ->
ItemSkillParent(
skill = skill,
onClickSkill = onClickSkill
)
}
}
}
Then here is my ItemSkillParent:
#Composable
fun ItemSkillParent(
skill: Skill,
onClickSkill: (skill: Skill) -> Unit
) {
val backgroundColor =
if (skill.asChildNoted()) Orange
else OrangeLight3
val endIconRes =
if (skill.asChildNoted()) R.drawable.ic_apple
else R.drawable.ic_arrow_right
Box(
modifier = Modifier
.fillMaxWidth()
.clip(shape = MaterialTheme.shapes.itemSkill)
.background(backgroundColor)
.clickable { onClickSkill(skill) },
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 7.dp, horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier
.weight(1f)
.size(50.dp)
.clip(shape = MaterialTheme.shapes.itemSkillImage),
painter = rememberAsyncImagePainter(model = skill.imageUrl),
contentDescription = "Image skill",
contentScale = ContentScale.Crop
)
Text(
modifier = Modifier
.weight(6f)
.padding(horizontal = 10.dp),
text = skill.name,
style = MaterialTheme.typography.itemSkill
)
ButtonIconRoundedMini(
iconRes = endIconRes,
contentDesc = "Icon arrow right",
onClick = { onClickSkill(skill) }
)
}
}
}
My onClickSkill() will open a new Screen then pass result, then I will update my data with this :
fun updateSkill(skill: Skill) {
val skillsUpdated = _state.value.skills
skillsUpdated
.filter { it.id == skill.id }
.forEach { it.children = skill.children }
_state.value = _state.value.copy(skills = skillsUpdated)
}
As you can see, the background color and the iconResource should be changed, it's changing when only when I scroll.
Can someone explain me what's happening there ?
You should never use var in class properties if you want a property update to cause recomposition.
Check out Why is immutability important in functional programming?.
In this case you are updating the children property, but skillsUpdated and _state.value.skills are actually the same object - you can check the address, so Compose thinks it has not been changed.
After updating your children to val, you can use copy to update it.
val skillsUpdated = _state.value.skills.toMutableList()
for (i in skillsUpdated.indices) {
if (skillsUpdated[i].id != skill.id) continue
skillsUpdated[i] = skillsUpdated[i].copy(children = skill.children)
}
_state.value = _state.value.copy(skills = skillsUpdated.toImmutableList())
Note that converting a mutable list into an immutable list is also critical here, because otherwise the next time you try to update it, the list will be the same object: both toList and toMutableList return this when applied to a mutable list.
make your properties as state
val backgroundColor by remember {
mutableStateOf (if (skill.asChildNoted()) Orange
else OrangeLight3)
}
val endIconRes by remember {
mutableStateOf (if (skill.asChildNoted()) R.drawable.ic_apple else R.drawable.ic_arrow_right)
}
I was developing an App where I try to implement some new technologies, as Jetpack Compose. And in general, it's a great tool, except the fact that it has hard pre-visualize system (#Preview) thn the regular xml design files.
My problem comes when I try to create a #Preview of the component which represent the different rows, where I load my data recover from network.
In my case I made this:
#Preview(
name ="ListScreenPreview ",
showSystemUi = true,
showBackground = true,
device = Devices.NEXUS_9)
#Composable
fun myPokemonRowPreview(
#PreviewParameter(PokemonListScreenProvider::class) pokemonMokData: PokedexListModel
) {
PokedexEntry(
model = pokemonMokData,
navController = rememberNavController(),
viewModel = hiltViewModel())
}
class PokemonListScreenProvider: PreviewParameterProvider<PokedexListModel> {
override val values: Sequence<PokedexListModel> = sequenceOf(
PokedexListModel(
pokemonName = "Cacamon",
number = 0,
imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/2.png"
),
PokedexListModel(
pokemonName = "Tontaro",
number = 73,
imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"
)
)
}
To represent this #Composable:
#Composable
fun PokemonListScreen(
navController: NavController,
viewModel: PokemonListViewModel
) {
Surface(
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxSize()
)
{
Column {
Spacer(modifier = Modifier.height(20.dp))
Image(
painter = painterResource(id = R.drawable.ic_international_pok_mon_logo),
contentDescription = "Pokemon",
modifier = Modifier
.fillMaxWidth()
.align(CenterHorizontally)
)
SearchBar(
hint = "Search...",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
viewModel.searchPokemonList(it)
}
Spacer(modifier = Modifier.height(16.dp))
PokemonList(navController = navController,
viewModel = viewModel)
}
}
}
#Composable
fun SearchBar(
modifier: Modifier = Modifier,
hint: String = " ",
onSearch: (String) -> Unit = { }
) {
var text by remember {
mutableStateOf("")
}
var isHintDisplayed by remember {
mutableStateOf(hint != "")
}
Box(modifier = modifier) {
BasicTextField(value = text,
onValueChange = {
text = it
onSearch(it)
},
maxLines = 1,
singleLine = true,
textStyle = TextStyle(color = Color.Black),
modifier = Modifier
.fillMaxWidth()
.shadow(5.dp, CircleShape)
.background(Color.White, CircleShape)
.padding(horizontal = 20.dp, vertical = 12.dp)
.onFocusChanged {
isHintDisplayed = !it.isFocused
}
)
if (isHintDisplayed) {
Text(
text = hint,
color = Color.LightGray,
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 12.dp)
)
}
}
}
#Composable
fun PokemonList(
navController: NavController,
viewModel: PokemonListViewModel
) {
val pokemonList by remember { viewModel.pokemonList }
val endReached by remember { viewModel.endReached }
val loadError by remember { viewModel.loadError }
val isLoading by remember { viewModel.isLoading }
val isSearching by remember { viewModel.isSearching }
LazyColumn(contentPadding = PaddingValues(16.dp)) {
val itemCount = if (pokemonList.size % 2 == 0) {
pokemonList.size / 2
} else {
pokemonList.size / 2 + 1
}
items(itemCount) {
if (it >= itemCount - 1 && !endReached && !isLoading && !isSearching) {
viewModel.loadPokemonPaginated()
}
PokedexRow(rowIndex = it, models = pokemonList, navController = navController, viewModel = viewModel)
}
}
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxSize()
) {
if (isLoading) {
CircularProgressIndicator(color = MaterialTheme.colors.primary)
}
if (loadError.isNotEmpty()) {
RetrySection(error = loadError) {
viewModel.loadPokemonPaginated()
}
}
}
}
#SuppressLint("LogNotTimber")
#Composable
fun PokedexEntry(
model: PokedexListModel,
navController: NavController,
modifier: Modifier = Modifier,
viewModel: PokemonListViewModel
) {
val defaultDominantColor = MaterialTheme.colors.surface
var dominantColor by remember {
mutableStateOf(defaultDominantColor)
}
Box(
contentAlignment = Center,
modifier = modifier
.shadow(5.dp, RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(10.dp))
.aspectRatio(1f)
.background(
Brush.verticalGradient(
listOf(dominantColor, defaultDominantColor)
)
)
.clickable {
navController.navigate(
"pokemon_detail_screen/${dominantColor.toArgb()}/${model.pokemonName}/${model.number}"
)
}
) {
Column {
CoilImage(
imageRequest = ImageRequest.Builder(LocalContext.current)
.data(model.imageUrl)
.target {
viewModel.calcDominantColor(it) { color ->
dominantColor = color
}
}.build(),
imageLoader = ImageLoader.Builder(LocalContext.current)
.availableMemoryPercentage(0.25)
.crossfade(true)
.build(),
contentDescription = model.pokemonName,
modifier = Modifier
.size(120.dp)
.align(CenterHorizontally),
loading = {
ConstraintLayout(
modifier = Modifier.fillMaxSize()
) {
val indicator = createRef()
CircularProgressIndicator(
//Set constrains dynamically
modifier = Modifier.constrainAs(indicator) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
}
},
// shows an error text message when request failed.
failure = {
Text(text = "image request failed.")
}
)
Log.d("pokemonlist", model.imageUrl)
Text(
text = model.pokemonName,
fontFamily = RobotoCondensed,
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
#Composable
fun PokedexRow(
rowIndex: Int,
models: List<PokedexListModel>,
navController: NavController,
viewModel: PokemonListViewModel
) {
Column {
Row {
PokedexEntry(
model = models[rowIndex * 2],
navController = navController,
modifier = Modifier.weight(1f),
viewModel = viewModel
)
Spacer(modifier = Modifier.width(16.dp))
if (models.size >= rowIndex * 2 + 2) {
PokedexEntry(
model = models[rowIndex * 2 + 1],
navController = navController,
modifier = Modifier.weight(1f),
viewModel = viewModel
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
#Composable
fun RetrySection(
error: String,
onRetry: () -> Unit,
) {
Column() {
Text(error, color = Color.Red, fontSize = 18.sp)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { onRetry() },
modifier = Modifier.align(CenterHorizontally)
) {
Text(text = "Retry")
}
}
}
I try to annotate with the #Nullable navController and viewmodel of the PokemonListScreen #Composable, but doesn't work either. I'm still seeing an empty screen:
So I try to search into the Jetpack documentation but, it's just defining quite simple Composables.
So if you have some more knowledge about it and can help, thanks in advance !
The main problem is if I wanna Preview that #Composable, although I made #Nullable to the viewmodel parameter, which I guess it's the problem here, AS still demand to initialize. Because I guess the right way to pass argument to a preview is by #PreviewArgument annotation.
[EDIT]
After some digging, I found AS is returning the following error under the Preview Screen:
So, there anyway to avoid viewmodel error??
[SOLUTION]
Finally a apply the following solution which make works, because the cause of the problem is due to Hilt have some inconpatibilities with Jetpack Compose previews:
Create an interface of the your ViewModel which recover all the variables and methods.
Make yourcurrent viemodel class extends of the interface.
Create a 2ยบ class which extends on the interface and pass that to your #Preview
#SuppressLint("UnrememberedMutableState")
#Preview(
name ="ListScreenPreview",
showSystemUi = true,
showBackground = true,
device = Devices.PIXEL)
#Composable
fun MyPokemonRowPreview(
#PreviewParameter(PokemonListScreenProvider::class) pokemonMokData: PokedexListModel
) {
JetpackComposePokedexTheme {
PokedexRow(
rowIndex = 0,
models = PokemonListScreenProvider().values.toList(),
navController = rememberNavController(),
viewModel = PokemonListViewModelMock(
0, mutableStateOf(""), mutableStateOf(value = false),
mutableStateOf(false), mutableStateOf(listOf(pokemonMokData))
)
)
}
}
class PokemonListScreenProvider: PreviewParameterProvider<PokedexListModel> {
override val values: Sequence<PokedexListModel> = sequenceOf(
PokedexListModel(
pokemonName = "Machasaurio",
number = 0,
imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/2.png"
),
PokedexListModel(
pokemonName = "Tontaro",
number = 73,
imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"
)
)
}
PokemonListViewModelInterface
interface PokemonListViewModelInterface {
var curPage : Int
var loadError: MutableState<String>
var isLoading: MutableState<Boolean>
var endReached: MutableState<Boolean>
var pokemonList: MutableState<List<PokedexListModel>>
fun searchPokemonList(query: String)
fun loadPokemonPaginated()
fun calcDominantColor(drawable: Drawable, onFinish: (Color) -> Unit)
}
PokemonListViewModelMock
class PokemonListViewModelMock (
override var curPage: Int,
override var loadError: MutableState<String>,
override var isLoading: MutableState<Boolean>,
override var endReached: MutableState<Boolean>,
override var pokemonList: MutableState<List<PokedexListModel>>
): PokemonListViewModelInterface{
override fun searchPokemonList(query: String) {
TODO("Not yet implemented")
}
override fun loadPokemonPaginated() {
TODO("Not yet implemented")
}
override fun calcDominantColor(drawable: Drawable, onFinish: (Color) -> Unit) {
TODO("Not yet implemented")
}
}
The actual Preview is the following, and although the image doesn't display, is shown correctly:
You could create another composable which invokes the viewmodel logic via lambda functions instead of using the viewmodel itself. Extract your uiState to a separate class, so it can be used as a StateFlow in your viewmodel, which in turn can be observed from the composable.
#Composable
fun PokemonListScreen(
navController: NavController,
viewModel: PokemonListViewModel
) {
/*
rememberStateWithLifecyle is an extension function based on
https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
*/
val uiState by rememberStateWithLifecycle(viewModel.uiState)
PokemonListScreen(
uiState = uiState,
onLoadPokemons = viewModel::loadPokemons,
onSearchPokemon = {viewModel.searchPokemon(it)},
onCalculateDominantColor = {viewModel.calcDominantColor(it)},
onNavigate = {route -> navController.navigate(route, null, null)},
)
}
#Composable
private fun PokemonListScreen(
uiState: PokemonUiState,
onLoadPokemons:()->Unit,
onSearchPokemon: (String) -> Unit,
onCalculateDominantColor: (Drawable) -> Color,
onNavigate:(String)->Unit,
) {
}
#HiltViewModel
class PokemonListViewModel #Inject constructor(/*your datasources*/) {
private val loading = MutableStateFlow(false)
private val loadError = MutableStateFlow(false)
private val endReached = MutableStateFlow(false)
private val searching = MutableStateFlow(false)
private val pokemons = MutableStateFlow<Pokemon?>(null)
val uiState: StateFlow<PokemonUiState> = combine(
loading,
loadError,
endReached,
searching,
pokemons
) { loading, error, endReached, searching, pokemons ->
PokemonUiState(
isLoading = loading,
loadError = error,
endReached = endReached,
isSearching = searching,
pokemonList = pokemons,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = PokemonUiState.Empty,
)
}
data class PokemonUiState(
val pokemonList: List<Pokemon> = emptyList(),
val endReached: Boolean = false,
val loadError: Boolean = false,
val isLoading: Boolean = false,
val isSearching: Boolean = false,
) {
companion object {
val Empty = PokemonUiState()
}
}
I'm not sure of the depth of this application, but a potential idea would be to code to an interface and not an implementation.
That is, create an interface with all of the functions you need (that may already exist in your ViewModel), have your PokemonListViewModel implement it, and create another mock class that implements it as well. Pass the mock into your preview and leave the real implementation with PokemonListViewModel
interface PokeListViewModel {
...
// your other val's
val isLoading: Boolean
fun searchPokemonList(pokemon: String)
fun loadPokemonPaginated()
// your other functions
...
}
Once you create your interface you can simply update your composables to be expecting an object that "is a" PokeListViewModel, for example.
Hopefully this helps
I have 2 Mutable States in my ViewModel and want them to observe the textfields in my composable.
ViewModel:
#HiltViewModel
class AddEditTransactionViewModel #Inject constructor(
private val moneyManagerUseCases: MoneyManagerUseCases
) : ViewModel() {
private val _transactionDescription = mutableStateOf("")
val transactionDescription: State<String> = _transactionDescription
private val _transactionAmount = mutableStateOf("")
val transactionAmount: State<String> = _transactionAmount
Composable:
#Composable
fun AddEditTransactionScreen(
navController: NavController,
viewModel: AddEditTransactionViewModel = hiltViewModel(),
) {
// This works
var descriptionState by remember { mutableStateOf("") }
var amountState by remember { mutableStateOf("") }
// This doesn't work
var viewModelDescriptionState = viewModel.transactionDescription.value
var viewModelAmountState = viewModel.transactionAmount.value
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
value = viewModelDescriptionState,
onValueChange = { viewModelDescriptionState = it },
label = { Text(text = "Description") })
Spacer(modifier = Modifier.padding(20.dp))
TextField(
value = viewModelAmountState,
onValueChange = { viewModelAmountState = it },
label = { Text(text = "Amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
It works if I use the state from my composable but that kind of beats the purpose of the ViewModel.
The first line:
var descriptionState by remember { mutableStateOf(") }
It works because you're updating the value of mutableState via delegation. It's easier to understand if you look at how you would do same without delegation:
val descriptionState = remember { mutableStateOf("") }
//...
descriptionState.value = "new value"
remember will save same object between recompositions, but this object have value field which is changing.
On the other hand, here:
var viewModelDescriptionState = viewModel.transactionDescription.value
you create a local variable, and any changes will not be saved between recompositions.
This is usually solved by creating a setter function within the view model. You can also use delegation in the view model, if you want to restrict it to update without a setter, you can add private set, instead of having private _variable and public variable:
var transactionDescription by mutableStateOf("")
private set
fun updateTransactionDescription(newValue: String) {
transactionDescription = newValue
}
This is considered best practice because the setter can contain more logic than a simple update value. In the case where you only need to store it, you can remove the private part and update it directly, without the setter function.
You can find more information about the state in documentation Compose, including this youtube video which explains the basic principles.
Solution
AddEditTransactionScreen:
#Composable
fun AddEditTransactionScreen(
navController: NavController,
viewModel: AddEditTransactionViewModel = hiltViewModel()
) {
val descriptionState = viewModel.transactionDescription
val amountState = viewModel.transactionAmount
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
value = descriptionState.value,
onValueChange = { viewModel.updateTransactionDescription(it) },
label = { Text(text = "Description") })
Spacer(modifier = Modifier.padding(20.dp))
TextField(
value = amountState.value,
onValueChange = { viewModel.updateTransactionAmount(it) },
label = { Text(text = "Amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
AddEditTransactionViewModel:
#HiltViewModel
class AddEditTransactionViewModel #Inject constructor() : ViewModel() {
private val _transactionDescription = mutableStateOf("")
val transactionDescription: State<String> = _transactionDescription
private val _transactionAmount = mutableStateOf("")
val transactionAmount: State<String> = _transactionAmount
fun updateTransactionDescription(value: String) {
_transactionDescription.value = value
}
fun updateTransactionAmount(value: String) {
_transactionAmount.value = value
}
}
Explanation
Its transactionDescription and transactionAmount attributes are of the State<String> type, the State type cannot have its value changed, unlike the MutableState type, a simple solution that maintains the change protection only by the view model, is creating a method for each attribute that you want to change and make this change in the attribute that is MutableState.