I want to make without paging lib load more functionality with JetpackCompose LazyColumn. All works fine except I am sending from parent isLoading value that is coming from ViewModel. But in my extension function this value never change. Any advice? Here is my code:
TileListView
#Composable
fun LazyListState.OnBottomReached(
onEndReachedThreshold : Int = 0,
onLoadMore : suspend () -> Unit,
isLoading: Boolean = false
) {
require(onEndReachedThreshold >= 0) { "buffer cannot be negative, but was $onEndReachedThreshold" }
// Is Loading does not update from parent
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
?:
return#derivedStateOf true
lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - onEndReachedThreshold
}
}
LaunchedEffect(shouldLoadMore){
snapshotFlow { shouldLoadMore.value}
.collect { if (it) {
onLoadMore()
} }
}
}
#Composable
fun TileListView(tiles: List<Tile>,
loadMore: suspend () -> Unit,
isLoading: Boolean,
onEndReachedThreshold: Int = 0,) {
val listState = rememberLazyListState()
val tileViewModel: TileViewModel = hiltViewModel()
LazyColumn(state = listState) {
items(tiles) { tile ->
tileViewModel.tileViewFactory.constructTileView(tile)
}
}
listState.OnBottomReached(onEndReachedThreshold, {
loadMore()
}, isLoading )
}
And here is where I render it
#Composable
fun Feed() {
val feedViewModel: FeedViewModel = hiltViewModel()
val tiles = feedViewModel.state.collectAsState()
val isLoading = feedViewModel.isLoading.collectAsState()
val coroutineScope = rememberCoroutineScope()
TileListView(
tiles = tiles.value,
{
coroutineScope.launch {
feedViewModel.loadMore()
}
}, isLoading = isLoading.value, onEndReachedThreshold = 0
)
}
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 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 ->
}
}
}
}
The following Code A is from the official Advanced State in Jetpack Compose Codelab.
And I have read the article.
In Code B, I think the value of uiState.isLoading should always false because either DetailsUiState(cityDetailsResult.data) or DetailsUiState(throwError = true) will get the object with the value isLoading = false by default, right?
I think that the business logic should be this:
Loading screen is displayed first and uiState.isLoading==true when the data ExploreModel is loading.
The data UI is displayed automatically and uiState.cityDetails != null when the data ExploreModel has been loaded.
I run and test the Code A, the log of the project record "Is Loading" first, then record "Have Data".
But I can't understand how the code in the project can display loading screen first, then display data UI automatically again, could you tell me?
Code B
#Composable
fun DetailsScreen(
...
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
...
}
Code A
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
Log.e("My","Have Data") //I add
DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
}
uiState.isLoading -> {
Box(modifier.fillMaxSize()) {
Log.e("My","Is Loading") // I add
CircularProgressIndicator(
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> { onErrorLoading() }
}
}
#Composable
fun DetailsContent(
exploreModel: ExploreModel,
modifier: Modifier = Modifier
) {
Column(modifier = modifier, verticalArrangement = Arrangement.Center) {
...
}
}
class DetailsViewModel #Inject constructor(
private val destinationsRepository: DestinationsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val cityName = savedStateHandle.get<String>(KEY_ARG_DETAILS_CITY_NAME)!!
val cityDetails: Result<ExploreModel>
get() {
val destination = destinationsRepository.getDestination(cityName)
return if (destination != null) {
Result.Success(destination)
} else {
Result.Error(IllegalArgumentException("City doesn't exist"))
}
}
}
sealed class Result<out R> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
}
The below line produces the initial State with isLoading true.
val uiState by produceState(initialValue = DetailsUiState(isLoading = true))
This is the initial state with isLoading true.
DetailsUiState(
cityDetails =null,
isLoading = true,
throwError = false
)
So initially It will show a CircularProgressBar.
Then the following code block will be executed.
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
And either it will get data or produce an error.
If it is a success then the state will be like
DetailsUiState(
cityDetails = cityDetailsResult.data,
isLoading = false,
throwError = false
)
or Error
DetailsUiState(
cityDetails = null,
isLoading = false,
throwError = true
)
In both success or error cases, isLoading is false so there will be no CircularProgressBar. Only the initial state will be with isLoading true. So initially, it will show a CircularProgressBar.
For example it working with state{} within composable function.
#Composable
fun CounterButton(text: String) {
val count = state { 0 }
Button(
modifier = Modifier.padding(8.dp),
onClick = { count.value++ }) {
Text(text = "$text ${count.value}")
}
}
But how to update value outside of composable function?
I found a mutableStateOf() function and MutableState interface which can be created outside composable function, but no effect after value update.
Not working example of my attempts:
private val timerState = mutableStateOf("no value", StructurallyEqual)
#Composable
private fun TimerUi() {
Button(onClick = presenter::onTimerButtonClick) {
Text(text = "Timer Button")
}
Text(text = timerState.value)
}
override fun updateTime(time: String) {
timerState.value = time
}
Compose version is 0.1.0-dev14
You can do it by exposing the state through a parameter of the controlled composable function and instantiate it externally from the controlling composable.
Instead of:
#Composable
fun CounterButton(text: String) {
val count = remember { mutableStateOf(0) }
//....
}
You can do something like:
#Composable
fun screen() {
val counter = remember { mutableStateOf(0) }
Column {
CounterButton(
text = "Click count:",
count = counter.value,
updateCount = { newCount ->
counter.value = newCount
}
)
}
}
#Composable
fun CounterButton(text: String, count: Int, updateCount: (Int) -> Unit) {
Button(onClick = { updateCount(count+1) }) {
Text(text = "$text ${count}")
}
}
In this way you can make internal state controllable by the function that called it.
It is called state hoisting.