Search code examples
androidandroid-recyclerviewcenteringitem-decoration

How can I properly center the first and last items in a horizontal RecyclerView


StackOverflow contains a lot of questions like this one, but so far absolutely no solution works 100%.

I tried the solutions for these:

RecyclerView ItemDecoration - How to draw a different width divider for every viewHolder?

first item center aligns in SnapHelper in RecyclerView

Horizontally center first item of RecyclerView

LinearSnapHelper doesn't snap on edge items of RecyclerView

Android Centering Item in RecyclerView

How to make recycler view start adding items from center?

How to have RecyclerView snapped to center and yet be able to scroll to all items, while the center is "selected"?

How to snap to particular position of LinearSnapHelper in horizontal RecyclerView?


And every time the first item fails to be centered correctly.

Sample code of what I am using

    recyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
        @Override
        public void getItemOffsets(
            @NonNull Rect outRect,
            @NonNull View view,
            @NonNull RecyclerView parent,
            @NonNull RecyclerView.State state
        ) {

            super.getItemOffsets(outRect, view, parent, state);

            final int count = state.getItemCount();
            final int position = parent.getChildAdapterPosition(view);

            if (position == 0 || position == count - 1) {

                int offset = (int) (parent.getWidth() * 0.5f - view.getWidth() * 0.5f);

                if (position == 0) {
                    setupOutRect(outRect, offset, true);
                } else if (position == count - 1) {
                    setupOutRect(outRect, offset, false);
                }

            }

        }

        private void setupOutRect(Rect rect, int offset, boolean start) {
            if (start) {
                rect.left = offset;
            } else {
                rect.right = offset;
            }
        }

    });

After investigating I discovered that is because at the time of the getItemOffsets the view.getWidth is 0, it hasn't been measured yet.

I tried to force it to be measured, but every single time it gives an incorrect size, nothing like the actual size it occupies, it is smaller.

I also tried to use the addOnGlobalLayoutListener trick, but by the time it is called and has the correct width, the outRect was already consumed, so it is lost.

I do not want to set any fixed sizes because the items in the RecyclerView can have different sizes, so setting its padding in advance is not an option.

I also do not want to add "ghost" items to fill the space and those also don't work well for the scrolling experience.

How can I get this working properly?

Ideally the ItemDecorator method looks to be the best, but it falls flat for the first item right away.


Solution

  • You can alter padding of RecyclerView itself to get this effect too (as long as clipToPadding is disabled). We can intercept first layout phase in LayoutManager so it can use updated padding even when laying out items for the first time:

    Add this layout manager:

    open class CenterLinearLayoutManager : LinearLayoutManager {
        constructor(context: Context) : super(context)
        constructor(context: Context, orientation: Int, reverseLayout: Boolean) : super(context, orientation, reverseLayout)
        constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
    
        private lateinit var recyclerView: RecyclerView
    
        override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
            // always measure first item, its size determines starting offset
            // this must be done before super.onLayoutChildren
            if (childCount == 0 && state.itemCount > 0) {
                val firstChild = recycler.getViewForPosition(0)
                measureChildWithMargins(firstChild, 0, 0)
                recycler.recycleView(firstChild)
            }
            super.onLayoutChildren(recycler, state)
        }
    
        override fun measureChildWithMargins(child: View, widthUsed: Int, heightUsed: Int) {
            val lp = (child.layoutParams as RecyclerView.LayoutParams).absoluteAdapterPosition
            super.measureChildWithMargins(child, widthUsed, heightUsed)
            if (lp != 0 && lp != itemCount - 1) return
            // after determining first and/or last items size use it to alter host padding
            when (orientation) {
                HORIZONTAL -> {
                    val hPadding = ((width - child.measuredWidth) / 2).coerceAtLeast(0)
                    if (!reverseLayout) {
                        if (lp == 0) recyclerView.updatePaddingRelative(start = hPadding)
                        if (lp == itemCount - 1) recyclerView.updatePaddingRelative(end = hPadding)
                    } else {
                        if (lp == 0) recyclerView.updatePaddingRelative(end = hPadding)
                        if (lp == itemCount - 1) recyclerView.updatePaddingRelative(start = hPadding)
                    }
                }
                VERTICAL -> {
                    val vPadding = ((height - child.measuredHeight) / 2).coerceAtLeast(0)
                    if (!reverseLayout) {
                        if (lp == 0) recyclerView.updatePaddingRelative(top = vPadding)
                        if (lp == itemCount - 1) recyclerView.updatePaddingRelative(bottom = vPadding)
                    } else {
                        if (lp == 0) recyclerView.updatePaddingRelative(bottom = vPadding)
                        if (lp == itemCount - 1) recyclerView.updatePaddingRelative(top = vPadding)
                    }
                }
            }
        }
    
        // capture host recyclerview
        override fun onAttachedToWindow(view: RecyclerView) {
            recyclerView = view
            super.onAttachedToWindow(view)
        }
    }
    

    Then use it for your RecyclerView:

    recyclerView.layoutManager = CenterLinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
    recyclerView.clipToPadding = false // disabling clip to padding is critical