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
Related
The below code works as desired: the canvas gets recomposed each time the user either clicks the canvas itself or clicks the topBar icon, no matter how many times or in what order. In addition, the state variable value reveals something I want to know: where the user clicked. (Values 0 and 1 mean the icon was clicked and values 2 and 3 mean the canvas).
However, if the canvasState and iconState variables are set to their respective V1 functions instead of the V2 functions, then clicking the canvas or icon multiple times in a row is not detected. Apparently this is because the V1 functions can re-assign the same value to the state variable, unlike the V2 functions.
Since I'm using the neverEqualPolicy(), I thought I didn't have to assign a different value to the state variable to trigger a recompose. As a noob to Kotlin and Compose, what am I misunderstanding?
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
#Composable
fun MyApp() {
var state by remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
val canvasStateV1 = { state = 0 }
val iconStateV1 = { state = 2 }
val canvasStateV2 = { state = if (state == 0) { 1 } else { 0 } }
val iconStateV2 = { state = if (state == 2) { 3 } else { 2 } }
val iconState = iconStateV2
val canvasState = canvasStateV2
Scaffold(
topBar = { TopBar(canvasState) },
content = { padding ->
Column(Modifier.padding(padding)) {
Screen(state, iconState)
}
}
)
}
#Composable
fun TopBar(iconState: () -> Unit) {
TopAppBar(
title = { Text("This is a test") },
actions = {
IconButton(onClick = { iconState() }) {
Icon(Icons.Filled.AddCircle, null)
}
}
)
}
#Composable
fun Screen(state: Int, canvasState: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.aspectRatio(ratio = 1f)
.background(color = MaterialTheme.colors.onSurface)
.pointerInput(Unit) {
detectTapGestures(
onTap = { canvasState() },
)
}
) {
Canvas(
modifier = Modifier.fillMaxSize().clipToBounds()
) {
Log.d("Debug", "Canvas: state = $state")
}
}
}
}
I didn't know other things to try to get the neverEqualPolicy() to work as expected.
I think the main reason for this is because the function Screen() is skippable. If you add the state as a MutableState instead of the Int itself, you will see that the Log.d gets called each time the state value gets updated. Same goes for merging the Screen() function into Column in MyApp
Compose analyses each function during build time. The screen functions receives an integer value, this is an immutable value, so the function itself becomes skippable.
To analyse which function is skippable/stable (and which is not), you can run a report during the build phase
This repo shows how
EDIT:
In this example you have two buttons, one changes the value, one just sets the same value. When setting the same value, you only see the Log.d of the local recomposition. When changing the state value, you see two log lines. the local and external both go through the recomposition.
#Composable
fun StackOverflowApp() {
var state by remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
Column() {
Button(onClick = { state = state }) {
Text(text = "State same value")
}
Button(onClick = { state += 1 }) {
Text(text = "State up")
}
Text(text = "[local] current State = $state")
Log.d("TAG","Recomposition local")
ExternalText(state)
}
}
/**
* A skippable function
*
* restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ExternalText(
stable state: Int
)
*/
#Composable
fun ExternalText(state: Int){
Text(text = "[external] current State = $state")
Log.d("TAG","Recomposition external")
}
You can also pass the MutableState instead of the int value itself, when you pass the mutableState, the neverEqualPolicy is still in play. Each interaction fires both log lines
#Composable
fun StackOverflowApp() {
var state = remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
Column() {
Button(onClick = { state.value = state.value }) {
Text(text = "State same value")
}
Button(onClick = { state.value += 1 }) {
Text(text = "State up")
}
Text(text = "[local] current State = ${state.value}")
Log.d("TAG","Recomposition internal")
ExternalText(state)
}
}
#Composable
fun ExternalText(state: MutableState<Int>){
Text(text = "[external] current State = ${state.value}")
Log.d("TAG","Recomposition external")
}
Looking at the documentation for LazyRow I was wondering if it was possible to reduce the scroll speed, it looks like LazyRow inherits from ScrollState but I can't find anything useful on how to reduce the speed of the scroll
LazyRow doesn't have a parameter to customize scroll speed so you have to do it manually.
You could first capture the scroll gesture something like the below (from google example here ):
#Composable
fun ScrollableSample() {
// actual composable state
var offset by remember { mutableStateOf(0f) }
Box(
Modifier
.size(150.dp)
.scrollable(
orientation = Orientation.Vertical,
// Scrollable state: describes how to consume
// scrolling delta and update offset
state = rememberScrollableState { delta ->
offset += delta
delta
}
)
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
Text(offset.toString())
}
}
Then you have to implement the scroll state at your desired speed for the LazyRow by manually changing the LazyRow's state which is one of the LazyRow parameters.
you will also have to disable LazyRow userscrolling
something like this:
LazyRow(
...
state = yourCustomState,
userScrollEnabled = false,
...
){ ... }
below is a complete working solution:
var stateIsGood = rememberLazyListState()
var offset = 0f
LazyRow(
modifier = Modifier
.scrollable(
orientation = Orientation.Horizontal,
// Scrollable state: describes how to consume
// scrolling delta and update offset
state = rememberScrollableState { delta ->
offset += delta
vm.viewModelScope.launch {
//dividing by 8 the delta for 8 times slower horizontal scroll,
//you can change direction by making it a negative number
stateIsGood.scrollBy(-delta/8)
}
delta
}
),
state = stateIsGood,
userScrollEnabled = false,
) {
item {
Text(text = "Header")
}
// Add 3 items
items(3) { index ->
Text(text = "SCROLL ME First List items : $index")
}
// Add 2 items
items(2) { index ->
Text(text = "Second List Items: $index")
}
// Add another single item
item {
Text(text = "Footer")
}
}
As a shorter alternative to David's Code,
#Composable
fun LazyStack(ssd: Int = 1) { // Scroll-Speed-Divisor
val lazyStackState = rememberLazyListState()
val lazyStackScope = rememberCoroutineScope()
LazyColumn(
modifier = Modifier.pointerInput(Unit) {
detectVerticalDragGestures { _, dragAmount ->
lazyStackScope.launch {
lazyStackState.scrollBy(-dragAmount / ssd).log("checkpoint")
}
}
},
state = lazyStackState,
userScrollEnabled = false
) {...}
}
And yes, LazyStack is short for LazyStackOverflower
instead of prevent userScroll we can use flingBehavior parameter. When we update initialVelocity it should change fling effect.
#Composable
fun maxScrollFlingBehavior(): FlingBehavior {
val flingSpec = rememberSplineBasedDecay<Float>()
return remember(flingSpec) {
ScrollSpeedFlingBehavior(flingSpec)
}
}
private class ScrollSpeedFlingBehavior(
private val flingDecay: DecayAnimationSpec<Float>
) : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
// Prevent very fast scroll
val newVelocity =
if (initialVelocity > 0F) minOf(initialVelocity, 15_000F)
else maxOf(initialVelocity, -15_000F)
return if (abs(newVelocity) > 1f) {
var velocityLeft = newVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = newVelocity,
).animateDecay(flingDecay) {
val delta = value - lastValue
val consumed = scrollBy(delta)
lastValue = value
velocityLeft = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
velocityLeft
} else newVelocity
}
}
and you can use like this
LazyColumn(
modifier = Modifier.fillMaxSize(),
flingBehavior = maxScrollFlingBehavior()
) {
// Content Here
}
If you want to implement your own logic, you can update initialVelocity param. Higher its 'absolute' value, the faster it moves.
I'm trying to implement a carousel component on Android TV with Compose, and I have a problem with fast scrolling using the dpad. NB: I want to keep the focused item as the first displayed item on the screen.
Here is a screen capture:
The first 5 items are scrolled by pressing and releasing the right key after each item. The next 15 items are scrolled by keeping the right key pressed to the end of the list.
The scrolling and focus management work well, but I would like to make it faster. On the screen capture you see that when pressing the right key, the list is scrolled then the next item gets the focus. It is really slow.
Here is the Composable function:
#Composable
private fun CustomLazyRow() {
val scrollState = rememberLazyListState()
LazyRow(
state = scrollState,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(
items = (1..20).toList()
) { index, item ->
var isFocused by remember { mutableStateOf(false) }
Text(
text = "Item $item",
modifier = Modifier
.dpadNavigation(scrollState, index)
.width(156.dp)
.aspectRatio(4 / 3F)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.border(if (isFocused) 4.dp else Dp.Hairline, Color.Black)
)
}
}
}
And the dpadNavigation Modifier function:
fun Modifier.dpadNavigation(
scrollState: LazyListState,
index: Int
) = composed {
val focusManager = LocalFocusManager.current
var focusDirectionToMove by remember { mutableStateOf<FocusDirection?>(null) }
val scope = rememberCoroutineScope()
onKeyEvent {
if (it.type == KeyEventType.KeyDown) {
when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> focusDirectionToMove = FocusDirection.Left
KeyEvent.KEYCODE_DPAD_RIGHT -> focusDirectionToMove = FocusDirection.Right
}
if (focusDirectionToMove != null) {
scope.launch {
if (focusDirectionToMove == FocusDirection.Left && index > 0) {
// This does not work:
// scope.launch { scrollState.animateScrollToItem(index - 1) }
scrollState.animateScrollToItem(index - 1)
focusManager.moveFocus(FocusDirection.Left)
}
if (focusDirectionToMove == FocusDirection.Right) {
// scope.launch { scrollState.animateScrollToItem(index + 1) }
scrollState.animateScrollToItem(index + 1)
focusManager.moveFocus(FocusDirection.Right)
}
}
}
}
true
}
}
I thought it was caused by the animateScrollToItem function that had to complete before executing moveFocus.
So I tried to execute animateScrollToItem in its own launch block but it didn't work; in this case there is no scrolling at all.
You can see the complete source code in a repo at https://github.com/geekarist/perf-carousel.
How to do this Scroll hide fab button in Jetpack Compose with transaction
Like this I need it:
You need to listen to the scroll state and apply AnimatedVisibiltiy. Here is an example using LazyColumn with LazyListState (you could also use Column with ScrollState)
#Composable
fun Screen() {
val listState = rememberLazyListState()
val fabVisibility by derivedStateOf {
listState.firstVisibleItemIndex == 0
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
Modifier.fillMaxSize(),
state = listState,
) {
items(count = 100, key = { it.toString() }) {
Text(modifier = Modifier.fillMaxWidth(),
text = "Hello $it!")
}
}
AddPaymentFab(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 40.dp),
isVisibleBecauseOfScrolling = fabVisibility
)
}
}
#Composable
private fun AddPaymentFab(
modifier: Modifier,
isVisibleBecauseOfScrolling: Boolean,
) {
val density = LocalDensity.current
AnimatedVisibility(
modifier = modifier,
visible = isVisibleBecauseOfScrolling,
enter = slideInVertically {
with(density) { 40.dp.roundToPx() }
} + fadeIn(),
exit = fadeOut(
animationSpec = keyframes {
this.durationMillis = 120
}
)
) {
ExtendedFloatingActionButton(
text = { Text(text = "Add Payment") },
onClick = { },
icon = { Icon(Icons.Filled.Add, "Add Payment") }
)
}
}
It may be late, but after struggling with this issue for a while, I was able to find the right solution from the Animation Codelab sourcecode.
The difference between this and the previous answer is that in this way, as soon as the page is scrolled up, the Fab is displayed and there is no need to reach the first item of the page to display the Fab.
step one: getting an instance of the lazyListState class inside LazyColumn
val lazyListState = rememberLazyListState()
Step two: Creating a top level variable to hold the scroll state so that recompositions do not change the state value unintentionally.
var isScrollingUp by mutableStateOf(false)
Step three: just copy this composable Extension Function inside the file
#Composable
private fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) }
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}
Step four: Open an AnimatedVisibility block and pass the isScrollingUp variable as its first parameter. And finally, making the Fab and placing it inside the AnimatedVisibility
AnimatedVisibility(visible = isScrollingUp) {
FloatingActionButton(onClick = { /*TODO*/ }) {
// your code
}
}
have fun!
I'm trying to do a quick pagination example with Jetpack compose in where I load the first 6 items of the list, then I load another 6 and so on until the end of the list.
I'm aware of the error in the for loop that will cause an IndexOutOfBounds since is not well designed to iterate until the last element of the array
My problem is two, first the for loop one, I don't know how to take from 0 - 6 , 6 - 12 - 12 - list.size
Then my other problem is that every time I use setList it should recompose the LazyColumnForIndex and its not, causing only to render the first 6 items
What I'm doing wrong here ?
val longList = mutableListOf("Phone","Computer","TV","Glasses","Cup",
"Stereo","Music","Furniture","Air","Red","Blue","Yellow",
"White","Black","Pink","Games","Car","Motorbike")
#Composable
fun PaginationDemo() {
val maxItemsPerPage = 6
val list = mutableListOf<String>()
var (page,setValue) = remember { mutableStateOf(1) }
val (paginatedList,setList) = remember { mutableStateOf(getPaginatedList(page,maxItemsPerPage, list)) }
LazyColumnForIndexed(items = paginatedList ) { index, item ->
Text(text = item,style = TextStyle(fontSize = 24.sp))
if(paginatedList.lastIndex == index){
onActive(callback = {
setValue(page++)
setList(getPaginatedList(page,maxItemsPerPage,list))
})
}
}
}
private fun getPaginatedList(page:Int,maxItemsPerPage:Int,list:MutableList<String>): MutableList<String> {
val startIndex = maxItemsPerPage * page
for(item in 0 until startIndex){
list.add(longList[item])
}
return list
}
This seems to work.
#Composable
fun PaginationDemo() {
val maxItemsPerPage = 6
val list = mutableStateListOf<String>()
var (page,setValue) = remember { mutableStateOf(1) }
val (paginatedList,setList) = remember { mutableStateOf(getPaginatedList(page,maxItemsPerPage, list)) }
LazyColumnForIndexed(
items = paginatedList
) { index, item ->
Text(
text = item,
style = TextStyle(fontSize = 24.sp),
modifier = Modifier
.fillParentMaxWidth()
.height((ConfigurationAmbient.current.screenHeightDp / 3).dp)
.background(color = Color.Blue)
)
Divider(thickness = 2.dp)
Log.d("MainActivity", "lastIndex = ${paginatedList.lastIndex} vs index = $index")
if(paginatedList.lastIndex == index){
onActive(callback = {
setValue(++page)
setList(getPaginatedList(page,maxItemsPerPage,list))
})
}
}
}
private fun getPaginatedList(page:Int,maxItemsPerPage:Int,list:MutableList<String>): MutableList<String> {
val maxSize = longList.size
val startIndex = if (maxItemsPerPage * page >= maxSize) maxSize else maxItemsPerPage * page
list.clear()
for(item in 0 until startIndex){
list.add(longList[item])
}
return list
}