6

Background

I'm try to achieve something similar to what the Camera app has for its modes:

enter image description here

I might probably not need to have a ViewPager, as seems that it uses above the horizontal list, but could be nice to have it as an option.

The problem

While technically I succeeded to have the RecyclerView center its items, it doesn't let you actually have the all items to be able to be on the center (example is the first/last one). When you try to scroll to the first or last items, it doesn't let you , because you've reached the edge of the RecyclerView :

enter image description here

Not only that, but in the beginning it doesn't really center, and if I have the RecyclerView have few items, it becomes a problem because I want them to be centered, but having android:layout_width="match_parent" (because all of it should be touch-able) produces this:

enter image description here

while having android:layout_width="wrap_content" get me this:

enter image description here

On both cases, I can't scroll at all. When it's "wrap_content", it's a problem because I won't be able to scroll on the sides.

What I've tried

It's possible to snap the items so that there will always be an item in the center of RecyclerView as such :

val snapHelper = LinearSnapHelper()
snapHelper.attachToRecyclerView(categoriesRecyclerView)

We can also get which is the item in the center (as shown here), by having a scroll listener and using snapHelper.findSnapView(layoutManagaer).

But as I wrote, I can't really have the first/last item being selected this way, because I can't scroll to it so that it will be at the middle.

I tried to look at the docs of the related classes, but I can't find such a thing.

Here's the current code (sample available here) :

MainActivity.kt

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
                val holder = object : RecyclerView.ViewHolder(
                    LayoutInflater.from(this@MainActivity).inflate(
                        R.layout.list_item,
                        parent,
                        false
                    )
                ) {}
                holder.itemView.setOnClickListener {
                }
                return holder
            }

            override fun getItemCount(): Int = 20

            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
                holder.itemView.textView.text = "pos:$position"
            }
        }
        val snapHelper = LinearSnapHelper()
        snapHelper.attachToRecyclerView(recyclerView)
    }
}

activity_main.xml

<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView android:background="#66000000"
                                               android:id="@+id/recyclerView"
                                               android:layout_width="match_parent"
                                               android:layout_height="@dimen/list_item_size"
                                               android:orientation="horizontal"
                                               app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
                                               app:layout_constraintBottom_toBottomOf="parent"
                                               app:layout_constraintEnd_toEndOf="parent"
                                               app:layout_constraintStart_toStartOf="parent"
                                               app:layout_constraintTop_toTopOf="parent"
                                               tools:listitem="@layout/list_item"/>
</androidx.constraintlayout.widget.ConstraintLayout>

list_item.xml

<TextView
    android:id="@+id/textView" 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="wrap_content" android:layout_height="@dimen/list_item_size"
    android:background="?attr/selectableItemBackground" android:breakStrategy="balanced" android:clickable="true"
    android:focusable="true" android:gravity="center" android:maxLines="1" android:padding="8dp"
    android:shadowColor="#222" android:shadowDx="1" android:shadowDy="1" android:textColor="#fff"
    app:autoSizeTextType="uniform" tools:targetApi="m" tools:text="@tools:sample/lorem"/>

The question

How can I let the user freely scroll inside, so that the edge will be determined by whether the first/last item is in the middle? How can I always have the items centered, including when I just started seeing the RecyclerView), and including when there are few of them?

android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • Do you really need the RecyclerView features? Wouldn't it be better to have a TabLayout which does just that? – Vincent Mimoun-Prat Nov 26 '18 at 14:35
  • @VincentMimoun-Prat I have actually thought about it seconds after publishing the question. I need to think about it. Do you have a tiny sample I can try that demonstrates what I've written , to see if it fits the requirements? Can it work without ViewPager? – android developer Nov 26 '18 at 14:39
  • Something worth looking at: https://stackoverflow.com/questions/29134094/recyclerview-horizontal-scroll-snap-in-center – Vincent Mimoun-Prat Nov 26 '18 at 14:46
  • And that one too: http://www.plattysoft.com/2015/06/16/snapping-items-on-a-horizontal-list/ – Vincent Mimoun-Prat Nov 26 '18 at 14:47
  • @VincentMimoun-Prat Thank you. I hope it can help. First link (https://stackoverflow.com/questions/29134094/recyclerview-horizontal-scroll-snap-in-center) is what I've posted, though, and it doesn't seem to work as I wrote... – android developer Nov 26 '18 at 14:50
  • First and the last items can be centered by using RecyclerView.ItemDecoration the link might give a direction ,https://stackoverflow.com/questions/44662420/horizontally-center-first-item-of-recyclerview – Kaveri Nov 26 '18 at 15:06
  • @Kaveri Can you please be more specific what should be added? – android developer Nov 26 '18 at 15:12
  • @VincentMimoun-Prat The second link, which points to this Github repo: https://github.com/plattysoft/SnappingList , seems to work well, but scrolling is wobbly at the beginning and end, similar to IOS. It should stop without an effect. In fact, it seems to have extra items just for this, which are used for padding. – android developer Nov 26 '18 at 15:14
  • @VincentMimoun-Prat The first link just makes it restricted to swipe just one item at a time, like ViewPager, but doesn't allow me to have the first/last items being centered. It also doesn't center on the first item. – android developer Nov 26 '18 at 15:23
  • It is astonishing how after all these years we still do not have a vanilla solid version of this working provided by the Android team. All available solutions have problems or link to abandoned libraries, quite discouraging to even attempt to venture in this till this day. – Shadow Dec 20 '20 at 16:41
  • @androiddeveloper I have a similar issue. I what to achieve that white background animation when the user scroll. Any idea how can I achieve this? https://stackoverflow.com/questions/66804464/android-recyclerview-animate-item-background-on-scroll – Favolas Mar 26 '21 at 09:32

2 Answers2

24

I gave this a try

5 items: https://drive.google.com/open?id=1RPyiY9UndXcrbfBDWLB-UklxjPKMiR8- 2 items: https://drive.google.com/open?id=1HkG8NShxQ3illFupK-urSPwsUhag74WS

First, apply an item decoration to center the first and last items:

class CenterDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() {

    private var firstViewWidth = -1
    private var lastViewWidth = -1

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        super.getItemOffsets(outRect, view, parent, state)
        val adapterPosition = (view.layoutParams as RecyclerView.LayoutParams).viewAdapterPosition
        val lm = parent.layoutManager as LinearLayoutManager
        if (adapterPosition == 0) {
            // Invalidate decorations when this view width has changed
            if (view.width != firstViewWidth) {
                view.doOnPreDraw { parent.invalidateItemDecorations() }
            }
            firstViewWidth = view.width
            outRect.left = parent.width / 2 - view.width / 2
            // If we have more items, use the spacing provided
            if (lm.itemCount > 1) {
                outRect.right = spacing / 2
            } else {
                // Otherwise, make sure this to fill the whole width with the decoration
                outRect.right = outRect.left
            }
        } else if (adapterPosition == lm.itemCount - 1) {
            // Invalidate decorations when this view width has changed
            if (view.width != lastViewWidth) {
                view.doOnPreDraw { parent.invalidateItemDecorations() }
            }
            lastViewWidth = view.width
            outRect.right = parent.width / 2 - view.width / 2
            outRect.left = spacing / 2
        } else {
            outRect.left = spacing / 2
            outRect.right = spacing / 2
        }
    }

}

Now, LinearSnapHelper determines the center of a view and includes its decorations. You can create a custom one that excludes the decorations from the calculation to center the view only:

/**
 * A LinearSnapHelper that ignores item decorations to determine a view's center
 */
class CenterSnapHelper : LinearSnapHelper() {

    private var verticalHelper: OrientationHelper? = null
    private var horizontalHelper: OrientationHelper? = null
    private var scrolled = false
    private var recyclerView: RecyclerView? = null
    private val scrollListener = object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            if (newState == RecyclerView.SCROLL_STATE_IDLE && scrolled) {
                if (recyclerView.layoutManager != null) {
                    val view = findSnapView(recyclerView.layoutManager)
                    if (view != null) {
                        val out = calculateDistanceToFinalSnap(recyclerView.layoutManager!!, view)
                        if (out != null) {
                            recyclerView.smoothScrollBy(out[0], out[1])
                        }
                    }
                }
                scrolled = false
            } else {
                scrolled = true
            }
        }
    }

    fun scrollTo(position: Int, smooth: Boolean) {
        if (recyclerView?.layoutManager != null) {
            val viewHolder = recyclerView!!.findViewHolderForAdapterPosition(position)
            if (viewHolder != null) {
                val distances = calculateDistanceToFinalSnap(recyclerView!!.layoutManager!!, viewHolder.itemView)
                if (smooth) {
                    recyclerView!!.smoothScrollBy(distances!![0], distances[1])
                } else {
                    recyclerView!!.scrollBy(distances!![0], distances[1])
                }
            } else {
                if (smooth) {
                    recyclerView!!.smoothScrollToPosition(position)
                } else {
                    recyclerView!!.scrollToPosition(position)
                }
            }
        }
    }

    override fun findSnapView(layoutManager: RecyclerView.LayoutManager?): View? {
        if (layoutManager == null) {
            return null
        }
        if (layoutManager.canScrollVertically()) {
            return findCenterView(layoutManager, getVerticalHelper(layoutManager))
        } else if (layoutManager.canScrollHorizontally()) {
            return findCenterView(layoutManager, getHorizontalHelper(layoutManager))
        }
        return null
    }

    override fun attachToRecyclerView(recyclerView: RecyclerView?) {
        this.recyclerView = recyclerView
        recyclerView?.addOnScrollListener(scrollListener)
    }

    override fun calculateDistanceToFinalSnap(
        layoutManager: RecyclerView.LayoutManager,
        targetView: View
    ): IntArray? {
        val out = IntArray(2)
        if (layoutManager.canScrollHorizontally()) {
            out[0] = distanceToCenter(layoutManager, targetView, getHorizontalHelper(layoutManager))
        } else {
            out[0] = 0
        }
        if (layoutManager.canScrollVertically()) {
            out[1] = distanceToCenter(layoutManager, targetView, getVerticalHelper(layoutManager))
        } else {
            out[1] = 0
        }
        return out
    }

    private fun findCenterView(
        layoutManager: RecyclerView.LayoutManager,
        helper: OrientationHelper
    ): View? {
        val childCount = layoutManager.childCount
        if (childCount == 0) {
            return null
        }
        var closestChild: View? = null
        val center: Int = if (layoutManager.clipToPadding) {
            helper.startAfterPadding + helper.totalSpace / 2
        } else {
            helper.end / 2
        }
        var absClosest = Integer.MAX_VALUE

        for (i in 0 until childCount) {
            val child = layoutManager.getChildAt(i)
            val childCenter = if (helper == horizontalHelper) {
                (child!!.x + child.width / 2).toInt()
            } else {
                (child!!.y + child.height / 2).toInt()
            }
            val absDistance = Math.abs(childCenter - center)

            if (absDistance < absClosest) {
                absClosest = absDistance
                closestChild = child
            }
        }
        return closestChild
    }

    private fun distanceToCenter(
        layoutManager: RecyclerView.LayoutManager,
        targetView: View,
        helper: OrientationHelper
    ): Int {
        val childCenter = if (helper == horizontalHelper) {
            (targetView.x + targetView.width / 2).toInt()
        } else {
            (targetView.y + targetView.height / 2).toInt()
        }
        val containerCenter = if (layoutManager.clipToPadding) {
            helper.startAfterPadding + helper.totalSpace / 2
        } else {
            helper.end / 2
        }
        return childCenter - containerCenter
    }

    private fun getVerticalHelper(layoutManager: RecyclerView.LayoutManager): OrientationHelper {
        if (verticalHelper == null || verticalHelper!!.layoutManager !== layoutManager) {
            verticalHelper = OrientationHelper.createVerticalHelper(layoutManager)
        }
        return verticalHelper!!
    }

    private fun getHorizontalHelper(
        layoutManager: RecyclerView.LayoutManager
    ): OrientationHelper {
        if (horizontalHelper == null || horizontalHelper!!.layoutManager !== layoutManager) {
            horizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager)
        }
        return horizontalHelper!!
    }
}

Usage:

class MainActivity : AppCompatActivity() {

    private val snapHelper = CenterSnapHelper()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        recyclerView.addItemDecoration(CenterDecoration(0))
        snapHelper.attachToRecyclerView(recyclerView)
        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
                val holder = object : RecyclerView.ViewHolder(
                    LayoutInflater.from(this@MainActivity).inflate(
                        R.layout.list_item,
                        parent,
                        false
                    )
                ) {}
                holder.itemView.setOnClickListener {
                    if (holder.adapterPosition != RecyclerView.NO_POSITION) {
                        snapHelper.scrollTo(holder.adapterPosition, true)
                    }
                }
                return holder
            }

            override fun getItemCount(): Int = 20

            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
                holder.itemView.textView.text = "pos:$position"
            }
        }

    }
}

Posting XML here in case someone wants to check this out:

MainActivity

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <View
        android:layout_width="4dp"
        android:layout_height="0dp"
        android:background="@color/colorAccent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

list_item.xml

<TextView
        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:id="@+id/textView"
        android:layout_width="wrap_content" android:layout_height="@dimen/list_item_size"
        android:background="?attr/selectableItemBackground" android:clickable="true"
        android:focusable="true" android:gravity="center" android:maxLines="1" android:padding="8dp"
        android:shadowColor="#222" android:shadowDx="1" android:shadowDy="1" android:textColor="#fff"
        tools:targetApi="m" tools:text="@tools:sample/lorem"/>

EDIT: here's a sample of how to use this:

http://s000.tinyupload.com/?file_id=01184747175525079378

android developer
  • 114,585
  • 152
  • 739
  • 1,270
Rúben Sousa
  • 1,389
  • 10
  • 8
  • Seems the chat was gone for some reason. I suggest putting the sample on Github. Was very useful. I've granted the bounty. Thank you for all your help. – android developer Dec 03 '18 at 22:56
  • Great solution, thanks! Also, How I can scroll only one item on time? I tried to do it via a custom layout manager but it doesn't work. I mean like on IOS camera mode – Ilnar Karimov Dec 11 '18 at 16:02
  • have a problem about add more data, only have half divider padding – Michael Mao Oct 31 '19 at 16:23
  • Here's one more thing to consider: margins. If the items have their own margin, then we should also treat it as a part of total width. Subtracting margin from `outRect` will do that: `outRect.left = parent.width / 2 - view.width / 2 - layoutParams.marginLeft` – Tura Jun 02 '20 at 07:30
0

This is how i solved it. The problem on custom snaphelper and decorators is that they dont work with other libaries and custom Views. It also works with items with variable widths.

If you want to snap the items, just use the classic snaphelper on the recyclerview

public class CenterRecyclerView extends RecyclerView {

    public CenterRecyclerView(@NonNull Context context) {
        super(context);
    }

    public CenterRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public CenterRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void updatePadding() {
        post(() -> {
            final DisplayMetrics displayMetrics = getContext().getResources().getDisplayMetrics();
            final int screenWidth = displayMetrics.widthPixels;
            final int screenHeight = displayMetrics.heightPixels;

            ViewHolder firstViewHolder = findViewHolderForAdapterPosition(0);
            if (firstViewHolder != null) {
                firstViewHolder.itemView.measure(WRAP_CONTENT, WRAP_CONTENT);
                int viewWidth = firstViewHolder.itemView.getMeasuredWidth();
                int padding;
                if (screenHeight > screenWidth) {
                    //Portrait
                    padding = screenWidth / 2 - viewWidth / 2;
                } else {
                    //Landscape
                    padding = screenHeight / 2 - viewWidth / 2;
                }
                setPadding(padding, 0, padding, 0);
            } else {
                Log.e("CenterRecyclerView", "Could not get first ViewHolder");
            }
        });
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        updatePadding();
    }

    @Override
    protected void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        updatePadding();
    }
}