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 ->
}
}
}
}
Related
I have implemented search functionality in my app which display result as a verticalGridView with pagination : https://github.com/alirezaeiii/TMDb-Compose
I have following logic for refresh load state that works as I wish :
#Composable
fun <T : TMDbItem> PagingScreen(
viewModel: BasePagingViewModel<T>,
onClick: (TMDbItem) -> Unit,
) {
val lazyTMDbItems = viewModel.pagingDataFlow.collectAsLazyPagingItems()
when (lazyTMDbItems.loadState.refresh) {
is LoadState.Loading -> {
TMDbProgressBar()
}
is LoadState.Error -> {
val message =
(lazyTMDbItems.loadState.refresh as? LoadState.Error)?.error?.message ?: return
lazyTMDbItems.apply {
ErrorScreen(
message = message,
modifier = Modifier.fillMaxSize(),
refresh = { retry() }
)
}
}
else -> {
LazyTMDbItemGrid(lazyTMDbItems, onClick)
}
}
}
In LazyTMDbItemGrid, I try to manage append load state as follow :
#Composable
private fun <T : TMDbItem> LazyTMDbItemGrid(
lazyTMDbItems: LazyPagingItems<T>,
onClick: (TMDbItem) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(COLUMN_COUNT),
contentPadding = PaddingValues(
start = Dimens.GridSpacing,
end = Dimens.GridSpacing,
bottom = WindowInsets.navigationBars.getBottom(LocalDensity.current)
.toDp().dp.plus(
Dimens.GridSpacing
)
),
horizontalArrangement = Arrangement.spacedBy(
Dimens.GridSpacing,
Alignment.CenterHorizontally
),
content = {
repeat(COLUMN_COUNT) {
item {
Spacer(
Modifier.windowInsetsTopHeight(
WindowInsets.statusBars.add(WindowInsets(top = 56.dp))
)
)
}
}
items(lazyTMDbItems.itemCount) { index ->
val tmdbItem = lazyTMDbItems[index]
tmdbItem?.let {
TMDbItemContent(
it,
Modifier
.height(320.dp)
.padding(vertical = Dimens.GridSpacing),
onClick
)
}
}
lazyTMDbItems.apply {
when (loadState.append) {
is LoadState.Loading -> {
item(span = span) {
LoadingRow(modifier = Modifier.padding(vertical = Dimens.GridSpacing))
}
}
is LoadState.Error -> {
val message =
(loadState.append as? LoadState.Error)?.error?.message ?: return#apply
item(span = span) {
ErrorScreen(
message = message,
modifier = Modifier.padding(vertical = Dimens.GridSpacing),
refresh = { retry() })
}
}
else -> {}
}
}
})
}
The problem is when there is no result for search, or when result items is shorter than screen size, it displays LoadingRow. My expectation is when we are in this state, LoadingRow does not display, but how can I detect this state?
Correct me if I'm wrong but these should be dictated by the PagingSource.LoadResult.Page
Documentation :
Success result object for PagingSource.load. Params: data - Loaded
data prevKey - Key for previous page if more data can be loaded in
that direction, null otherwise. nextKey - Key for next page if more
data can be loaded in that direction, null otherwise.
So if you reached the pagination end (in either direction) :
PagingSource.LoadResult.Page(
data = loadedData,
prevKey = null,
nextKey = null)
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 -> {}
}
}
}
}
}
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
I am developing an application in which I show a list of potatoes, retrieving the data from Firestore.
I have added a swipe action to refresh the data. With the code that I show below, the data is updating fine, the call is made to Firestore and it is updated showing new values in case they exist, or stopping showing values that no longer exist.
The problem is that when I swipe the potato list screen remains blank, empty, and when the call to Firestore ends, they are shown again. That is, there are a couple of seconds that the screen goes blank.
Is there a possibility that this will not happen? This effect is somewhat ugly
ViewModel:
#HiltViewModel
class PotatoesViewModel #Inject constructor(
private val getPotatoesDataUseCase: GetPotatoesData
) : ViewModel() {
private val _state = mutableStateOf(PotatoesState())
val state: State<PotatoesState> = _state
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean>
get() = _isRefreshing.asStateFlow()
init {
getPotatoes()
}
private fun getPotatoes() {
getPotatoesDataUseCase().onEach { result ->
when (result) {
is Resource.Success -> {
_state.value = PotatoesState(potatoes = result.data?.potatoes ?: emptyList())
}
is Resource.Error -> {
_state.value = PotatoesState(
error = result.message ?: "An unexpected error occurred"
)
}
is Resource.Loading -> {
_state.value = PotatoesState(isLoading = true)
}
}
}.launchIn(viewModelScope)
}
fun refresh() {
viewModelScope.launch {
_isRefreshing.emit(true)
getIncidents()
_isRefreshing.emit(false)
}
}
}
Screen:
#Composable
fun PotatoesDataScreen(
navController: NavController,
viewModel: PotatoesViewModel = hiltViewModel()
) {
val state = viewModel.state.value
val isRefreshing by viewModel.isRefreshing.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(R.string.app_name),
fontWeight = FontWeight.Bold
)
},
backgroundColor = Primary,
contentColor = Color.White
)
},
content = {
Box(modifier = Modifier.fillMaxSize()) {
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing),
onRefresh = { viewModel.refresh() }
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
) {
items(state.potatoes) { potato ->
PotatoCard(
potato = potato
)
}
}
}
}
}
)
}
PotatoState:
data class PotatoesState(
val isLoading: Boolean = false,
val potatoes: List<Potato> = emptyList(),
val error: String = ""
)
When the list screen is blank, this is the time when the Api call is made.
When your call is made and response is still not received, this is also when the list is blank.
You pass a new Object of PotatoesState to the mutableState every time you:
receive a response,
get an error, (with Potatoes = emptyList())
or state is loading. (with Potatoes = emptyList())
UI is updated according to the MutableState you named _state.
If you want to keep the same data until you get a new response, then you need to update the current state.value: MutableState<PotatoesState> object only when you get a new response (AKA, is Resource.success).
Alternatively, you can implement a Loading Spinner, and show it when you start your Api Request, until isLoading is false.
EDIT: Addition of suggested code.
So that how I would declare PotatoesState class:
class PotatoesState(
var isLoading: Boolean = false,
var potatoes: List<Potato> = emptyList(),
var error: String = ""
)
And that's what I would write in the CallBack function:
when (result) {
is Resource.Success -> {
if (result.data != null) {
_state.value = mutableStateOf().apply {
addAll(_state.value.apply { it ->
it.potatoes = result.data!!.potatoes
it.isLoading = false
it.error = ""
})
}
}
}
is Resource.Error -> {
_state.value = mutableStateOf().apply {
addAll(_state.value.apply { it ->
it.error =
result.message ?:
"An unexpected error occurred"
it.isLoading = false
})
}
}
is Resource.Loading -> {
_state.value = mutableStateOf().apply {
addAll(_state.value.apply { it ->
it.isLoading = true
})
}
}
}
I have a LazyVerticalGrid with 2 cells.
LazyVerticalGrid(
cells = GridCells.Fixed(2),
content = {
items(moviePagingItems.itemCount) { index ->
val movie = moviePagingItems[index] ?: return#items
MovieItem(movie, Modifier.preferredHeight(320.dp))
}
renderLoading(moviePagingItems.loadState)
}
)
I am trying to show full width loading with LazyGridScope's fillParentMaxSize modifier.
fun LazyGridScope.renderLoading(loadState: CombinedLoadStates) {
when {
loadState.refresh is LoadState.Loading -> {
item {
LoadingColumn("Fetching movies", Modifier.fillParentMaxSize())
}
}
loadState.append is LoadState.Loading -> {
item {
LoadingRow(title = "Fetching more movies")
}
}
}
}
But since we have 2 cells, the loading can occupy half of the screen. Like this:
Is there a way my loading view can occupy full width?
Jetpack Compose 1.1.0-beta03 version includes horizontal span support for LazyVerticalGrid.
Here is the example usage:
private const val CELL_COUNT = 2
private val span: (LazyGridItemSpanScope) -> GridItemSpan = { GridItemSpan(CELL_COUNT) }
LazyVerticalGrid(
cells = GridCells.Fixed(CELL_COUNT),
content = {
items(moviePagingItems.itemCount) { index ->
val movie = moviePagingItems.peek(index) ?: return#items
Movie(movie)
}
renderLoading(moviePagingItems.loadState)
}
}
private fun LazyGridScope.renderLoading(loadState: CombinedLoadStates) {
if (loadState.append !is LoadState.Loading) return
item(span = span) {
val title = stringResource(R.string.fetching_more_movies)
LoadingRow(title = title)
}
}
Code examples of this answer can be found at: Jetflix/MoviesGrid.kt
LazyVerticalGrid has a span strategy built into items() and itemsIndexed()
#Composable
fun SpanLazyVerticalGrid(cols: Int, itemList: List<String>) {
val lazyGridState = rememberLazyGridState()
LazyVerticalGrid(
columns = GridCells.Fixed(cols),
state = lazyGridState
) {
items(itemList, span = { item ->
val lowercase = item.lowercase()
val span = if (lowercase.startsWith("a") || lowercase.lowercase().startsWith("b") || lowercase.lowercase().startsWith("d")) {
cols
}
else {
1
}
GridItemSpan(span)
}) { item ->
Box(modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(10.dp)
.background(Color.Black)
.padding(2.dp)
.background(Color.White)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = item,
fontSize = 18.sp
)
}
}
}
}
'
val names = listOf("Alice", "Bob", "Cindy", "Doug", "Ernie", "Fred", "George", "Harris")
SpanLazyVerticalGrid(
cols = 3,
itemList = names
)
Try something like:
var cellState by remember { mutableStateOf(2) }
LazyVerticalGrid(
cells = GridCells.Fixed(cellState),
content = {
items(moviePagingItems.itemCount) { index ->
val movie = moviePagingItems[index] ?: return#items
MovieItem(movie, Modifier.preferredHeight(320.dp))
}
renderLoading(moviePagingItems.loadState) {
cellState = it
}
}
)
The renderLoading function:
fun LazyGridScope.renderLoading(loadState: CombinedLoadStates, span: (Int) -> Unit) {
when {
loadState.refresh is LoadState.Loading -> {
item {
LoadingColumn("Fetching movies", Modifier.fillParentMaxSize())
}
span(1)
}
...
else -> span(2)
}
}
I have created an issue for it: https://issuetracker.google.com/u/1/issues/176758183
Current workaround I have is to use LazyColumn and implement items or header.
override val content: #Composable () -> Unit = {
LazyColumn(
contentPadding = PaddingValues(8.dp),
content = {
items(colors.chunked(3), itemContent = {
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
val modifier = Modifier.weight(1f)
it.forEach {
ColorItem(modifier, it)
}
for (i in 1..(3 - it.size)) {
Spacer(modifier)
}
}
})
item {
Text(
text = stringResource(R.string.themed_colors),
style = MaterialTheme.typography.h3
)
}
items(themedColors.chunked(3), itemContent = {
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
val modifier = Modifier.weight(1f)
it.forEach {
ColorItem(modifier, it)
}
for (i in 1..(3 - it.size)) {
Spacer(modifier)
}
}
})
})
}