Scroll to top when adding new items - android

I have a usecase where I would like a LazyColumn to scroll to the top if a new item is added to the start of the list - but only if the list was scrolled to top before. I'm currently using keys for bookkeeping the scroll position automatically, which is great for all other cases than when the scroll state is at the top of the list.
This is similar to the actual logic I have in the app (in my case there is however no button, showFirstItem is a parameter to the composable function, controlled by some other logic):
var showFirstItem by remember { mutableStateOf(true) }
Column {
Button(onClick = { showFirstItem = !showFirstItem }) {
Text("${if (showFirstItem) "Hide" else "Show"} first item")
}
LazyColumn {
if (showFirstItem) {
item(key = "first") {
Text("First item")
}
}
items(count = 100,
key = { index ->
"item-$index"
}
) { index ->
Text("Item $index")
}
}
}
As an example, I would expect "First item" to be visible if I scroll to top, hide the item and them show it again. Or hide the item, scroll to top and then show it again.
I think the solution could be something with LaunchedEffect, but I'm not sure at all.

If a new item has been added and user is at top, the new item would not appear unless the list is scrolled to the top. I have tried this:
if (lazyListState.firstVisibleItemIndex <= 1) {
//scroll to top to ensure latest added book gets visible
LaunchedEffect(key1 = key) {
lazyListState.scrollToItem(0)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState
) {...
And it seems to work. But it breaks the pull to refresh component that I am using. So not sure how to solve that. I am still trying to force myself to like Compose. Hopefully that day will come =)

You can scroll to the top of the list on a LazyColumn like this:
val coroutineScope = rememberCoroutineScope()
...
Button(onClick = {
coroutineScope.launch {
// 0 is the first item index
scrollState.animateScrollToItem(0)
}
}) {
Text("Scroll to the top")
}
So call scrollState.animateScrollToItem(0) where ever you need from a coroutine, e.g. after adding a new item.

In your item adding logic,
scrollState.animateScrollToItem(0)
//Add an optional delay here
showFirst = ... //Handle here whatever you want
Here, scrollState is to be passed in the LazyColumn
val scrollState = rememberScrollState() LazyColumn(state = scrollState) { ... }

Related

Jetpack Compose: LazyRow onScrollListener

I was wondering if there is a way or a resource I could refer to in order to achieve a side effect on a LazyRow when an item is scrolled?
The side effect is basically to call a function in the viewModel to alter the state of the list's state.
The side effect should be only executed only if the current firstVisibleItemIndex after
scroll is different than before
The side effect should not be executed the item is not fully scrolled
I am implementing a fullscreen LazyRow items with a snap behavior
So far I have tried NestedScrollConnection
class OnMoodItemScrolled : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
viewModel.fetchItems()
return super.onPostFling(consumed, available)
}
}
The issue with the above is that the side effect is going to be executed anyway even-though the item displayed after the scroll is the same as before the scroll.
I also tried to collecting the listState interaction as the following
val firstVisibleItem: Int = remember { sectionItemListState.firstVisibleItemIndex }
sectionItemListState.interactionSource.collectIsDraggedAsState().let {
if (firstVisibleItem != sectionItemListState.firstVisibleItemIndex) {
viewModel.fetchItems()
}
}
The issue with the above is that the side effect is going to be executed the second the composable is composed for the first time.
You can use the LazyListState#firstVisibleItemIndex to get the information about the first visible item and store this value. When the value changes the item is scrolled up.
Something like:
#Composable
private fun LazyListState.itemIndexScrolledUp(): Int {
var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) }
return remember(this) {
derivedStateOf {
if (firstVisibleItemIndex > previousIndex) {
//scrolling up
previousIndex
} else {
- 1
}.also {
//Update the previous index
previousIndex = firstVisibleItemIndex
}
}
}.value
}
and then:
val state = rememberLazyListState()
var index = state.itemIndexScrolledUp()
DisposableEffect(index){
if (index != -1) {
//...item is scrolled up
}
onDispose { }
}
LazyColumn(
state = state,
){
//...
}
I solved my issue using a LaunchedEffect with 2 keys.
val sectionItemListState = rememberLazyListState()
val flingBehavior = rememberSnapFlingBehavior(sectionItemListState)
var previousVisibleItemIndex by rememberSaveable {
mutableStateOf(0)
}
val currentVisibleItemIndex: Int by remember {
derivedStateOf { sectionItemListState.firstVisibleItemIndex }
}
val currentVisibleItemScrollOffset: Int by remember {
derivedStateOf { sectionItemListState.firstVisibleItemScrollOffset }
}
LaunchedEffect(currentVisibleItemIndex, currentVisibleItemScrollOffset) {
if (previousVisibleItemIndex != currentVisibleItemIndex && currentVisibleItemScrollOffset == 0) {
// The currentVisible item is different than the previous one && it's fully visible
viewModel.fetchItems()
previousVisibleItemIndex = currentVisibleItemIndex
}
}
Using both currentVisibleItemIndex and currentVisibleItemScrollOffset as keys will make sure that the LaunchedEffect will be triggered whenever one of them changes. Moreover, checking if the previousVisibleItemIndex is different than the currentVisibleItemIndex will ensure that we only trigger this effect only if the visible item is changing.
However, this condition will true also if the use has partially scrolled and since I have a snapping effect it will go back to the previous position. Which will result in triggering the effect twice.
In order to make sure that we only trigger the effect only in case were we actually scrolled to the next/previous fully visible position we need to rely on the scrollOffset.

Avoid initial scrolling when using Jetpack Compose ScrollableTabRow

I'm using a ScrollableTabRow to display some 60 Tabs.
At the very beginning, the indicator should start "in the middle".
However, this results in an unwanted scrolling animation when the composable is drawn - see video. Am i doing something wrong or is this component buggy?
#Composable
#Preview
fun MinimalTabExample() {
val tabCount = 60
var selectedTabIndex by remember { mutableStateOf(tabCount / 2) }
ScrollableTabRow(selectedTabIndex = selectedTabIndex) {
repeat(tabCount) { tabNumber ->
Tab(
selected = selectedTabIndex == tabNumber,
onClick = { selectedTabIndex = tabNumber },
text = { Text(text = "Tab #$tabNumber") }
)
}
}
}
But why would you like to do that?
I'm writing a calendar-like application and have a day-detail-view.
From there want a fast way to navigate to adjacent days. A Month into the future and a month into the past - relative to the selected month - is what i'm aiming for.
No, you are not doing it wrong. Also the component is not really buggy, rather the behaviour you are seeing is an implementation detail.
If we check the implementation of the ScrollableTabRow composable we see that the selectedTabIndex is used in two places inside the composable:
inside the default indicator implementation
as an input parameter for the scrollableTableData.onLaidOut call
The #1 is used for positioning the tabs inside the layout, so it is not interesting for this issue.
The #2 is used to scroll to the selected tab index.
The code below shows how the initial scroll state is set up, followed by the call to scrollableTabData.onLaidOut
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val scrollableTabData = remember(scrollState, coroutineScope) {
ScrollableTabData(
scrollState = scrollState,
coroutineScope = coroutineScope
)
}
// ...
scrollableTabData.onLaidOut(
density = this#SubcomposeLayout,
edgeOffset = padding,
tabPositions = tabPositions,
selectedTab = selectedTabIndex // <-- selectedTabIndex is passed here
)
And this is the implementation of the above call
fun onLaidOut(
density: Density,
edgeOffset: Int,
tabPositions: List<TabPosition>,
selectedTab: Int
) {
// Animate if the new tab is different from the old tab, or this is called for the first
// time (i.e selectedTab is `null`).
if (this.selectedTab != selectedTab) {
this.selectedTab = selectedTab
tabPositions.getOrNull(selectedTab)?.let {
// Scrolls to the tab with [tabPosition], trying to place it in the center of the
// screen or as close to the center as possible.
val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
if (scrollState.value != calculatedOffset) {
coroutineScope.launch {
scrollState.animateScrollTo( // <-- even the initial scroll is done using an animation
calculatedOffset,
animationSpec = ScrollableTabRowScrollSpec
)
}
}
}
}
}
As we can see already from the first comment
Animate if the new tab is different from the old tab, or this is called for the first time
but also in the implementation, even the first time the scroll offset is set using an animation.
coroutineScope.launch {
scrollState.animateScrollTo(
calculatedOffset,
animationSpec = ScrollableTabRowScrollSpec
)
}
And the ScrollableTabRow class does not expose a way to control this behaviour.

Material Swipe To Dismiss in Compose maks incorrect items for dismissal

I'm implementing drag/swipe to dismiss functionality in a simple notepad app implemented in Compose. I've run into a strange issue where SwipeToDismiss() in a LazyColumn dismisses not only the selected item but those after it as well.
Am I doing something wrong or is this a bug with SwipeToDismiss()? (I'm aware that it's marked ExperimentalMaterialApi)
I've used the Google recommended approach from here:
https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary#swipetodismiss
this is where it happens:
/* ...more code... */
LazyColumn {
items(items = results) { result ->
Card {
val dismissState = rememberDismissState()
//for some reason the dismmissState is EndToStart for all the
//items after the deleted item, even adding new items becomes impossible
if (dismissState.isDismissed(EndToStart)) {
val scope = rememberCoroutineScope()
scope.launch {
dismissed(result)
}
}
SwipeToDismiss(
state = dismissState,
modifier = Modifier.padding(vertical = 4.dp),
/* ...more code... */
and here is my project with the file in question
https://github.com/davida5/ComposeNotepad/blob/main/app/src/main/java/com/anotherday/day17/ui/NotesList.kt
You need to provide key for the LazyColumn's items.
By default, each item's state is keyed against the position of the
item in the list. However, this can cause issues if the data set
changes, since items which change position effectively lose any
remembered state.
Example
LazyColumn {
items(
items = stateList,
key = { _, listItem ->
listItem.hashCode()
},
) { item ->
// As it is ...
}
}
Reference

How to save paging state of LazyColumn during navigation in Jetpack Compose

I'm using androidx.paging:paging-compose (v1.0.0-alpha-14), together with Jetpack Compose (v1.0.3), I have a custom PagingSource which is responsible for pulling items from backend.
I also use compose navigation component.
The problem is I don't know how to save a state of Pager flow between navigating to different screen via NavHostController and going back (scroll state and cached items).
I was trying to save state via rememberSaveable but it cannot be done as it is not something which can be putted to Bundle.
Is there a quick/easy step to do it?
My sample code:
#Composable
fun SampleScreen(
composeNavController: NavHostController? = null,
myPagingSource: PagingSource<Int, MyItem>,
) {
val pager = remember { // rememberSaveable doesn't seems to work here
Pager(
config = PagingConfig(
pageSize = 25,
),
initialKey = 0,
pagingSourceFactory = myPagingSource
)
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn() {
itemsIndexed(items = lazyPagingItems) { index, item ->
MyRowItem(item) {
composeNavController?.navigate(...)
}
}
}
}
I found a solution!
#Composable
fun Sample(data: Flow<PagingData<Something>>):
val listState: LazyListState = rememberLazyListState()
val items: LazyPagingItems<Something> = data.collectAsLazyPagingItems()
when {
items.itemCount == 0 -> LoadingScreen()
else -> {
LazyColumn(state = listState, ...) {
...
}
}
}
...
I just found out what the issue is when using Paging.
The reason the list scroll position is not remembered with Paging when navigating boils down to what happens below the hood.
It looks like this:
Composable with LazyColumn is created.
We asynchronously request our list data from the pager. Current pager list item count = 0.
The UI draws a lazyColumn with 0 items.
The pager responds with data, e.g. 10 items, and the UI is recomposed to show them.
User scrolls e.g. all the way down and clicks the bottom item, which navigates them elsewhere.
User navigates back using e.g. the back button.
Uh oh. Due to navigation, our composable with LazyColumn is recomposed. We start again with asynchronously requesting pager data. Note: pager item count = 0 again!
rememberLazyListState is evaluated, and it tells the UI that the user scrolled down all the way, so it now should go back to the same offset, e.g. to the fifth item.
This is the point where the UI screams in wild confusion, as the pager has 0 items, so the lazyColumn has 0 items.
The UI cannot handle the scroll offset to the fifth item. The scroll position is set to just show from item 0, as there are only 0 items.
What happens next:
The pager responds that there are e.g. 10 items again, causing another recomposition.
After recomposition, we see our list again, with scroll position starting on item 0.
To confirm this is the case with your code, add a simple log statement just above the LazyColumn call:
Log.w("TEST", "List state recompose. " +
"first_visible=${listState.firstVisibleItemIndex}, " +
"offset=${listState.firstVisibleItemScrollOffset}, " +
"amount items=${items.itemCount}")
You should see, upon navigating back, a log line stating the exact same first_visible and offset, but with amount items=0.
The line directly after that will show that first_visible and offset are reset to 0.
My solution works, because it skips using the listState until the pager has loaded the data.
Once loaded, the correct values still reside in the listState, and the scroll position is correctly restored.
Source: https://issuetracker.google.com/issues/177245496
Save the list state in your viewmodel and reload it when you navigate back to the screen containing the list. You can use LazyListState in your viewmodel to save the state and pass that into your composable as a parameter. Something like this:
class MyViewModel: ViewModel() {
var listState = LazyListState()
}
#Composable
fun MessageListHandler() {
MessageList(
messages: viewmodel.messages,
listState = viewmode.listState
)
}
#Composable
fun MessageList(
messages: List<Message>,
listState: LazyListState) {
LazyColumn(state = listState) {
}
}
If you don't like the limitations that Navigation Compose puts on you, you can try using Jetmagic. It allows you to pass any object between screens and even manages your viewmodels in a way that makes them easier to access from any composable:
https://github.com/JohannBlake/Jetmagic
The issue is that when you navigate forward and back your composable will recompose and collectAsLazyPagingItems() will be called again, triggering a new network request.
If you want to avoid this issue, you should call pager.flow.cacheIn(viewModelScope) on your ViewModel with activity scope (the ViewModel instance is kept across fragments) before calling collectAsLazyPagingItems().
LazyPagingItems is not intended as a persistent data store; it is just a simple wrapper for the UI layer. Pager data should be cached in the ViewModel.
please try using '.cachedIn(viewModelScope) '
simple example:
#Composable
fun Simple() {
val simpleViewModel:SimpleViewModel = viewModel()
val list = simpleViewModel.simpleList.collectAsLazyPagingItems()
when (list.loadState.refresh) {
is LoadState.Error -> {
//..
}
is LoadState.Loading -> {
BoxProgress()
}
is LoadState.NotLoading -> {
when (list.itemCount) {
0 -> {
//..
}
else -> {
LazyColumn(){
items(list) { b ->
//..
}
}
}
}
}
}
//..
}
class SimpleViewModel : ViewModel() {
val simpleList = Pager(
PagingConfig(PAGE_SIZE),
pagingSourceFactory = { SimpleSource() }).flow.cachedIn(viewModelScope)
}

What is the equivalent of [NestedScrollView + RecyclerView] or [Nested RecyclerView (Recycler inside another recycler) in Jetpack compose

I want to create the following layout in Jetpack compose.
I've tried creating two lists inside a vertical scrollable Box but that's not possible as I got the this error:
"java.lang.IllegalStateException: Nesting scrollable in the same direction layouts like ScrollableContainer and LazyColumn is not allowed. If you want to add a header before the list of items please take a look on LazyColumn component which has a DSL api which allows to first add a header via item() function and then the list of items via items()."
I've tried creating two different lists inside a parent list by using the following code, but that doesn't work either.
#Composable
fun MainList() {
LazyColumn() {
item {
/* LazyRow code here */
}
item {
/* LazyColumn code here */
}
}
}
Now I'm clueless about what else could I try to achieve two lists (one vertical and one horizontal) on the same activity and keep the activity vertically scrollable too.
I think the best option, would be if the LazyVerticalGrid allows some sort of expand logic on each item, but looks like it's not supported yet (beta-03).
So I'm leaving here my solution using one single LazyColumn for the entire list and LazyRow for "My Books" section.
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
// My Books section
item {
Column(modifier = Modifier.fillMaxWidth()) {
Text("My Books")
LazyRow {
items(books) { item ->
// Each Item
}
}
}
}
// Whishlisted Books title
item {
Text("Whishlisted Books", style = MaterialTheme.typography.h4)
}
// Turning the list in a list of lists of two elements each
items(wishlisted.windowed(2, 2, true)) { item ->
Row {
// Draw item[0]
// Draw item[1]
}
}
}
Here is my gist with the full solution and the result is listed below.
You can do something like:
Column(Modifier.fillMaxWidth()) {
LazyRow() {
items(itemsList){
//.....
}
}
LazyColumn() {
items(itemsList2){
//..
}
}
}
or:
Column(Modifier.fillMaxWidth()) {
LazyRow() {
items(itemsList){
//....
}
}
LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(itemsList2.size){
//....
}
}
}
An alternative equivalent of nested RecyclerViews is nested LazyColumns, where the heights of the inner LazyColumns are specified or constant, and the inner LazyColumns are placed inside item {} blocks.
Unlike the accepted answer, this approach relies on the .height() modifier to avoid the "java.lang.IllegalStateException: Nesting scrollable in the same direction layouts like ScrollableContainer and LazyColumn is not allowed... " error. Also, this approach addresses the scenario of nested scrolling in the same direction.
Here is an example code and output.
#Composable
fun NestedLists() {
LazyColumn(Modifier.fillMaxSize().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
//Header for first inner list
item {
Text(text = "List of numbers:", style = MaterialTheme.typography.h5)
}
// First, scrollable, inner list
item {
// Note the important height modifier.
LazyColumn(Modifier.height(100.dp)){
val numbersList = (0 .. 400 step 4).toList()
itemsIndexed(numbersList) { index, multipleOf4 ->
Text(text = "$multipleOf4", style = TextStyle(fontSize = 22.sp, color = Color.Blue))
}
}
}
// Header for second inner list
item {
Text(text = "List of letters:", style = MaterialTheme.typography.h5)
}
// Second, scrollable, inner list
item {
// Note the important height modifier.
LazyColumn(Modifier.height(200.dp)) {
val lettersList = ('a' .. 'z').toList()
itemsIndexed(lettersList) { index, letter ->
Text(text = "$letter", style = TextStyle(color = Color.Blue, fontSize = 22.sp))
}
}
}
}
}

Categories

Resources