Search code examples
androidandroid-recyclerviewandroid-coordinatorlayout

How to programmatically cancel fling on a RecyclerView when reaching its end


I'm using a ViewPager inside a CoordinatorLayout with RecyclerViews in each of its pages (posted a small sample project on GitHub as a demonstration). I've noticed that swiping left/right in the ViewPager is ignored for some time after flinging to the end of the RecyclerView. Narrowing down the issue, I came to the conclusion (actually more of an assumption) that the fling is still going on for some more time after reaching the end of the - rather short - RecyclerView and a swipe on the ViewPager is only possible after this fling has stopped.

Following is a demo gif of the issue: only scrolling lets the ViewPager swipe right away, whereas flinging needs 2 tries (or just some time).

img

Is there a clean way to stop the fling on reaching either end of the RecyclerView? My workaround would be to dispatch a MotionEvent when reaching the end, but that feels very hack-ish.


Solution

  • I managed to work around the problem by subclassing RecyclerView as follows (of course, still open for other suggestions):

    /**
     * RecyclerView dispatching an ACTION_DOWN MotionEvent when reaching either its beginning
     * or end and consuming a fling gesture when that fling is in the (vertical) direction
     * the RecyclerView can't scroll anymore anyway.
     *
     * Background: in following setup
     *
     *  <CoordinatorLayout>
     *      <NestedScrollView>
     *          <ViewPager>
     *              <Fragments containing RecyclerView/>
     *          </ViewPager>
     *      </NestedScrollView>
     *  </CoordinatorLayout>
     *
     * a vertical fling on the RecyclerView will prevent the viewpager to swipe right/left
     * immediately after reaching the end (on scroll down) or beginning (on scroll up) of the RV.
     * It seems the RV is intercepting the touch until the fling has worn off. TouchyRecyclerView
     * is a workaround for this phenomenon by both
     *  a) cancelling the fling on reaching either end of the RecyclerView by dispatching a
     *     MotionEvent ACTION_DOWN and
     *  b) consuming a detected fling gesture when that fling is in the direction the RV is
     *     at the respective end.
     */
    class TouchyRecyclerView : RecyclerView {
        constructor(context: Context) : super(context)
        constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
    
        private val gestureDetector: GestureDetector by lazy {
            GestureDetector(context, VerticalFlingListener(this))
        }
        private val scrollListener = object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                val llm: LinearLayoutManager = recyclerView.layoutManager as LinearLayoutManager
    
                if (dy > 0) { // we're scrolling down the RecyclerView
                    val adapter = adapter
                    val position = llm.findLastCompletelyVisibleItemPosition()
                    if (adapter != null && position == adapter.itemCount - 1) {
                        // and we're at the bottom
                        dispatchActionDownMotionEvent()
                    }
                } else if (dy < 0) { // we're scrolling up the RecyclerView
                    val position = llm.findFirstCompletelyVisibleItemPosition()
                    if (position == 0) {
                        // and we're at the very top
                        dispatchActionDownMotionEvent()
                    }
                }
            }
        }
    
        init {
            this.addOnScrollListener(scrollListener)
        }
    
        private fun dispatchActionDownMotionEvent() {
            val los = intArrayOf(0, 0)
            this.getLocationOnScreen(los)
            val e = MotionEvent.obtain(
                0,
                0,
                ACTION_DOWN,
                los[0].toFloat(),
                los[1].toFloat(),
                0)
            dispatchTouchEvent(e)
        }
    
        @SuppressLint("ClickableViewAccessibility")
        override fun onTouchEvent(e: MotionEvent?): Boolean {
            return if (gestureDetector.onTouchEvent(e)) {
                true
            } else {
                super.onTouchEvent(e)
            }
        }
    
        /**
         * Listener to consume unnecessary vertical flings (i.e. when the RecyclerView is at the respective end).
         */
        inner class VerticalFlingListener(private val recyclerView: RecyclerView) :
            GestureDetector.SimpleOnGestureListener() {
            override fun onDown(e: MotionEvent?): Boolean {
                return true
            }
    
            override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
                val adapter = recyclerView.adapter
                val llm = recyclerView.layoutManager as LinearLayoutManager
                if (velocityY < 0) { // we're flinging down the RecyclerView
                    if (adapter != null &&
                        llm.findLastCompletelyVisibleItemPosition() == adapter.itemCount - 1) {
                        // but we're already at the bottom - consume the fling
                        return true
                    }
                } else if (velocityY > 0) { // we're flinging up the RecyclerView
                    if (0 == llm.findFirstCompletelyVisibleItemPosition()) {
                        // but we're already at the top - consume the fling
                        return true
                    }
                }
                return false
            }
        }
    }