Send RecyclerView’s scroll events to a compose parent

We have an app that relies heavily on fragments. Every screen is an activity that hosts a fragment that hosts a recycler view:

We want to start migrating the screens to compose by keeping the screen’s content and placing it in a compose environment. This means that our activities will call setContent { } instead of setContentView() and that our top and bottom bars will be written in compose.
Also, a must have requirement is that the top bar has to react to the user’s scroll by collapsing and expanding.

Using Scaffold and AndroidFragment

Scaffold offers, among others, slots for both bars. It also provides wiring between the screens’ components so that we can easily add support for various interactions. In our case we want to leverage top bar’s enter always scroll behavior.
AndroidFragment is a composable that takes care the instantiation and hosting of a fragment.

Putting them together we start we something like this:

MaterialTheme {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Text("Top App Bar Content")
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Gray,
scrolledContainerColor = Color.Black
),
scrollBehavior = scrollBehavior
)
},
bottomBar = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Gray),
contentAlignment = Alignment.Center
) {
Text("Bottom Bar Content")
}
}
) { paddingValues ->
AndroidFragment<MainFragment>(
modifier = Modifier
.padding(paddingValues)
)
}
}

which renders in this:

As you can see the list renders fine but, even though we’ve setup the wiring, the top bar does not collapse/expand when we scroll.

NestedScrollConnection and NestedScrollDispatcher

Scrolling in compose is a bit different from the view systems’. When a scroll event happens, instead of just one composable handling it, multiple composables can participate and decide how much of that scroll they want to consume.

To achieve that, compose provides three components:

  1. NestedScrollConnection which is for parents to listen and consume
  2. NestedScrollDispatcher which is for children to dispatch events and
  3. the .nestedScroll() modifier which is the way to integrate these to the hierarchy

This means that if we want a parent composable to react to scroll events we need to provide a connection to it. If we want one of its the children to emit those events we need to provide to it that same connection and a dispatcher. .nestedScroll internally creates a NestedScrollNode which is used to couple the connection and the dispatcher together.

So, for our case, we have to create a dispatcher, couple it with the scroll behavior’s connection and provide it to our recycler view. Then the view will use it to dispatch its scroll events.

fun RecyclerView.setNestedScrollDispatcher()

Looking at the dispatcher’s API we can see that it provides two methods to dispatch scroll events. The first one, dispatchPreScroll, will inform the that a scroll is about to take place and that it will consume a certain distance. The second one, dispatchPostScroll, will inform that a scroll took place, it consumed a certain distance and has left some for consumption.

In the compose world all that makes sense. The scrollable modifier handles a scroll delta and communicates it properly in pre and post events.
In the view world we don’t have anything similar. We can implement the logic using gesture detectors and by intercepting touch event but we can start simpler with a OnScrollListener:

fun RecyclerView.setNestedScrollDispatcher(dispatcher: NestedScrollDispatcher) {
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (abs(dx) > abs(dy)) return // Ignore horizontal scrolls
dispatcher.dispatchPreScroll(Offset(0f, -dy.toFloat()), UserInput)
}
})
}

which is added to the recycler view like this:

MaterialTheme {
// code…
val nestedScrollDispatcher = remember { NestedScrollDispatcher() }
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
// code…
) { paddingValues ->
AndroidFragment<MainFragment>(
modifier = Modifier
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection, nestedScrollDispatcher)
) { fragment ->
fragment.view
?.findViewById<RecyclerView>(R.id.outer_recycler_view)
?.setNestedScrollDispatcher(dispatcher = nestedScrollDispatcher)
}
}
}

Two things here:

  1. When dispatching the y delta we need to negate its sign because RecyclerView and Compose use opposite sign conventions for scroll direction.
  2. We coupled the connection with the dispatcher in the AndroidFragment because the created NestedScrollNode will try to find a parent node to dispatch its events too.

As you can see from the video the implementation works. The top bar collapses and expands accordingly. The only problem is that when the user starts scrolling slowly the top bar jiggles creating an unpleasant UX.

After adding some logs we can see that the scrolled dy is not always positive or negative through out the gesture:

NestedScrollDispatcher  gr.le0nidas.fragmentincompose        D  onScrolled: dy=3
NestedScrollDispatcher gr.le0nidas.fragmentincompose D onScrolled: dy=1
NestedScrollDispatcher gr.le0nidas.fragmentincompose D onScrolled: dy=6
NestedScrollDispatcher gr.le0nidas.fragmentincompose D onScrolled: dy=-3
NestedScrollDispatcher gr.le0nidas.fragmentincompose D onScrolled: dy=6
NestedScrollDispatcher gr.le0nidas.fragmentincompose D onScrolled: dy=-4
NestedScrollDispatcher gr.le0nidas.fragmentincompose D onScrolled: dy=10
NestedScrollDispatcher gr.le0nidas.fragmentincompose D onScrolled: dy=-7

The slow movement is making the framework think that we are constantly trying to move a little bit up and immediately a little bit down. This causes the jiggle since the top bar toggles constantly between collapsing and expanding.

VerticalMovementDetector

We can prevent this by determining what kind of movement we have and then ignore any deltas that are not part of that movement.

To do that we need to have a window of n deltas and then see if the majority of them is positive or negative which will mean that the user scrolls down or up respectively. After knowing that we simply ignore the deltas that we don’t want.

A couple of things that help in the UX:

  • Until we fill that window we do not dispatch anything.
  • After filling it we make sure that we keep the last n deltas. That way we can determine the movement even if the user does one continuous gesture.
private class NestedScrollDispatcherAdapter(
private val detector: VerticalMovementDetector,
private val dispatcher: NestedScrollDispatcher
) : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (abs(dx) > abs(dy)) return // Ignore horizontal scrolls
val distanceY = dy.toFloat()
when (detector.getDirection(distanceY)) {
UP -> if (distanceY > 0) return
DOWN -> if (distanceY < 0) return
null -> return
}
dispatcher.dispatchPreScroll(distanceY)
}
}
private class VerticalMovementDetector {
private val yDistances = ArrayDeque<Float>()
private var inSamplingMode = false
fun getDirection(yDistance: Float): MovementDirection? {
takeSample(yDistance)
if (isSampling()) return null
return calculateDirection()
}
fun startSampling() {
inSamplingMode = true
}
fun stopSampling() {
inSamplingMode = false
yDistances.clear()
}
private fun calculateDirection(): MovementDirection? {
val partition = yDistances.partition { it < 0 }
return if (partition.first.size > partition.second.size) UP else DOWN
}
private fun takeSample(yDistance: Float) {
if (yDistances.size >= SAMPLING_SIZE) {
yDistances.removeFirst()
}
yDistances.addLast(yDistance)
}
private fun isSampling(): Boolean = yDistances.size < SAMPLING_SIZE
}
private enum class MovementDirection {
UP,
DOWN
}

The final result looks like this:

Here is the entire code where we’ve added a sampling switch in order to keep things cleaner and avoid weird edge cases:

private const val SAMPLING_SIZE = 5
fun RecyclerView.setNestedScrollDispatcher(dispatcher: NestedScrollDispatcher) {
val detector = VerticalMovementDetector()
addOnItemTouchListener(SamplingSwitch(detector))
addOnScrollListener(NestedScrollDispatcherAdapter(detector, dispatcher))
}
private fun NestedScrollDispatcher.dispatchPreScroll(distanceY: Float) {
dispatchPreScroll(
available = Offset(0f, -distanceY),
source = UserInput
)
}
private class SamplingSwitch(private val detector: VerticalMovementDetector) :
RecyclerView.SimpleOnItemTouchListener() {
override fun onInterceptTouchEvent(
rv: RecyclerView,
e: MotionEvent
): Boolean {
if (e.actionMasked == ACTION_UP || e.actionMasked == ACTION_CANCEL) detector.stopSampling()
if (e.actionMasked == ACTION_DOWN) detector.startSampling()
return false
}
}
private class NestedScrollDispatcherAdapter(
private val detector: VerticalMovementDetector,
private val dispatcher: NestedScrollDispatcher
) : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (abs(dx) > abs(dy)) return // Ignore horizontal scrolls
val distanceY = dy.toFloat()
when (detector.getDirection(distanceY)) {
UP -> if (distanceY > 0) return
DOWN -> if (distanceY < 0) return
null -> return
}
dispatcher.dispatchPreScroll(distanceY)
}
}
private class VerticalMovementDetector {
private val yDistances = ArrayDeque<Float>()
private var inSamplingMode = false
fun getDirection(yDistance: Float): MovementDirection? {
takeSample(yDistance)
if (isSampling()) return null
return calculateDirection()
}
fun startSampling() {
inSamplingMode = true
}
fun stopSampling() {
inSamplingMode = false
yDistances.clear()
}
private fun calculateDirection(): MovementDirection? {
val partition = yDistances.partition { it < 0 }
return if (partition.first.size > partition.second.size) UP else DOWN
}
private fun takeSample(yDistance: Float) {
if (yDistances.size >= SAMPLING_SIZE) {
yDistances.removeFirst()
}
yDistances.addLast(yDistance)
}
private fun isSampling(): Boolean = yDistances.size < SAMPLING_SIZE
}
private enum class MovementDirection {
UP,
DOWN
}

Horizontal RecyclerView inside a vertical one

Or to be exact, multiple RecyclerViews with horizontal orientation inside a RecyclerView where its orientation is vertical.

Easy to implement and works out of box quite well as long as the user swipes gently or flings in an almost straight motion. Unfortunately this is not always the case. Especially when holding the phone with one hand the user tends to swipe with her thumb and in a great velocity ending up in a fling motion that has an arch shape:

user’s motion

This has the result of either moving on the vertical axis or not moving at all in both axes:

All movements in this video are with my right thumb while holding a 6” phone in my right hand.
You might have noticed that the user’s experience gets even worse when trying to scroll a list that is positioned either in the bottom or the bottom half of the screen.

The touch flow

But why is that happening? To understand it we must first understand the flow that a single touch event follows starting by defining two things:

  1. Every motion our finger does, either touching the screen, lifting it from it or dragging it along is being translated to a MotionEvent that gets emitted from the framework to our app. More specifically to our active activity.
  2. A gesture is the sequence of motion events that start from touching our finger down, the MotionEvent‘s action is ACTION_DOWN, until we lift it up, the MotionEvent‘s action is ACTION_UP. All intermediate events have the ACTION_MOVE action.
Regular flow

So, when the framework registers a motion event it sends it to our activity which then calls the dispatchTouchEvent method in its root view. The root view dispatches the event to its child view that was in the touch area, the child view to its child and so on so forth until the event reaches the last view in the hierarchy:

touch flow

The view then has to decide if it will consume the gesture by returning true in its onTouchEvent. If it returns false the flow will continue by asking the view’s parent and so on so forth until the event reaches the activity again.

Note that we say consume the gesture and continue here. That is because if the view returns true in the ACTION_DOWN event then all subsequent events, until the gesture’s end (ACTION_UP) will never reach the view’s parents on their way up. They will stop at the view.

Interception

You may ask yourself, what happens if the view’s parent is a scrollable view and the view decides to consume the gesture? Wouldn’t that mean that the parent will never get informed about the user’s movements thus will never scroll?

Correct! That is why the framework allows every view group to intercept an event before it reaches a view. There, the group can decide if it will just keep monitoring the events or stop them from ever reaching its children. This way view groups like ScrollView or RecyclerView can monitor the user’s gestures and if one of them gets translated to a scroll movement, they stop the events from getting to their children and handle them themselves (start scrolling).

The problem

This last part is the root of our bad UX. When the user flings her finger as shown above, the parent RecyclerView, which is always monitoring the emitted events, thinks that it was asked to scroll vertically and immediately prevents all other events from ever reaching the horizontal RecyclerViews while also start to scroll its content.

The solution

Fortunately the framework provides the solution (actually part of it) too.

A child view can ask its parent to stop intercepting and leave all events reach to it. This is done by calling, on its parent, the requestDisallowInterceptTouchEven(true) method. The request will cascade to all the parents in the hierarchy and as a result all events will reach the view.

That’s what we need to do here. All horizontal RecyclerViews need to ask their parents (this will affect the vertical RecyclerView too) to stop intercepting. The question is where to put this request.

Turns out that a OnItemTouchListener is the best place to make the request:

Add an RecyclerView.OnItemTouchListener to intercept touch events before they are dispatched to child views or this view’s standard scrolling behavior.

docs

This way we can act upon an event before reaching the recycler’s content:

list.addOnItemTouchListener(
object: RecyclerView.SimpleOnItemTouchListener() {
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
if (e.action == MotionEvent.ACTION_DOWN) {
rv.parent.requestDisallowInterceptTouchEvent(true)
}
return super.onInterceptTouchEvent(rv, e)
}
}
)

With that we make sure that the moment the user starts a gesture nothing will stop it from reaching a horizontal recycler no matter how quick or slow the gesture is, or what shape it has.

Now you might wonder what happens when the user wants to actually scroll up or down! Well this is when we need to permit the parents to intercept again:

list.addOnItemTouchListener(
object : RecyclerView.SimpleOnItemTouchListener() {
var initialX = 0f
var initialY = 0f
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
if (e.action == MotionEvent.ACTION_DOWN) {
rv.parent.requestDisallowInterceptTouchEvent(true)
initialX = e.rawX
initialY = e.rawY
} else if (e.action == MotionEvent.ACTION_MOVE) {
val xDiff = abs(e.rawX – initialX)
val yDiff = abs(e.rawY – initialY)
if (yDiff > xDiff) {
rv.parent.requestDisallowInterceptTouchEvent(false)
}
}
return super.onInterceptTouchEvent(rv, e)
}
}
)

On ACTION_DOWN except from making the request we also keep the x and y coordinates that the touch occurred. Then while the user drags her finger we try to figure out if the user drags it horizontally or vertically. If it is vertically then it does not concern us (being a horizontal recycler) so we allow our parents (this means the vertical recycler too) to start intercepting again. Now the vertical recycler acts upon the events and takes over the gesture:

after

PS: onTouchEvent is part of View and can only be overridden from custom views. That is why the framework provides a OnTouchListener so that we can consume a gesture in any of the framework’s views since the framework checks if there is a listener first and only if there is none or if it didn’t handle the event it calls onTouchEvent.