I am using Retrofit in my project to make requests to the Google Books API and if I keep it outside of a NavHost, it works just fine; searching for a book returns a list of books that is reflected in the UI. However, when I put the same composable into a NavHost, it only shows the starting screen for the composable (pre-search), and searching for a book causes the UI to remain the same.
I have used NavHosts many times in my projects and have yet to experience a problem like this. Simply keeping the composable outside of the NavHost makes it work just fine, but putting it in completely stops UI updates.
At first, I thought that the request simply wasn't being made to the API, but using the debugging tool showed that pressing the button actually results in a successful API response. Below is the code for the composable containing the NavHost, the composable with the UI that doesn't update, as well as the relevant View Model. Please note that moving "HomeScreen" out of the NavHost to be right after innerPadding causes it to work just fine, but of course prevents me from using the NavHost for navigation.
Composable containing the NavHost
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun BookSearchApp(viewModel: BookSearchViewModel = hiltViewModel()) {
val navController = rememberNavController()
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.book_search)) },
navigationIcon = {
if (navController.previousBackStackEntry != null)
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.back_arrow)
)
}
)
},
bottomBar = {
BottomAppBar() {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
val focusManger = LocalFocusManager.current
OutlinedTextField(
value = viewModel.searchTextUiState.searchText,
onValueChange = { viewModel.updateSearchTextUiState(it) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManger.clearFocus() }),
singleLine = true,
modifier = Modifier.width(200.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Button(onClick = { viewModel.getBooks(); focusManger.clearFocus() }) {
Text(text = stringResource(R.string.search))
}
}
}
}) { innerPadding ->
NavHost(
navController = navController,
startDestination = "HomeScreen",
modifier = Modifier.padding(innerPadding)
) {
composable(route = "HomeScreen") {
HomeScreen(onBookClick = { navController.navigate("BookDataScreen") })
}
composable(route = "BookDataScreen") {
BookDataScreen()
}
}
}
}
Composable with the UI that updates if it is outside of the NavHost, and doesn't update if inside of it
/**
* Home screen that allows users to query books using the Google Books API.
* [HomeScreenUiState] determines which screen to display, based on the status of the query.
*/
#Composable
fun HomeScreen(
viewModel: BookSearchViewModel = hiltViewModel(),
onBookClick: () -> Unit,
modifier: Modifier = Modifier
) {
when (viewModel.homeScreenUiState) {
is HomeScreenUiState.Loading -> LoadingScreen()
is HomeScreenUiState.Success -> HomeBody(
bookData = (viewModel.homeScreenUiState as HomeScreenUiState.Success).bookData,
onBookClick = onBookClick,
modifier = modifier
)
is HomeScreenUiState.Start -> StartScreen()
else -> ErrorScreen(retryAction = { viewModel.getBooks() })
}
}
/**
* Body for [HomeScreen].
*/
#Composable
private fun HomeBody(bookData: GetBooks, onBookClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.padding(6.dp)
) {
BookList(bookData = bookData, onBookClick = onBookClick)
}
}
/**
* Displays a lazy vertical grid of queried book thumbnails if [bookData] is not null, or [InvalidSearch] if null.
*/
#Composable
private fun BookList(bookData: GetBooks, onBookClick: () -> Unit) {
if (bookData.bookItems != null) {
LazyVerticalGrid(
horizontalArrangement = Arrangement.Center,
columns = GridCells.Adaptive(minSize = 150.dp),
) {
items(items = bookData.bookItems) { bookItem ->
BookListEntry(volumeInfo = bookItem.volumeInfo, onBookClick = onBookClick)
}
}
} else {
InvalidSearch()
}
}
/**
* Composable that displays in [BookList] if a search returns books.
*/
#Composable
private fun BookListEntry(volumeInfo: VolumeInfo, onBookClick: () -> Unit, modifier: Modifier = Modifier) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
modifier = modifier
.padding(6.dp)
) {
AsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(volumeInfo.imageLinks?.thumbnail) //Must use a safe call in case the thumbnail is null
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.FillBounds,
error = painterResource(id = R.drawable.ic_broken_image),
placeholder = painterResource(id = R.drawable.loading_img),
fallback = painterResource(id = R.drawable.ic_broken_image),
modifier = modifier
.height(200.dp)
.width(150.dp)
.clickable { }
)
}
}
}
View Model for the previous code blocks
/**
* UI state for [HomeScreen].
*/
sealed interface HomeScreenUiState {
data class Success(val bookData: GetBooks) : HomeScreenUiState
object Error : HomeScreenUiState
object Loading : HomeScreenUiState
object Start : HomeScreenUiState
}
**View Model for the previous composables**
/**
* View Model for [HomeScreen].
*/
#HiltViewModel
class BookSearchViewModel #Inject constructor(private val bookRepository: BookRepository) :
ViewModel() {
/**
* Holds [HomeScreenUiState].
*/
var homeScreenUiState: HomeScreenUiState by mutableStateOf(HomeScreenUiState.Start)
private set
/**
* Uses [bookRepository] to query the Google Books API with [searchTextUiState].
*/
fun getBooks() {
viewModelScope.launch {
homeScreenUiState = HomeScreenUiState.Loading
homeScreenUiState = try {
HomeScreenUiState.Success(bookRepository.getBooks(searchTextUiState.searchText))
} catch (e: IOException) {
HomeScreenUiState.Error
} catch (e: HttpException) {
HomeScreenUiState.Error
}
}
}
/**
* Holds [searchTextUiState] for the text field from [HomeScreen].
*/
var searchTextUiState by mutableStateOf(SearchTextUiState())
private set
/**
* Updates [searchTextUiState] with values provided by the text field from [HomeScreen].
*/
fun updateSearchTextUiState(searchText: String) {
searchTextUiState = SearchTextUiState(searchText = searchText)
}
}
/**
* Ui state for the text field from [HomeScreen].
*/
data class SearchTextUiState(
val searchText: String = ""
)
Thank you so much to whomever reads this!
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.
I'm creating a project with Compose, but I ran into a situation that I couldn't solve.
View Model:
data class OneState(
val name: String = "",
val city: String = ""
)
sealed class OneChannel {
object FirstStepToSecondStep : OneChannel()
object Finish : OneChannel()
}
#HiltViewModel
class OneViewModel #Inject constructor() : ViewModel() {
private val viewModelState = MutableStateFlow(OneState())
val screenState = viewModelState.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = viewModelState.value
)
private val _channel = Channel<OneChannel>()
val channel = _channel.receiveAsFlow()
fun changeName(value: String) {
viewModelState.update { it.copy(name = value) }
}
fun changeCity(value: String) {
viewModelState.update { it.copy(city = value) }
}
fun firstStepToSecondStep() {
Log.d("OneViewModel", "start of method first step to second step")
if (viewModelState.value.name.isBlank()) {
Log.d("OneViewModel", "name is empty, nothing should be done")
return
}
Log.d(
"OneViewModel",
"name is not empty, first step to second step event will be send for composable"
)
viewModelScope.launch {
_channel.send(OneChannel.FirstStepToSecondStep)
}
}
fun finish() {
Log.d("OneViewModel", "start of method finish")
if (viewModelState.value.city.isBlank()) {
Log.d("OneViewModel", "city is empty, nothing should be done")
return
}
Log.d(
"OneViewModel",
"city is not empty, finish event will be send for composable"
)
viewModelScope.launch {
_channel.send(OneChannel.Finish)
}
}
}
This ViewModel has a MutableStateFlow, a StateFlow to be collected on composable screens and a Channel/Flow for "one time events".
The first two methods are to change a respective state and the last two methods are to validate some logic and then send an event through the Channel.
Composables:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun FirstStep(
viewModel: OneViewModel,
nextStep: () -> Unit
) {
val state by viewModel.screenState.collectAsState()
LaunchedEffect(key1 = Unit) {
Log.d("FirstStep (Composable)", "start of launched effect block")
viewModel.channel.collect { channel ->
when (channel) {
OneChannel.FirstStepToSecondStep -> {
Log.d("FirstStep (Composable)", "first step to second step action")
nextStep()
}
else -> Log.d(
"FirstStep (Composable)",
"another action that should be ignored in this scope"
)
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
value = state.name,
onValueChange = { viewModel.changeName(value = it) },
placeholder = { Text(text = "Type our name") }
)
Spacer(modifier = Modifier.weight(weight = 1F))
Button(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
onClick = { viewModel.firstStepToSecondStep() }
) {
Text(text = "Next Step")
}
}
}
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun SecondStep(
viewModel: OneViewModel,
prevStep: () -> Unit,
finish: () -> Unit
) {
val state by viewModel.screenState.collectAsState()
LaunchedEffect(key1 = Unit) {
Log.d("SecondStep (Composable)", "start of launched effect block")
viewModel.channel.collect { channel ->
when (channel) {
OneChannel.Finish -> {
Log.d("SecondStep (Composable)", "finish action //todo")
finish()
}
else -> Log.d(
"SecondStep (Composable)",
"another action that should be ignored in this scope"
)
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
value = state.city,
onValueChange = { viewModel.changeCity(value = it) },
placeholder = { Text(text = "Type our city name") }
)
Spacer(modifier = Modifier.weight(weight = 1F))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(space = 16.dp)
) {
Button(
modifier = Modifier.weight(weight = 1F),
onClick = prevStep
) {
Text(text = "Previous Step")
}
Button(
modifier = Modifier.weight(weight = 1F),
onClick = { viewModel.finish() }
) {
Text(text = "Finish")
}
}
}
}
#OptIn(ExperimentalPagerApi::class)
#Composable
fun OneScreen(viewModel: OneViewModel = hiltViewModel()) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0)
val pages = listOf<#Composable () -> Unit>(
{
FirstStep(
viewModel = viewModel,
nextStep = {
coroutineScope.launch {
pagerState.animateScrollToPage(page = pagerState.currentPage + 1)
}
}
)
},
{
SecondStep(
viewModel = viewModel,
prevStep = {
coroutineScope.launch {
pagerState.animateScrollToPage(page = pagerState.currentPage - 1)
}
},
finish = {}
)
}
)
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
modifier = Modifier
.fillMaxWidth()
.weight(weight = 1F),
state = pagerState,
count = pages.size,
userScrollEnabled = false
) { index ->
pages[index]()
}
HorizontalPagerIndicator(
modifier = Modifier
.padding(vertical = 16.dp)
.align(alignment = Alignment.CenterHorizontally),
pagerState = pagerState,
activeColor = MaterialTheme.colorScheme.primary
)
}
}
OneScreen has a HorizontalPager (from the Accompanist library) which receives two other composables, FirstStep and SecondStep, these two composables have their own LaunchedEffect to collect any possible event coming from the View Model.
Dependencies used:
implementation 'androidx.navigation:navigation-compose:2.5.2'
implementation 'com.google.dagger:hilt-android:2.43.2'
kapt 'com.google.dagger:hilt-android-compiler:2.43.2'
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
implementation 'com.google.accompanist:accompanist-pager:0.25.1'
implementation 'com.google.accompanist:accompanist-pager-indicators:0.25.1'
The problem:
After typing something in the name field and clicking to go to the next step, the flow happens normally. When clicking to go back to the previous step, it also works normally. But now when clicking to go to the next step again, the collect in the LaunchedEffect of the FirstStep is not collected, instead the collect in LaunchedEffect of the SecondStep is, resulting in no action, and if click again, then collect in FirstStep works.
Some images that follow the logcat:
when opening the app
after typing something and clicking to go to the next step
going back to the first step
clicking to go to next step (problem)
clicking for the second time (works)
The problem is that HorizontalPager creates both the current page and the next page. When current page is FirstStep, both collectors are active and will be triggered sequentially.
Let's look at the three jump attempts on the first page. The first attempt is received by collector in FirstStep and successfully jumps to the second page. The second attempt is received by collector in SecondStep and fails. The third attempt succeeds again.
Actually, HorizontalPager is LazyRow, so this should be the result of LazyLayout's place logic.
To solve this problem, I suggest merging the two LaunchedEffect and moving it into OneScreen. In fact, the viewmodel should all be moved to the top of the OneScreen, for cleaner code.
At last, here is my simplified code if you want try it.
#Composable
fun Step(index: Int, flow: Flow<String>, onSwitch: () -> Unit, onSend: () -> Unit) {
LaunchedEffect(Unit) {
println("LaunchEffect$index")
flow.collect { println("Step$index:$it") }
}
Column {
Text(text = index.toString(), style = MaterialTheme.typography.h3)
Button(onClick = onSwitch) { Text(text = "Switch Page") }
Button(onClick = onSend) { Text(text = "Send") }
}
}
#OptIn(ExperimentalPagerApi::class)
#Composable
fun Test() {
val channel = remember { Channel<String>() }
val flow = remember { channel.receiveAsFlow() }
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState()
HorizontalPager(
modifier = Modifier.fillMaxSize(),
state = pagerState,
count = 4,
userScrollEnabled = false,
) { index ->
Step(index = index, flow = flow,
onSwitch = {
scope.launch { pagerState.scrollToPage((index + 1) % pagerState.pageCount) }
},
onSend = {
scope.launch { channel.send("Test") }
}
)
}
}
If you keep click send button at first page, it will print:
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'm building an application jetpack compose , after fetch some data from online source , i want to pass an id to as extras to the next screen so that i can call the next request api , but i'm facing two issues , the first issue is that showing me an error that composables can only be invoked from a composable context and the second issue is that i'm not sure wether i'm writing the correct code for calling the next screen , i appreciate any help , Thank you .
This is my code
val lazyPopularMoviesItems = movies.collectAsLazyPagingItems()
LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(lazyPopularMoviesItems.itemCount) { index ->
lazyPopularMoviesItems[index]?.let {
Card(elevation = 8.dp, modifier = Modifier
.height(200.dp)
.padding(10.dp)
.clickable {
// This is the function i want to call and pass extras with it
DetailsScreen(movieViewModel = movieViewModel, movieId = it.id)
}
.clip(RoundedCornerShape(8.dp))) {
Column {
Image(
painter = rememberImagePainter("http://image.tmdb.org/t/p/w500/" + it.backdrop_path),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.height(150.dp)
)
Text(
modifier = Modifier
.height(50.dp)
.padding(3.dp)
.fillMaxWidth(),
text = it.title,
fontSize = 15.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
textAlign = TextAlign.Center,
color = androidx.compose.ui.graphics.Color.Black
)
}
}
}
}
}
MainActivity Code
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val movieViewModel : MovieViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
Scaffold(
backgroundColor = Color.Blue.copy(0.1f),
topBar = { TopAppBar(title = {Text(text = "Movie Flex")}, backgroundColor = Color.White, elevation = 10.dp)},
bottomBar = {
val items = listOf(
BarItems.Popular,
BarItems.Playing,
BarItems.Top,
BarItems.Upcoming
)
BottomNavigation(backgroundColor = Color.Gray) {
items.forEach { item ->
BottomNavigationItem(
icon = { Icon(painter = painterResource(id = item.icon), contentDescription = item.title)},
label = { Text(text = item.title)},
selectedContentColor = Color.White,
alwaysShowLabel = true,
selected = false,
unselectedContentColor = Color.White.copy(0.5f),
onClick = {
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
})
}
}
},
content = {
ScreenNavigation(navController,movieViewModel)
},
)
}
}
}
#OptIn(ExperimentalCoilApi::class)
#Composable
fun ScreenNavigation(navController: NavHostController,movieViewModel: MovieViewModel){
NavHost(navController = navController, startDestination = BarItems.Popular.route){
composable(route = BarItems.Popular.route){
PopularScreen(movies = movieViewModel.getPopular(), movieViewModel = movieViewModel)
}
composable(route = BarItems.Playing.route){
PlayingScreen(movies = movieViewModel.getPlaying())
}
composable(route = BarItems.Top.route){
TopRatedScreen(movies = movieViewModel.getTopRated())
}
composable(route = BarItems.Upcoming.route){
UpcomingScreen(movies = movieViewModel.getUpcoming())
}
}
}
Navigation Routing
sealed class BarItems(var route : String , var icon : Int , var title : String) {
object Popular : BarItems("popular", R.drawable.ic_baseline_remove_red_eye_24,"Popular")
object Playing : BarItems("playing",R.drawable.ic_baseline_remove_red_eye_24,"Playing")
object Top : BarItems("top",R.drawable.ic_baseline_remove_red_eye_24,"Top")
object Upcoming : BarItems("upcoming",R.drawable.ic_baseline_remove_red_eye_24,"Upcoming")
}
As you've pointed out, we'll need two things:
Handle the navigation. You can use navigation-compose. Have a look at the documentation
Trigger the navigation with either a LaunchedEffect or by launching a coroutine.
My composable is recomposing endlessly after flow collect and navigating to a new screen.
I can't understand why.
I'm using Firebase for Auth with Email and Password.
I had to put some Log.i to test my function and my composable, and yes, my Main composable (SignUp) is recomposing endlessly after navigating.
ViewModel
// Firebase auth
private val _signUpState = mutableStateOf<Resources<Any>>(Resources.success(false))
val signUpState: State<Resources<Any>> = _signUpState
fun firebaseSignUp(email: String, password: String) {
job = viewModelScope.launch(Dispatchers.IO) {
firebaseAuth.firebaseSignUp(email = email, password = password).collect {
_signUpState.value = it
Log.i("balito", "polipop")
}
}
}
fun stop() {
job?.cancel()
}
SignUp
#Composable
fun SignUp(
navController: NavController,
signUpViewModel: SignUpViewModel = hiltViewModel()
) {
val localFocusManager = LocalFocusManager.current
Log.i("salut", "salut toi")
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(16.dp)
.background(color = PrimaryColor)
) {
BackButton(navController = navController)
Spacer(modifier = Modifier.height(30.dp))
Text(
text = stringResource(id = R.string.sinscrire),
fontFamily = visby,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
color = Color.White
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prenez_votre_sante_en_main),
fontFamily = visby,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
color = Grey
)
Spacer(modifier = Modifier.height(20.dp))
Email(signUpViewModel = signUpViewModel, localFocusManager = localFocusManager)
Spacer(modifier = Modifier.height(16.dp))
Password(signUpViewModel = signUpViewModel, localFocusManager = localFocusManager)
Spacer(modifier = Modifier.height(30.dp))
Button(value = stringResource(R.string.continuer), type = Type.Valid.name) {
localFocusManager.clearFocus()
signUpViewModel.firebaseSignUp(signUpViewModel.emailInput.value, signUpViewModel.passwordInput.value)
}
Spacer(modifier = Modifier.height(16.dp))
Button(value = stringResource(R.string.inscription_avec_google), type = Type.Other.name) {
}
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
ClickableTextInfo(stringResource(id = R.string.deja_un_compte_se_connecter), onClick = {})
}
}
Response(navController = navController, signUpViewModel = signUpViewModel)
DisposableEffect(key1 = signUpViewModel.signUpState.value == Resources.success(true)) {
onDispose {
signUpViewModel.stop()
Log.i("fin", "fin")
}
}
}
#Composable
private fun Response(
navController: NavController,
signUpViewModel: SignUpViewModel
) {
when (val response = signUpViewModel.signUpState.value) {
is Resources.Loading<*> -> {
//WaitingLoaderProgress(loading = true)
}
is Resources.Success<*> -> {
response.data.also {
Log.i("lolipop", "lolipopi")
if (it == true) {
navController.navigate(Screen.SignUpConfirmation.route)
}
}
}
is Resources.Failure<*> -> {
// response.throwable.also {
// Log.d(TAG, it)
// }
}
}
}
During navigation transition recomposition happens multiple times because of animations, and you call navController.navigate on each recomposition.
You should not cause side effects or change the state directly from the composable builder, because this will be performed on each recomposition, which is not expected in cases like animation.
Instead you should use side effects. In your case, LaunchedEffect should be used.
if (response.data) {
LaunchedEffect(Unit) {
Log.i("lolipop", "lolipopi")
navController.navigate(Screen.SignUpConfirmation.route)
}
}