StuartDTO
StuartDTO

Reputation: 1031

How to detect scroll behaviour in LazyColumn?

I'm trying to detect three scenarios :

1.- User scroll vertically (down) and notify to hide a button

2.- User stop scrolls and notify to hide button

3.- User scroll vertically (up) and notify to show the button

4.- User is in the bottom of the list and there are no more items and notify to show the button.

What I've tried is :

First approach is to use nestedScrollConnection as follows

val isVisible = remember { MutableTransitionState(false) }
                .apply { targetState = true }
val nestedScrollConnection = remember {
                object : NestedScrollConnection {
                    override fun onPreScroll(
                        available: Offset,
                        source: NestedScrollSource
                    ): Offset {
                        return Offset.Zero
                    }

                    override fun onPostScroll(
                        consumed: Offset,
                        available: Offset,
                        source: NestedScrollSource
                    ): Offset {
                        isVisible.targetState = false
                        return super.onPostScroll(consumed, available, source)
                    }
                }
            }
LazyColumn(
                modifier = Modifier
                    .padding(start = 16.dp, end = 16.dp, top = 16.dp)
                    .nestedScroll(nestedScrollConnection),
                verticalArrangement = Arrangement.spacedBy(16.dp),
            )

What I've tried is when y > 0 is going up, else is going down, but the others I don't know how to get them.

Another approach I followed is :

val scrollState = rememberLazyListState()
LazyColumn(
                modifier = Modifier
                    .padding(start = 16.dp, end = 16.dp, top = 16.dp),
                verticalArrangement = Arrangement.spacedBy(16.dp),
                state = scrollState,

But I don't know how to get if it's last item or not, I can get if the scroll is in progress.

Note

Answer from Skizo works but with this it's a bit weird because if you scroll up slowly the Y sometimes is not what I want and then hide it again, is there any way to leave some scroll to start reacting to this? For instance, scroll X pixels to start showing / hiding.

What I want is to hide/show is a Float Action Button depending on the scroll (the scenarios are the ones from above)

I've found this way, but it is using the offset and I'd like to animate the FloatActionButton instead of appearing from the bottom like a fade in/fade out I was using the Animation Visibility and I got this working with fade in/fade out but now, how can I adapt the code from github to use Animation Visibility? And also add this when the user ends scrolling that from now in the code is just while scrolling

Upvotes: 5

Views: 5469

Answers (3)

Joel Alvarado
Joel Alvarado

Reputation: 96

For 1 and 3, if you want to specify a specific scroll distance before showing/hiding something, you can create a NestedScrollConnection with its own state.

Here is an example of an implementation that may give you some ideas. CustomScrollConnection.isScrollingDown would return true when downThreshold is reached, and it would change to false when upThreshold is reached.

private class CustomScrollConnection(
    downThreshold: Dp = 0.dp,
    upThreshold: Dp = 0.dp,
    density: Density
) : NestedScrollConnection {

    var isScrollingDown by mutableStateOf(false)
        private set

    private val downThresholdPx = with(density) { -downThreshold.toPx() }
    private val upThresholdPx = with(density) { upThreshold.toPx() }
    private var isScrollingDownDelta = 0f

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val delta = available.y
        updateState(delta)
        return Offset.Zero
    }

    private fun updateState(delta: Float) {
        val isChangingDirections = isScrollingDownDelta.sign != delta.sign

        isScrollingDownDelta = if (isChangingDirections) {
            delta
        } else {
            isScrollingDownDelta + delta
        }.coerceIn(downThresholdPx, upThresholdPx)

        val isUpScroll = isScrollingDownDelta == upThresholdPx && delta > 0
        val isDownScroll = isScrollingDownDelta == downThresholdPx && delta < 0

        val continueDownScroll = isScrollingDown && !isUpScroll
        isScrollingDown = continueDownScroll || isDownScroll
    }
}

And you can create an instance like this before passing it to the nestedScroll modifier.

val localDensity = LocalDensity.current

val customScrollConnection = remember(localDensity) {
    CustomScrollConnection(
        downThreshold = DOWN_SCROLL_THRESHOLD,
        upThreshold = UP_SCROLL_THRESHOLD,
        density = localDensity
    )
}

Additional code would be needed to use it with rememberSaveable.

For 2, if you are using LazyListState, you can see if !lazyListState.isScrollInProgress helps.

For 4, you can try using !lazyListState.canScrollForward. Or, in an implementation of NestedScrollConnection, if you override onPostScroll, you can check if consumed.y == 0f && available.y < 0f; this could mean you reached the bottom.

Upvotes: 0

Richard Onslow Roper
Richard Onslow Roper

Reputation: 6835

Here's how you'd go about achieving these,

1.) Detect The Scrolling Direction (Vertically Up, or Vertically Down)

@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
}

Now, just create a listState variable and use it with this Composable to retrieve the scrolling dierection.

val listState = rememberLazyListState()
val scrollingUp = listState.isScrollingUp()

Then, as you say, you'd like to get notified if the user stops scrolling, so for that you can just create a variable known as restingScrollPosition, I'll name it rsp for short. Now that you are familiar with the required helper methods, all that is required is devising a mechanism to trigger an event based on the value of the current scroll position that triggers the code in concern if the value has been the same for a particular amount of time (the definition of the scroll being "at rest").

So, here it is

var rsp by remember { mutableStateOf(listState.firstVisibleItemScrollOffset) } 
var isScrolling by remember { mutableStateOf(false) } // to keep track of the scroll-state

LaunchedEffect(key = listState.firstVisibleItemScrollOffset){ //Recomposes every time the key changes

/*This block will also be executed
 on the start of the program,
 so you might want to handle
 that with the help of another variable.*/
 
 isScrolling = true // If control reaches here, we're scrolling
 launch{
  isScrolling = false
  delay(100) //If there's no scroll after a hundred seconds, update rsp
  if(!isScrolling){
     rsp = listState.firstVisibleItemScrollOffset
     /* Execute your trigger here,
        this denotes the scrolling has stopped */
    }
 }
}

I don't think I would be explaining the workings of the last code here, please analyze it yourself to gain a better understanding, it's not difficult.

Ah yes to achieve the last objective, you can just use a little swashbuckling with the APIs, specifically methods like lazyListState.layoutInfo. It has all the info you'll require about the items currently visible on-screen, and so you can also use it to implement your subtle need where you wish to allow for a certain amount to be scrolled before triggering the codeblock. Just have a look at the availannle info in the object and you should be able to start it up.

UPDATE:

Based on the info provided in the comments added below as of yesterday, this should be the implementation,

You have a FAB somewhere in your heirarchy, and you wish to animate it's visibility based on the isScrollingUp, which is bound to the Lazy Scroller defined somewhere else in the heirarchy,

In that case, you can take a look at state-hoisting, which is a general best practice for declarative programming.

Just hoist the isScrollingUp() output up to the point where your FAB is declared, or please share the complete code if you need specific instructions based on your use-case. I would require the entire heirarchy to be able to help you out with this.

Upvotes: 5

Skizo-ozᴉʞS ツ
Skizo-ozᴉʞS ツ

Reputation: 20626

I've faced same problem some days ago and I did a mix of what you say.

To show or hide then scrolling up or down with the Y is enough.

val nestedScrollConnection = remember {
   object : NestedScrollConnection {
             override fun onPreScroll(
             available: Offset,
             source: NestedScrollSource
         ): Offset {
            val delta = available.y
            isVisible.targetState = delta > 0
            return Offset.Zero
        }
    }
}

And to detect there's no more items you can use

fun isLastItemVisible(lazyListState: LazyListState): Boolean {
 val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
 return lastItem == null || lastItem.size + lastItem.offset <= layoutInfo.viewportEndOffset
}

Upvotes: 1

Related Questions