I have a fragment with a RecyclerView, in which elements are custom views containing number of clickable areas. The requirement is to allow the recycler view to scroll by predefined amount of items at once (if moved more than threshold up or down). Additionally all the recycler view items clickable areas must support click and long click actions.
In the fragment for that purpose was set a gesture detector:
private val gestureDetector: GestureDetector by lazy { GestureDetector(context, object : SimpleOnGestureListener() {
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
return true
}
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
isScrolling = true
scrollingOffset = e2.y - e1.y
return false
}
override fun onDown(e: MotionEvent): Boolean {
scrollingOffset = 0f
return false
}
})}
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
binding.recycler.setOnTouchListener { _, event ->
val p = gestureDetector.onTouchEvent(event)
if(event.action == MotionEvent.ACTION_UP) {
if(isScrolling) {
isScrolling = false
when {
scrollingOffset < - SCROLL_ATTEMPT_DISTANCE_THRESHOLD -> scrollToNext()
scrollingOffset > SCROLL_ATTEMPT_DISTANCE_THRESHOLD -> scrollToPrevious()
else -> scrollBack()
}
}
}
return@setOnTouchListener p
}
...
}
In a view for each area I first set a common onClick and onLongClick listeners with callbacks passed through an adapter.
frames.forEachIndexed { index, view ->
view.setOnClickListener {
onClickListener.invoke()
}
view.setOnLongClickListener {
onLongClickListener.invoke().let { true }
}
}
It works well prior to libs update to an actual versions. For some reason I started to get an error each time I try to scroll the recycler.
java.lang.NullPointerException: Parameter specified as non-null is null: method ...Fragment$gestureDetector$2$1.onScroll, parameter e1
After searching for a possible solution I have found a bug report: https://issuetracker.google.com/issues/243267018
As much as I figured it out, the reason of crash is that e1 parameter passed to onScroll method is actually has null value, but e1 should be non-nullable type. The root of a problem as I have read can be the wrong order or missing some of the touch events passed to gesture detector handle cycle. When I commented out items click listeners the error did not appear. So it could be that those listeners consume some of touch events which leads to an exception in gesture detector.
I've tried to change onClick listeners with an inner gesture listeners for each of items:
val gestureDetector = GestureDetector(this.context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
parentGestureDetector.onTouchEvent(e)
return true
}
override fun onSingleTapUp(e: MotionEvent): Boolean {
parentGestureDetector.onTouchEvent(e)
onClickListener.invoke()
return super.onSingleTapConfirmed(e)
}
override fun onLongPress(e: MotionEvent) {
parentGestureDetector.onTouchEvent(e)
onLongClickListener.invoke()
super.onLongPress(e)
}
})
This way it can handle clicks and long clicks without crashing parent gesture listener. The only problem with this implementation is that after scrolling a recycler it ignores first click/long click on the elements (their gesture detector do not receive any of touch event at that time). The parent gesture detector goes form the Down, Scroll, Up stages and firing the onFling method in the end.
If I change the onFling method to return false, meaning it must not consume event, all click listeners starts to work well, but I get the strongly undesired fling behaviour.
Is there a better way to solve the situation with both click listeners working and fling movement turned off?
After more searching I've found the bug report related to this issue: https://issuetracker.google.com/issues/66996774
If I get it correctly the root of a problem in a few words is that after a fling action recycler view does not set to a proper state when it can handle incoming touch events as clicks so it could pass them to child views, and instead it handles those clicks as attempts to cancel scroll process which is visually already finished but interpreted as still running.
So after spending a lot of time trying to find a workaround for this issue I've actually found it for my case. For that I have to call stopScroll() from recycler view on the onFling method of gesture detector - this makes it to handle next click properly and allows child view to intercept it.
Also I fixed offset calculation in onScroll method by using e.rawY property instead of e.y. That's because e1 event is received from child view gesture detector (passed from its onDown() method) and e1.y is coordinate in space based on child view, while e2.y is based on recycler view space. Raw coordinates e.rawY is based on space of device screen for both events.
private val gestureDetector: GestureDetector by lazy { GestureDetector(context, object : SimpleOnGestureListener() {
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
binding.recycler.stopScroll()
return true
}
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
isScrolling = true
scrollingOffset = e2.rawY - e1.rawY
return false
}
override fun onDown(e: MotionEvent): Boolean {
scrollingOffset = 0f
return false
}
})}
With this all stars to work the right way.