Android Paging3 - refresh from a ViewModel with Compose - android

I'm using the Paging 3 library with Jetpack Compose and have just implemented swipe to dismiss on some paged data (using the Material library's SwipeToDismiss composable).
Once a swipe action has completed, I call a method in my ViewModel to send an update to the server (either to mark a message as read or to delete a message). Once this action has taken place, I obviously need to refresh the paging data.
My current approach is to have a call back from my ViewModel function which will then handle the refresh on the LazyPagingItems, but this feels wrong.
Is there a better approach?
My ViewModel basically looks like:
#HiltViewModel
class MessageListViewModel #Inject constructor(
private val repository: Repository
): ViewModel() {
companion object {
private const val TAG = "MessageListViewModel"
}
val messages : Flow<PagingData<Message>> = Pager(
PagingConfig(
enablePlaceholders = false,
)
) {
MessagePagingSource(repository)
}.flow.cachedIn(viewModelScope)
fun markRead(guid: String, onComplete: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
try {
repository.markMessageRead(guid)
onComplete()
} catch (e: Throwable) {
Log.e(TAG, "Error marking message read: $guid", e)
}
}
}
}
And in my Composable for the message list, it looks a bit like the following:
#Composable
fun MessageListScreen(
vm: MessageListViewModel = viewModel(),
) {
val messages: LazyPagingItems<MessageSummary> = vm.messages.collectAsLazyPagingItems()
val refreshState = rememberSwipeRefreshState(
isRefreshing = messages.loadState.refresh is LoadState.Loading,
)
Scaffold(
topBar = {
SmallTopAppBar (
title = {
Text(stringResource(R.string.message_list_title))
},
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
SwipeRefresh(
state = refreshState,
onRefresh = {
messages.refresh()
},
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
items(
items = messages,
key = { it.guid }
) { message ->
message?.let {
MessageRow(
onMarkRead = {
vm.markRead(message.guid) {
messages.refresh()
}
},
)
}
}
}
}
}
}
}
As I say, this does work, it just doesn't quite feel like the cleanest approach.
I'm fairly new to working with flows, so I don't know if there's some other trick I'm missing...

I ended up implementing something like this:
View Model:
class MessageListViewModel #Inject constructor(
private val repository: Repository,
): ViewModel() {
sealed class UiAction {
class MarkReadError(val error: Throwable): UiAction()
class MarkedRead(val id: Long): UiAction()
}
private val _uiActions = MutableSharedFlow<UiAction>()
val uiActions = _uiActions.asSharedFlow()
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
)
fun markRead(id: Long) {
viewModelScope.launch(Dispatchers.IO) {
try {
repository.markMessageRead(id)
_uiActions.emit(UiAction.MarkedRead(id))
} catch (e: Throwable) {
Log.e(TAG, "Error marking message read: $id", e)
_uiActions.emit(UiAction.MarkReadError(e))
}
}
}
}
View:
#Composable
fun MessageListScreen(
vm: MessageListViewModel = viewModel(),
onMarkReadFailed: (String) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val messages: LazyPagingItems<Message> = vm.messages.collectAsLazyPagingItems()
val refreshState = rememberSwipeRefreshState(
isRefreshing = messages.loadState.refresh is LoadState.Loading,
)
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.uiActions.collectLatest {
when (it) {
is MessageListViewModel.UiAction.MarkReadError -> {
val msg = it.error.localizedMessage ?: it.error.message
val message = if (!msg.isNullOrEmpty()) {
context.getString(R.string.error_unknown_error_with_message, msg)
} else {
context.getString(R.string.error_unknown_error_without_message)
}
onMarkReadFailed(message)
}
is MessageListViewModel.UiAction.MarkedRead -> {
messages.refresh()
}
}
}
}
}
SwipeRefresh(
state = refreshState,
onRefresh = {
messages.refresh()
},
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
state = listState,
) {
items(
items = messages,
key = { it.id }
) { message ->
message?.let {
MessageRow(
onMarkRead = {
vm.markRead(message.id)
},
)
}
FadedDivider()
}
messages.apply {
when (loadState.append) {
is LoadState.Loading -> {
item {
LoadingRow(R.string.messages_loading)
}
}
else -> {}
}
}
}
}
}

Related

How to check if PadingData<T> is empty in ViewModel?

I'm using Paging 3 with a Room database in my Android app. Currently, I have a sealed class which contains the Loading, Empty, Error states, and a Success state which receives the retrieved PagingData<ShoppingListItem> list data. In the ViewModel, I want to assign the Empty state if the PagingData list is empty and display a Compose UI message stating that the shopping list is empty. But, I'm not sure how to do this? How can I check if PagingData<ShoppingListItem> is empty in the ViewModel?
Sealed Class
sealed class ListItemsState {
object Loading : ListItemsState()
object Empty : ListItemsState()
object Error : ListItemsState()
data class Success(val allItems: Flow<PagingData<ShoppingListItem>>?) : ListItemsState()
}
ViewModel
#HiltViewModel
class ShoppingListScreenViewModel #Inject constructor(
private val getAllShoppingListItemsUseCase: GetAllShoppingListItemsUseCase
) {
private val _shoppingListItemsState = mutableStateOf<Flow<PagingData<ShoppingListItem>>?>(null)
private val _listItemsLoadingState = MutableStateFlow<ListItemsState>(ListItemsState.Loading)
val listItemsLoadingState = _listItemsLoadingState.asStateFlow()
init {
getAllShoppingListItemsFromDb()
}
private fun getAllShoppingListItemsFromDb() {
viewModelScope.launch {
_shoppingListItemsState.value = getAllShoppingListItemsUseCase().distinctUntilChanged()
_listItemsLoadingState.value = ListItemsState.Success(_shoppingListItemsState.value)
}
}
}
ShoppingListScreen Composable
#Composable
fun ShoppingListScreen(
navController: NavHostController,
shoppingListScreenViewModel: ShoppingListScreenViewModel,
sharedViewModel: SharedViewModel
) {
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val allItemsState = shoppingListScreenViewModel.listItemsLoadingState.collectAsState().value
Scaffold(
topBar = {
CustomAppBar(
title = "Shopping List",
titleFontSize = 20.sp,
appBarElevation = 4.dp,
navController = navController
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
shoppingListScreenViewModel.setStateValue(SHOW_ADD_ITEM_DIALOG_STR, true)
},
backgroundColor = Color.Blue,
contentColor = Color.White
) {
Icon(Icons.Filled.Add, "")
}
},
backgroundColor = Color.White,
// Defaults to false
isFloatingActionButtonDocked = false,
bottomBar = { BottomNavigationBar(navController = navController) }
) {
Box {
when (allItemsState) {
is ListItemsState.Loading -> ConditionalCircularProgressBar(isDisplayed = true)
is ListItemsState.Error -> Text("Error!")
is ListItemsState.Success -> {
ConditionalCircularProgressBar(isDisplayed = false)
val successItems = allItemsState.allItems?.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(screenHeight)
) {
items(
items = successItems!!,
key = { item ->
item.id
}
) { item ->
ShoppingListScreenItem(
navController = navController,
item = item,
sharedViewModel = sharedViewModel
) { isChecked ->
scope.launch {
shoppingListScreenViewModel.changeItemChecked(item!!, isChecked)
}
}
}
item { Spacer(modifier = Modifier.padding(screenHeight - (screenHeight - 70.dp))) }
}
}
else -> {}
}
}
}
}

Loading spinner not working correctly in next screen in jetpack compose

I am new in Compose Navigation. I have Button and when I clicked, I called the function in Viewmodel and trigger loading event with using StateFlow. So I called the next screen through navigation and calling loading spinner. I used delay(5000) to show spinner more before getting data but spinner is loading after the data is loaded. Can someone guide me.
MainActivityViewModel.kt
class MainActivityViewModel(private val resultRepository: ResultRepository) : ViewModel() {
val stateResultFetchState = MutableStateFlow<ResultFetchState>(ResultFetchState.OnEmpty)
fun getSportResult() {
viewModelScope.launch {
stateResultFetchState.value = ResultFetchState.IsLoading
val result = resultRepository.getSportResult()
delay(5000)
result.handleResult(
onSuccess = { response ->
if (response != null) {
stateResultFetchState.value = ResultFetchState.OnSuccess(response)
} else {
stateResultFetchState.value = ResultFetchState.OnEmpty
}
},
onError = {
stateResultFetchState.value =
ResultFetchState.OnError(it.errorResponse?.errorMessage)
}
)
}
}
}
SetupMainActivityView.kt
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun SetupMainActivityView(
viewModel: MainActivityViewModel = koinViewModel(),
navigateToNext: () -> Unit,
) {
Scaffold(topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.app_name)) },
backgroundColor = getBackgroundColor(),
elevation = 0.dp
)
}, content = { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.background(getBackgroundColor())
.padding(padding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
viewModel.getSportResult()
}) {
Text(text = stringResource(id = R.string.get_result))
}
}
})
when (val state = viewModel.stateResultFetchState.collectAsState().value) {
is ResultFetchState.OnSuccess -> {}
is ResultFetchState.IsLoading -> {
navigateToNext()
}
is ResultFetchState.OnError -> {}
is ResultFetchState.OnEmpty -> {}
}
}
My whole project link. Can someone guide me how can I show loading spinner after loading the next screen. Thanks
UPDATE
NavigationGraph.kt
#Composable
internal fun NavigationGraph() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = ScreenRoute.Home.route) {
composable(ScreenRoute.Home.route) {
SetupMainActivityView{
navController.navigate(ScreenRoute.Result.route)
}
}
composable(ScreenRoute.Result.route) {
ResultScreen()
}
}
}
ResultScreen.kt
#Composable
fun ResultScreen() {
CircularProgressIndicator()
}
please check my repository if you need more code. I added my github link above. Thanks
I can't see your code handling the Spinner. Anyway, a general idea to handle these kinda situations is
val state = remember{mutableStateOf<ResultFetchState>(ResultFetchState.EMPTY)}
if(state == ResultFetchState.LOADING){
//show spinner
Spinner()
}
...
state.value = viewModel.stateResultFetchState.collectAsState().value

mutableList.postValue() does not update composable

For my pet app, I have a composable that looks like this:
#Composable
fun PokeBrowser(model: PokeBrowserViewModel) {
val pokemonDataState by model.pokemonDataList.observeAsState()
pokemonDataState?.let {
val staticState = pokemonDataState ?: listOf()
LazyColumn() {
itemsIndexed(staticState) { i, p ->
if (i == staticState.lastIndex) {
model.loadPokemonData()
}
val pokeImagePainter = rememberImagePainter(
data = p.imageUrl
)
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
) {
Image(
painter = pokeImagePainter,
contentDescription = "Pokemon name"
)
Text(text = p.name)
}
}
}
}
}
I populate the lazy load of data in this way:
private fun getPokemonData(nextPokeList: List<GetPokemonListResponse.PokemonUrl>) {
viewModelScope.launch {
val requests = nextPokeList.map { urlData ->
async {
pokeClient.getPokemonData(urlData.url)
}
}
val responses = requests.awaitAll()
val newPokemonDataList = _pokemonDataList.value as ArrayList
newPokemonDataList.addAll(
pokeClientMapper.mapPokeDataResponseToDomainModel(responses)
)
_pokemonDataList.postValue(newPokemonDataList)
}
}
I can see that more data is coming in on this line: _pokemonDataList.postValue(newPokemonDataList)
But, the composable does not update. Am I missing something?
Try this:
val pokemonDataState by model.pokemonDataList.observeAsState().value
I suspected that to postValue correctly I needed a brand new collection pointer. So, I rewrote the code in this way, which fixed the problem:
private fun getPokemonData(nextPokeList: List<GetPokemonListResponse.PokemonUrl>) {
viewModelScope.launch {
val requests = nextPokeList.map { urlData ->
async {
pokeClient.getPokemonData(urlData.url)
}
}
val responses = requests.awaitAll()
val oldPokemonData = _pokemonDataList.value as ArrayList
val newPokemonData = ArrayList<Pokemon>(oldPokemonData.size + responses.size)
newPokemonData.addAll(oldPokemonData.toList())
newPokemonData.addAll(pokeClientMapper.mapPokeDataResponseToDomainModel(responses))
_pokemonDataList.postValue(newPokemonData)
}
}

Screen scrolls to the top (Jetpack Compose Pagination)

I am trying to do pagination in my application. First, I'm fetching 20 item from Api (limit) and every time i scroll down to the bottom of the screen, it increase this number by 20 (nextPage()). However, when this function is called, the screen goes to the top, but I want it to continue where it left off. How can I do that?
Here is my code:
CharacterListScreen:
#Composable
fun CharacterListScreen(
characterListViewModel: CharacterListViewModel = hiltViewModel()
) {
val state = characterListViewModel.state.value
val limit = characterListViewModel.limit.value
Box(modifier = Modifier.fillMaxSize()) {
val listState = rememberLazyListState()
LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
itemsIndexed(state.characters) { index, character ->
characterListViewModel.onChangeRecipeScrollPosition(index)
if ((index + 1) >= limit) {
characterListViewModel.nextPage()
}
CharacterListItem(character = character)
}
}
if (state.error.isNotBlank()) {
Text(
text = state.error,
color = MaterialTheme.colors.error,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.align(Alignment.Center)
)
}
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
CharacterListViewModel:
#HiltViewModel
class CharacterListViewModel #Inject constructor(
private val characterRepository: CharacterRepository
) : ViewModel() {
val state = mutableStateOf(CharacterListState())
val limit = mutableStateOf(20)
var recipeListScrollPosition = 0
init {
getCharacters(limit.value, Constants.HEADER)
}
private fun getCharacters(limit : Int, header : String) {
characterRepository.getCharacters(limit, header).onEach { result ->
when(result) {
is Resource.Success -> {
state.value = CharacterListState(characters = result.data ?: emptyList())
}
is Resource.Error -> {
state.value = CharacterListState(error = result.message ?: "Unexpected Error")
}
is Resource.Loading -> {
state.value = CharacterListState(isLoading = true)
}
}
}.launchIn(viewModelScope)
}
private fun incrementLimit() {
limit.value = limit.value + 20
}
fun onChangeRecipeScrollPosition(position: Int){
recipeListScrollPosition = position
}
fun nextPage() {
if((recipeListScrollPosition + 1) >= limit.value) {
incrementLimit()
characterRepository.getCharacters(limit.value, Constants.HEADER).onEach {result ->
when(result) {
is Resource.Success -> {
state.value = CharacterListState(characters = result.data ?: emptyList())
}
is Resource.Error -> {
state.value = CharacterListState(error = result.message ?: "Unexpected Error")
}
is Resource.Loading -> {
state.value = CharacterListState(isLoading = true)
}
}
}.launchIn(viewModelScope)
}
}
}
CharacterListState:
data class CharacterListState(
val isLoading : Boolean = false,
var characters : List<Character> = emptyList(),
val error : String = ""
)
I think the issue here is that you are creating CharacterListState(isLoading = true) while loading. This creates an object with empty list of elements. So compose renders an empty LazyColumn here which resets the scroll state. The easy solution for that could be state.value = state.value.copy(isLoading = true). Then, while loading, the item list can be preserved (and so is the scroll state)
Not sure if you are using the LazyListState correctly. In your viewmodel, create an instance of LazyListState:
val lazyListState: LazyListState = LazyListState()
Pass that into your composable and use it as follows:
#Composable
fun CharacterListScreen(
characterListViewModel: CharacterListViewModel = hiltViewModel()
) {
val limit = characterListViewModel.limit.value
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize(), state = characterListViewModel.lazyListState) {
itemsIndexed(state.characters) { index, character ->
}
}
}
}

How to display data from view model correctly?

In my viewModel i get result.data:
accounts: [Account(accountId=461715f3-038c-4c3d-ac3c-1fac44f37f14, currency=GBP, description=Personal, nickname=Sydney Beard)]
Here is the code of ViewModel:
#HiltViewModel
class AccountListViewModel #Inject constructor(
private val getAccountsUseCase: GetAccountsUseCase,
savedStateHandle: SavedStateHandle
): ViewModel() {
private val _state = mutableStateOf<AccountListState>(AccountListState())
val state: State<AccountListState> = _state
init {
savedStateHandle.get<String>(Constants.ACCESS_TOKEN)?.let { accessToken ->
getAccounts(accessToken = accessToken)
}
}
private fun getAccounts(accessToken: String) {
getAccountsUseCase(accessToken = accessToken).onEach { result ->
when (result) {
is Resource.Success -> {
_state.value = AccountListState(accounts = result.data ?: emptyList())
}
is Resource.Error -> {
_state.value = AccountListState(error = result.message ?: "Something went wrong")
}
is Resource.Loading -> {
_state.value = AccountListState(isLoading = true)
}
}
}.launchIn(viewModelScope)
}
}
In my screen in log i see that i get account:
account: Account(accountId=461715f3-038c-4c3d-ac3c-1fac44f37f14, currency=GBP, description=Personal, nickname=Sydney Beard)
Here is code of my screen:
#ExperimentalMaterialApi
#Composable
fun OverviewScreen(
navController: NavController,
viewModel: AccountListViewModel = hiltViewModel()
) {
val state = viewModel.state.value
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.accounts) { account ->
Log.d("some", "account: $account")
AccountListItem(
account = account,
onItemClick = {
navController.navigate(Screen.AccountDetail.route + "/${account.accountId}")
}
)
}
}
if (state.error.isNotBlank()) {
Text(
text = state.error,
color = MaterialTheme.colors.error,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.align(Alignment.Center)
)
}
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
But unfortunately I can't see anything on the screen, although this is how I display my account.
Q: how to display my account, because now many times I have the getAccounts method called which is why the screen is redrawn every time
UPDATE
I check my AccountListViewModel instance and in method getAccounts() put the next log:
Log.d("some", "getAccounts: ${result.data} vm: ${this.toString()}")
And see that and I saw that every time the view model is recreated:
In Compose you have to collect your flow as a state. You are only reading flow once in your code.
You are also using state in ViewModel which is bad. Use flow there.
Fixes in ViewModel:
// wrong
private val _state = mutableStateOf<AccountListState>(AccountListState())
val state: State<AccountListState> = _state
// correct
private val _state = MutableStateFlow<AccountListState>(AccountListState())
val state: StateFlow<AccountListState> = _state
And fixed in compose:
// wrong
val state = viewModel.state.value
// correct
val state by viewModel.state.collectAsState()
Read more here: https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.StateFlow).collectAsState(kotlin.coroutines.CoroutineContext)

Categories

Resources