How to get soon-to-be-selected page while settling snapping feature on RecyclerView and ViewPager

734 views Asked by At

Background

ViewPager snaps to a view after you perform some scrolling, and so can RecyclerView, if you use something like this:

LinearSnapHelper().attachToRecyclerView(recyclerView)

Or by using a library to snap to a certain edge, as shown on this library. It can mimic ViewPager almost entirely if you wish, as shown on this library, mentioned by CommonsWare here.

Both of them can function the same way if needed (snapping to a single view, optionally taking whole available space), so this question is about both of them.

ViewPager has some issues of recycling views, but those can be fixed, for example using this library.

The problem

I'm required to update the UI of the newly shown view, as soon as the RecyclerView/ViewPager is about to be idle in scrolling.

Of course, since the user can still touch it, it would be a problem of knowing what is about to occur, so I'm required to also block touch events when it's settling the scroll state.

This means, for example, that when the user flings a single page, from page 0 to page 1, as soon as the snapping starts, touch events are blocked, and I would be able to know that it snaps to page 1 and update this page.

The problem here is that both RecyclerView and ViewPager don't offer this functionality. I can get the item that's selected only after it stopped scrolling, not while it is settling.

What I've tried

For ViewPager, the adapter only has setPrimaryItem , so sadly it tells me which item is selected after it finished settling. I do have addOnPageChangeListener function, which tells me about the scrolling position at any given time (using onPageScrolled), but it doesn't tell me which direction I go (left/right), and I can't know which data and which viewHolder of it is about to be selected. addOnPageChangeListener also provides the state of the scrolling (idle, settling, dragging).

For RecyclerView I can get a callback of the scrolling state, of when it's about to settle and when it becomes idle, but I can't see how to get the item it's about to settle on.

About blocking touch events, I think I could put a clickable view (that has no content, so it's invisible to the user) on top of it when it's settling, and hide it (set visibility to GONE) when it's idle, but I wonder if there is a better way.

I tried using setOnTouchListener for RecyclerView idle and settling states, but when I tried touching while it's settling, it got stuck on its current scrolling location.

So both of RecyclerView and ViewPager have obstacles for getting it all done...

Here's what I got currently working :

ViewPager:

enter image description here

RecyclerView:

enter image description here

POC code of what I tried (both ViewPager and RecyclerView):

MainActivity.kt

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val inflater = LayoutInflater.from(this)

        //viewPager area

        viewPager.adapter = object : RecyclerPagerAdapter<RecyclerPagerAdapter.ViewHolder>() {
            var selectedHolder: RecyclerPagerAdapter.ViewHolder? = null
            override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
                return object : RecyclerPagerAdapter.ViewHolder(inflater.inflate(R.layout.cell, parent, false)) {}
            }

            override fun getItemCount(): Int = 100

            override fun onBindViewHolder(holder: ViewHolder, position: Int) {
                (holder.itemView as TextView).text = position.toString()
            }

            override fun setPrimaryItem(container: ViewGroup?, position: Int, obj: Any?) {
                super.setPrimaryItem(container, position, obj)
                //TODO get the soon-to-be-selected page sooner
                val holder = obj as RecyclerPagerAdapter.ViewHolder
                if (selectedHolder != null && selectedHolder != holder)
                    (selectedHolder!!.itemView as TextView).text = position.toString()
                (holder.itemView as TextView).text = "selected:${position.toString()}"
                selectedHolder = holder
            }

        }
        viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
            override fun onPageScrollStateChanged(state: Int) {
                when (state) {
                    ViewPager.SCROLL_STATE_DRAGGING -> Log.d("AppLog", "onPageScrollStateChanged: SCROLL_STATE_DRAGGING")
                    ViewPager.SCROLL_STATE_IDLE -> Log.d("AppLog", "onPageScrollStateChanged: SCROLL_STATE_IDLE")
                    ViewPager.SCROLL_STATE_SETTLING -> Log.d("AppLog", "onPageScrollStateChanged: SCROLL_STATE_SETTLING")
                }
            }

            override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
                Log.d("AppLog", "onPageScrolled: position:$position positionOffset :$positionOffset positionOffsetPixels:$positionOffsetPixels")
            }

            override fun onPageSelected(position: Int) {
                Log.d("AppLog", "onPageSelected:" + position)
            }
        })

        //recyclerView area

        // Not needed, as I use a library for this: LinearSnapHelper().attachToRecyclerView(recyclerView)
        recyclerView.setHasFixedSize(true)
        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            override fun getItemCount(): Int = 100

            override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
                return object : RecyclerView.ViewHolder(inflater.inflate(R.layout.cell, parent, false)) {}
            }

            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
                (holder.itemView as TextView).text = position.toString()
            }
        }
        recyclerView.addOnPageChangedListener { oldPosition, newPosition -> Log.d("AppLog", "OnPageChanged:$oldPosition->$newPosition") }
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            @SuppressLint("ClickableViewAccessibility")
            override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)
                when (newState) {
                    RecyclerView.SCROLL_STATE_IDLE -> {
                        Log.d("AppLog", "state: SCROLL_STATE_IDLE")
                        recyclerViewStateTextView.text = "state: SCROLL_STATE_IDLE"
                        //setOnTouchListener doesn't really work well. It makes the scrolling stuck
                        //                        recyclerView!!.setOnTouchListener(null)
                    }
                    RecyclerView.SCROLL_STATE_SETTLING -> {
                        //TODO when settling, block touches, and update the soon-to-be-focused page
                        Log.d("AppLog", "state: SCROLL_STATE_SETTLING")
                        recyclerViewStateTextView.text = "state: SCROLL_STATE_SETTLING"
                        //                        recyclerView!!.setOnTouchListener(object : View.OnTouchListener {
                        //                            override fun onTouch(v: View?, event: MotionEvent?): Boolean {
                        //                                return true
                        //                            }
                        //
                        //                        })
                    }
                    RecyclerView.SCROLL_STATE_DRAGGING -> {
                        Log.d("AppLog", "state: SCROLL_STATE_DRAGGING")
                        recyclerViewStateTextView.text = "state: SCROLL_STATE_DRAGGING"
                    }
                }
            }
        })
        recyclerViewStateTextView.text = "state: SCROLL_STATE_IDLE"
    }

cell.xml

<TextView
    xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:id="@android:id/text1"
    android:textSize="36sp" tools:text="@tools:sample/lorem"/>

activity_main.xml

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent" android:orientation="vertical"
    tools:context="com.example.user.snapblockertest.MainActivity">

    <TextView
        android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="ViewPager :"/>

    <android.support.v4.view.ViewPager
        android:id="@+id/viewPager" android:layout_width="match_parent" android:layout_height="0px"
        android:layout_weight="1"/>

    <TextView
        android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="RecyclerView with snapping:"/>

    <com.lsjwzh.widget.recyclerviewpager.RecyclerViewPager
        android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="0px"
        android:layout_weight="1" android:orientation="horizontal"
        app:layoutManager="android.support.v7.widget.LinearLayoutManager" app:rvp_singlePageFling="true"
        app:rvp_triggerOffset="0.1"/>

    <TextView
        android:id="@+id/recyclerViewStateTextView" android:layout_width="match_parent" android:layout_height="wrap_content"/>

</LinearLayout>

gradle file "non-standard" dependencies, from here and here

//https://github.com/henrytao-me/recycler-pager-adapter
implementation "me.henrytao:recycler-pager-adapter:2.1.0"
//https://github.com/lsjwzh/RecyclerViewPager
implementation 'com.github.lsjwzh.RecyclerViewPager:lib:v1.1.2@aar'

The questions

  1. How can I get a callback of when the ViewPager/RecyclerView is settling, including which item is about to be snapped to ?

  2. How can I block the touch events from the time it's settling, till the time it's idle? Is there a better way than what I wrote (of having a clickable view on top) ?


Update:

Seems that for ViewPager, I could use the onPageSelected callback to get which item it's about to settle on. Wonder though what's the best way to get the ViewHolder of its page this way. I could save the needed data in onBindViewHolder and then check it myself, but I wonder if there is a better way.

Now what's missing is how to do it for RecyclerView and how to block touch events (if there is a better way than what I wrote). The library has a function called addOnPageChangedListener , but it's called after the settling has finished, so it can't help here.

1

There are 1 answers

0
android developer On BEST ANSWER

Seems that for ViewPager, I could use the onPageSelected callback to get which item it's about to settle on.

So here's the solution for ViewPager (using the library I used) :

    val inflater = LayoutInflater.from(this)
    val viewPagerHolders = HashMap<Int, ViewPagerViewHolder>()
    viewPager.adapter = object : RecyclerPagerAdapter<ViewPagerViewHolder>() {

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewPagerViewHolder {
            return ViewPagerViewHolder(inflater.inflate(R.layout.cell, parent, false))
        }

        override fun getItemCount(): Int = 100

        override fun onBindViewHolder(holder: ViewPagerViewHolder, position: Int) {
            holder.textView.text = position.toString()
            viewPagerHolders.remove(holder.adapterPosition)
            holder.adapterPosition = position
            viewPagerHolders[position] = holder

        }
    }
    viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
        var selectedHolder: ViewPagerViewHolder? = null

        override fun onPageScrollStateChanged(state: Int) {}
        override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}

        override fun onPageSelected(position: Int) {
            val holder = viewPagerHolders[position]
            if (holder != null) {
                if (selectedHolder != null && selectedHolder != holder)
                    selectedHolder!!.textView.text = selectedHolder!!.adapterPosition.toString()
                holder.textView.text = "selected:${position.toString()}"
                selectedHolder = holder
            }
        }
    })

And a ViewHolder class that just remembers which adapter position is associated with it (because this is missing for some reason) :

class ViewPagerViewHolder(itemView: View) : RecyclerPagerAdapter.ViewHolder(itemView) {
    val textView: TextView = itemView.findViewById(android.R.id.text1)
    var adapterPosition: Int = Int.MIN_VALUE
}

Since it works so well, I decided I will use it, even without blocking touch events functionality.

Still could be nice to know how to do it for RecyclerView.

EDIT: for RecyclerView, this can be done: https://stackoverflow.com/a/47580753/878126