Shadow
Shadow

Reputation: 4763

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.

Upvotes: 3

Views: 3016

Answers (3)

Shadow
Shadow

Reputation: 4763

I have been using the CenterLinearLayoutManager from Pawel's answer and so far it has been working almost perfectly. I say almost, not because it is not working straight away as it is, but because I ended up using a LinearSnapHelper in the same RecyclerView that makes use of the mentioned layout manager.

Because the snap helper depends on knowing the RecyclerView paddings to calculate its correct center, setting just the padding for the first item throws off this process, causing the first item (and subsequent items until the last one actually shows) to be offset from the center.

My solution was to ensure that both paddings are set right from the start when the first item is shown.

So this:

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)
  }

becomes this

if (!reverseLayout) {
    if (lp == 0) recyclerView.updatePaddingRelative(start = hPadding, end = hPadding) // here we set the same padding for both sides
    if (lp == itemCount - 1) recyclerView.updatePaddingRelative(end = hPadding)
   } else {
    if (lp == 0) recyclerView.updatePaddingRelative(end = hPadding, start = hPadding) // here we set the same padding for both sides
    if (lp == itemCount - 1) recyclerView.updatePaddingRelative(start = hPadding)
   }

And I assume the same logic must be applied to the vertical block as well.

I am sure this can be now optimized even further, so the above final block would look like this:

if (lp == 0) recyclerView.updatePaddingRelative(start = hPadding, end = hPadding) // here we set the same padding for both sides
if (lp == itemCount - 1) {
   if (!reverseLayout) recyclerView.updatePaddingRelative(end = hPadding)
   if (reverseLayout) recyclerView.updatePaddingRelative(start = hPadding)
}

NOTE: I ended up finding that if I use items with significant width differences between them the same issue I refer here also occurs when the last item loads, which makes sense since at that point we are setting a padding on one side different than the other one, again throwing off the native center calculation.

The solution for this one is to set the same padding for both sides whenever the first item loads and the last one as well like so

recyclerView.updatePaddingRelative(start = hPadding, end = hPadding)

That's literally it, no ifs like in the previous sample.

This, of course, still not solves the issue for when there are too few items showing in such a way that the first and last items are visible, but when I manage to find a solution for that specific case I will update it here.

Upvotes: 4

Pawel
Pawel

Reputation: 17258

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

Upvotes: 4

sajjad
sajjad

Reputation: 847

From what I get, you want your first and last items be in the center of your recyclerview. If so, I would recommend a much simpler workaround.

public class OverlaysAdapter extends RecyclerView.Adapter<OverlaysAdapter2.CategoryViewHolder> {


private int fullWidth;//gets the recyclerview full width in constructor. In my case it is full display width.

@Override
public CategoryViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    FrameLayout fr = (FrameLayout) inflater.inflate(R.layout.item_sticker, parent, false);
    if (viewType==TYPE_LEFT_ITEM) {
        int marginLeft = (fullWidth-leftItemWidth)/2;
        ((RecyclerView.LayoutParams)fr.getLayoutParams()).setMargins(marginLeft, 0,0,0);
    } else if (viewType==TYPE_RIGHT_ITEM) {
        int marginRight = (fullWidth-rightItemWidth)/2;
        ((RecyclerView.LayoutParams)fr.getLayoutParams()).setMargins(0, 0,marginRight,0);
    } else if (viewType==TYPE_MIDDLE_ITEM) {
        ((RecyclerView.LayoutParams)fr.getLayoutParams()).setMargins(0, 0,0,0);
    }
    return new CategoryViewHolder(fr);
}


private final int TYPE_LEFT_ITEM = 1;
private final int TYPE_MIDDLE_ITEM = 2;
private final int TYPE_RIGHT_ITEM = 3;

@Override
public int getItemViewType(int position) {
    if (position==0)
        return TYPE_RIGHT_ITEM;
    else if (position==items.size()-1)
        return TYPE_LEFT_ITEM;
    else
        return TYPE_MIDDLE_ITEM;
}

}

The idea is simple. Define three view types, for first, middle and last items. Mind the item view type and calculate the needed margins and set the margins.

Note that in my case, the left margin goes for the last item and the right margin goes for the first item, as the layout is always RTL. You may want to use the reverse order.

Upvotes: 0

Related Questions