Update Jetpack Compose list with scroll position reset - android

In my app I have list of items shown with LazyColumn. My list can update fields and sometime reorder items. Whenever I have reorder of items I need to show list with first item at the top. I came up with setup like the one below. List scrolls to the top only on the first reorder event. On consecutive list update with reorder = true, my list not scrolling to the top. What is the better approach in Compose to reset list scroll position (or even force rebuilding compose list) by sending state from ViewModel?
class ViewItemsState(val items: List<ViewItem>, val scrollToTop: Boolean)
class ExampleViewModel() : ViewModel() {
var itemsLiveData = MutableLiveData<ViewItemsState>()
}
#Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {
val itemState by viewModel.itemsLiveData.observeAsState()
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
if (itemState.scrollToTop) {
LaunchedEffect(coroutineScope) {
Log.e("TAG", "TopCoinsScreen: scrollToTop" )
listState.scrollToItem(0)
}
}
LazyColumn(state = listState) {
items(itemState.items) { item ->
ItemCompose(
item.name,
item.value
)
}
}
}

Recomposition is triggered only when a state changes.
Two things to fix in this,
Reset scrollToTop to false once scrolling completes.
Store scrollToTop in the view model as a MutableState, LiveData, Flow, or any other reactive element.
Currently scrollToTop is stored as a boolean in a data class object. Resetting the value will not trigger a Composable recomposition.
Example code with sample data,
class ViewItemsState(
val items: List<String>,
)
class ExampleViewModel : ViewModel() {
private var _itemsLiveData =
MutableLiveData(
ViewItemsState(
items = Array(20) {
it.toString()
}.toList(),
)
)
val itemsLiveData: LiveData<ViewItemsState>
get() = _itemsLiveData
private var _scrollToTop = MutableLiveData(false)
val scrollToTop: LiveData<Boolean>
get() = _scrollToTop
fun updateScrollToTop(scroll: Boolean) {
_scrollToTop.postValue(scroll)
}
}
#Composable
fun ExampleScreen(
viewModel: ExampleViewModel = ExampleViewModel(),
) {
val itemState by viewModel.itemsLiveData.observeAsState()
val scrollToTop by viewModel.scrollToTop.observeAsState()
val listState = rememberLazyListState()
LaunchedEffect(
key1 = scrollToTop,
) {
if (scrollToTop == true) {
listState.scrollToItem(0)
viewModel.updateScrollToTop(false)
}
}
Column {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
) {
items(itemState?.items.orEmpty()) { item ->
Text(
text = item,
modifier = Modifier.padding(16.dp),
)
}
}
Button(
onClick = {
viewModel.updateScrollToTop(true)
},
) {
Text(text = "Scroll")
}
}
}

Related

Android compose how to paging with Columns inside HorizontalPager?

I want paginated with paging3 libaray in jetpack compose.
I can use the paging3 library on one column. but, i want to use it in multiple columns.
Like LazyColumn in HorizontalPager.
The model used for paging is the same.
ex.
Usermodel
data class UserModel(
val name:String,
val grade:Int
)
ViewModel
val userItemPager = _page.flatMapLatest {
Pager(PagingConfig(pageSize = 20)) {
UserItemPagingSource(repoSearchImpl, it)
}.flow
}.cachedIn(viewModelScope)
PagingSource
class UserItemPagingSource(
private val repo: RepoSearch,
private val page:Int
) : PagingSource<Int, UserModel>() {
private val STARTING_KEY: Int = 0
override fun getRefreshKey(state: PagingState<Int, UserModel>): Int {
return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2).coerceAtLeast(0)
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UserModel> {
val position = params.key ?: 0
val response = repo.load(position)
val realItem = response.filter { it.grade == page }
return try {
LoadResult.Page(
data = realItem ,
prevKey = null,
nextKey = position + realItem.size
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
HorizontalPager
val userPagingItems= viewModel.userItemPager.collectAsLazyPagingItems()
HorizontalPager(
count = Grade.values().size,
state = pagerState,
modifier = Modifier.fillMaxWidth()
) { page ->
val reaItems = remember(page) {
derivedStateOf {
userPagingItems.itemSnapshotList.filter { it?.grade == page }
}
}
GridPage(
items = reaItems,
page = page,
)
}
Inside columns
#Composable
fun GridPage(
items : State<List<UserModel?>>,
page: Int,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
) {
items(items.value) { model ->
model?.let {
Text(text = "${model.name,model.grade}")
}
}
}
}
Divide tabs according to userModel grade(1,2,3 -> total three tap).
I want to do paging differently according to the grade.
The above code separates the tabs, but does not load.
another code
#Composable
fun GridPage(
items : LazyPagingItems<UserModel>,
page: Int,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
) {
items(items.count) { index ->
val model = items[index]
model?.let {
Text(text = "${model.name,model.grade}")
}
}
}
}
This code loads when I scroll, but all the grades are displayed in one tab, and I don't know how to divide the tabs according to the grades.
How can I classify and paginate according to tabs?

Empty list message still shown despite circular progress bar implementation with LazyColumn

I have a LazyColumn that displays a list of shopping list items retrieved from the database in the ViewModel. If the retrieved list of items is empty, the LazyColumn shows the following message: "You don't have any items in this shopping list." The problem is that this message displays briefly for 1 second before the items are displayed. To solve the problem I implemented a circular progress bar while the items are being retrieved, but it does not even appear, and the message is still displayed. How can I fix this?
ViewModel
#HiltViewModel
class ShoppingListScreenViewModel #Inject constructor(
private val getAllShoppingListItemsUseCase: GetAllShoppingListItemsUseCase
) {
private val _shoppingListItemsState = mutableStateOf<Flow<PagingData<ShoppingListItem>>?>(null)
val shoppingListItemsState: State<Flow<PagingData<ShoppingListItem>>?> get() = _shoppingListItemsState
val loading = mutableStateOf(false)
init {
loading.value = true
getAllShoppingListItemsFromDb()
}
private fun getAllShoppingListItemsFromDb() {
viewModelScope.launch {
_shoppingListItemsState.value = getAllShoppingListItemsUseCase().distinctUntilChanged()
loading.value = false
}
}
}
ShoppingListScreen Composable
fun ShoppingListScreen(
navController: NavHostController,
shoppingListScreenViewModel: ShoppingListScreenViewModel,
sharedViewModel: SharedViewModel
) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val allItems = shoppingListScreenViewModel.shoppingListItemsState.value?.collectAsLazyPagingItems()
val showProgressBar = shoppingListScreenViewModel.loading.value
Scaffold(
topBar = {
CustomAppBar(
title = "Shopping List Screen",
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 {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(screenHeight)
) {
item {
if (allItems != null && allItems.itemCount == 0) {
Text("You don't have any items in this shopping list.")
}
}
items(
items = allItems!!,
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))) }
}
ConditionalCircularProgressBar(isDisplayed = showProgressBar)
}
}
}
Quick solution:
val showProgressBar = shoppingListScreenViewModel.loading.collectAsState() //Use collectAsState, with Flow state in the ViewModel
and add this condition to the "You don't have any items..." if like so:
if (allItems != null && allItems.itemCount == 0 && !showProgressBar) { ... }
The better solution would be to implement this with sealed class(es), where you would return different class for different state (e.g. Loading, Error, Empty, Data). And on the UI side, you just need to when over the possible types of data. Here you can find a perfect example.

Dynamic Text Input does not trigger recomposition

I'm trying to build a custom form using jetpack compose.
What I did so far in the Screen :
#Composable
fun FormContent(
viewModel: FormViewModel,
customFieldList: List<String>,
valuesCustomFieldsList: List<String>
) {
Column(Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.weight(1f)
.padding(top = 8.dp, bottom = 8.dp)
) {
if (customFieldList.isNotEmpty()) {
customFieldList.forEachIndexed { index, item ->
TextRow(
title = item,
placeholder = "Insert $item",
value = valuesCustomFieldsList[index],
onValueChange = {
viewModel.onCustomFieldChange(index, it)
},
isError = false
)
}
}
}
}
Where customFieldList is the list of textFields i want and valuesCustomFieldsList is
val valuesCustomFields by viewModel.values.collectAsState()
In my viewModel the code is as it follows:
private val _values = MutableStateFlow(emptyList<String>())
val values : StateFlow<List<String>>
get() = _values
private var customFieldValues = mutableListOf<String>()
init {
viewModelScope.launch {
//get all custom fields
customFields = gmeRepository.getCustomField()
if (customFields.isNotEmpty()) {
//init the customFieldsValues
for (i in 0..customFields.size) {
customFieldValues.add("")
}
_values.value = customFieldValues
}
}
}
fun onCustomFieldChange(index : Int, value: String) {
customFieldValues[index] = value
_values.value = customFieldValues
}
What happens here is that if I try to change the value inside the inputText, onCustomFieldChange is correctly triggered with the first letter I wrote inside, but then no change is visible in the UI.
If I try to change another static field inside the form, then only the last char i wrote inside my custom fields are shown cause a recomposition is triggered.
Is there something I can do to achieve my goal?

Android Compose LazyList - keep scrolling position when new items are added to the top of the list

Here's the original list before I add any new item
When I add a new item to the end of the list, without scrolling, the first item still remains in that position
But when I add new items to the start of the list, all the old items is moved down. Notice that New item 0 is not at the very top of the list anymore
How can I make it so that if I add a new item to the start of the list, the old item still remain in the exact position. And these new items will only be visible when the user scrolls up, not pushing everything down. If I remember correctly, it's the default behavior with RecyclerView
Here's my code:
MainActivity.kt
class MainActivity : ComponentActivity() {
private val list = MutableStateFlow<List<String>>(emptyList())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
EmptyComposeTheme {
val items = list.collectAsState()
Box(Modifier.fillMaxSize()) {
LazyColumn(
Modifier.fillMaxSize(),
) {
itemsIndexed(items.value) { index: Int, item: String ->
Text(item, color = MaterialTheme.colors.onSurface)
}
}
FloatingActionButton(onClick = {
val oldList = list.value.toMutableList()
oldList.add("New item ${oldList.size}")
list.value = oldList
}, Modifier.align(Alignment.BottomStart)) {
Text("Click me!")
}
FloatingActionButton(onClick = {
val oldList = list.value.toMutableList()
oldList.add(0, "New item reversed ${oldList.size}")
list.value = oldList
}, Modifier.align(Alignment.BottomEnd)) {
Text("Click other me!")
}
}
}
}
}
}
I'm using Compose version 1.0.1
LazyComun with itemsIndexedand index in the key param solved my problem.
LazyColumn(
state = listState
) {
itemsIndexed(list, key = {index, item->
index + item.hashcode() // Including index in the key param solved my problem
}) {
....
}
}
Basically you aim at controlling the scroll position as described in some more detail here
While taking your code as a basis, you could hence do the following:
val items = list.collectAsState()
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
Box(Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
...
}
...
FloatingActionButton(onClick = {
val currentIndex = listState.firstVisibleItemIndex + 1
val oldList = list.value.toMutableList()
oldList.add(0, "New item reversed ${oldList.size}")
list.value = oldList
coroutineScope.launch {
listState.scrollToItem(currentIndex)
}
}, Modifier.align(Alignment.BottomEnd)) {
Text("Click other me!")
}
}
u can add key param to LazyColumn like this, but if ur firstVisibleIndex = 0, then problem not solved, i do not know why its:
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
key = { item -> item.id} // or any unique value
) {
...
}

Get last visible item index in jetpack compose LazyColumn

I want to check if the list is scrolled to end of the list. How ever the lazyListState does not provide this property
Why do I need this? I want to show a FAB for "scrolling to end" of the list, and hide it if last item is already visible
(Note: It does, but it's internal
/**
* Non-observable way of getting the last visible item index.
*/
internal var lastVisibleItemIndexNonObservable: DataIndex = DataIndex(0)
no idea why)
val state = rememberLazyListState()
LazyColumn(
state = state,
modifier = modifier.fillMaxSize()
) {
// if(state.lastVisibleItem == logs.length - 1) ...
items(logs) { log ->
if (log.level in viewModel.getShownLogs()) {
LogItemScreen(log = log)
}
}
}
So, how can I check if my LazyColumn is scrolled to end of the dataset?
Here is a way for you to implement it:
Extension function to check if it is scrolled to the end:
fun LazyListState.isScrolledToTheEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
Example usage:
val listState = rememberLazyListState()
val listItems = (0..25).map { "Item$it" }
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(listItems) { item ->
Text(text = item, modifier = Modifier.padding(16.dp))
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) {
if (!listState.isScrolledToTheEnd()) {
ExtendedFloatingActionButton(
modifier = Modifier.padding(16.dp),
text = { Text(text = "Go to Bottom") },
onClick = { /* Scroll to the end */}
)
}
}
I am sharing my solution in case it helps anyone.
It provides the info needed to implement the use case of the question and also avoids infinite recompositions by following the recommendation of https://developer.android.com/jetpack/compose/lists#control-scroll-position.
Create these extension functions to calculate the info needed from the list state:
val LazyListState.isLastItemVisible: Boolean
get() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
val LazyListState.isFirstItemVisible: Boolean
get() = firstVisibleItemIndex == 0
Create a simple data class to hold the information to collect:
data class ScrollContext(
val isTop: Boolean,
val isBottom: Boolean,
)
Create this remember composable to return the previous data class.
#Composable
fun rememberScrollContext(listState: LazyListState): ScrollContext {
val scrollContext by remember {
derivedStateOf {
ScrollContext(
isTop = listState.isFirstItemVisible,
isBottom = listState.isLastItemVisible
)
}
}
return scrollContext
}
Note that a derived state is used to avoid recompositions and improve performance.
The function needs the list state to make the calculations inside the derived state. Read the link I shared above.
Glue everything in your composable:
#Composable
fun CharactersList(
state: CharactersState,
loadNewPage: (offset: Int) -> Unit
) {
// Important to remember the state, we need it
val listState = rememberLazyListState()
Box {
LazyColumn(
state = listState,
) {
items(state.characters) { item ->
CharacterItem(item)
}
}
// We use our remember composable to get the scroll context
val scrollContext = rememberScrollContext(listState)
// We can do what we need, such as loading more items...
if (scrollContext.isBottom) {
loadNewPage(state.characters.size)
}
// ...or showing other elements like a text
AnimatedVisibility(scrollContext.isBottom) {
Text("You are in the bottom of the list")
}
// ...or a button to scroll up
AnimatedVisibility(!scrollContext.isTop) {
val coroutineScope = rememberCoroutineScope()
Button(
onClick = {
coroutineScope.launch {
// Animate scroll to the first item
listState.animateScrollToItem(index = 0)
}
},
) {
Icon(Icons.Rounded.ArrowUpward, contentDescription = "Go to top")
}
}
}
}
Cheers!
Starting from 1.4.0-alpha03 you can use LazyListState#canScrollForward to check if you can scroll forward or if you are at the end of the list.
val state = rememberLazyListState()
if (!state.canScrollForward){ /* ... */ }
Before you can use
the LazyListState#layoutInfo that contains information about the visible items. You can use it to retrieve information if the list is scrolled at the bottom.
Since you are reading the state you should use derivedStateOf to avoid redundant recompositions.
Something like:
val state = rememberLazyListState()
val isAtBottom by remember {
derivedStateOf {
val layoutInfo = state.layoutInfo
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (layoutInfo.totalItemsCount == 0) {
false
} else {
val lastVisibleItem = visibleItemsInfo.last()
val viewportHeight = layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset
(lastVisibleItem.index + 1 == layoutInfo.totalItemsCount &&
lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight)
}
}
}
It's too late, but maybe it would be helpful to others.
seeing the above answers, The layoutInfo.visibleItemsInfo.lastIndex will cause recomposition many times, because it is composed of state.
So I recommend to use this statement like below with derivedState and itemKey in item(key = "lastIndexKey").
val isFirstItemFullyVisible = remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
val isLastItemFullyVisible by remember {
derivedStateOf {
listState.layoutInfo
.visibleItemsInfo
.any { it.key == lastIndexKey }.let { _isLastIndexVisible ->
if(_isLastIndexVisible){
val layoutInfo = listState.layoutInfo
val lastItemInfo = layoutInfo.visibleItemsInfo.lastOrNull() ?: return#let false
return#let lastItemInfo.size+lastItemInfo.offset == layoutInfo.viewportEndOffset
}else{
return#let false
}
}
}
}
if (isFirstItemFullyVisible.value || isLastItemFullyVisible) {
// TODO
}
Current solution that I have found is:
LazyColumn(
state = state,
modifier = modifier.fillMaxSize()
) {
if ((logs.size - 1) - state.firstVisibleItemIndex == state.layoutInfo.visibleItemsInfo.size - 1) {
println("Last visible item is actually the last item")
// do something
}
items(logs) { log ->
if (log.level in viewModel.getShownLogs()) {
LogItemScreen(log = log)
}
}
}
The statement
lastDataIndex - state.firstVisibleItemIndex == state.layoutInfo.visibleItemsInfo.size - 1
guesses the last item by subtracting last index of dataset from first visible item and checking if it's equal to visible item count
Just wanted to build upon some of the other answers posted here.
#Tuan Chau mentioned in a comment that this will cause infinite compositions, here is something I tried using his idea to avoid this, and it seems to work ok. Open to ideas on how to make it better!
#Composable
fun InfiniteLoadingList(
modifier: Modifier,
items: List<Any>,
loadMore: () -> Unit,
rowContent: #Composable (Int, Any) -> Unit
) {
val listState = rememberLazyListState()
val firstVisibleIndex = remember { mutableStateOf(listState.firstVisibleItemIndex) }
LazyColumn(state = listState, modifier = modifier) {
itemsIndexed(items) { index, item ->
rowContent(index, item)
}
}
if (listState.shouldLoadMore(firstVisibleIndex)) {
loadMore()
}
}
Extension function:
fun LazyListState.shouldLoadMore(rememberedIndex: MutableState<Int>): Boolean {
val firstVisibleIndex = this.firstVisibleItemIndex
if (rememberedIndex.value != firstVisibleIndex) {
rememberedIndex.value = firstVisibleIndex
return layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
}
return false
}
Usage:
InfiniteLoadingList(
modifier = modifier,
items = listOfYourModel,
loadMore = { viewModel.populateMoreItems() },
) { index, item ->
val item = item as YourModel
// decorate your row
}
Try this:
val lazyColumnState = rememberLazyListState()
val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.lastIndex + state.firstVisibleItemIndex

Categories

Resources