2

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.

Shadow
  • 4,168
  • 5
  • 41
  • 72

3 Answers3

4

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.

Shadow
  • 4,168
  • 5
  • 41
  • 72
3

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
Pawel
  • 15,548
  • 3
  • 36
  • 36
  • I have no idea why this was not accepted as an answer when I did accept it almost on the same day as you posted, but hopefully it is gonna stay correctly marked this time. Also - worked perfectly and thank you so much for the explanations, I really value them. – Shadow Nov 30 '20 at 23:49
  • Can you clarify something for me? I've already used what I believed to be the equivalent value for `val lp =`, but just to make sure can you explain what you meant to pass on that line? Because layout params does not have a `absoluteAdapterPosition` option – Shadow Dec 20 '20 at 21:04
  • 1
    It exists in [`RecyclerView.LayoutParams`](https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/RecyclerView.LayoutParams#getAbsoluteAdapterPosition()). I've posted kotlin code that converted the getter into property, if you're using java you need to explicitly type in `getAbsoluteAdapterPosition()`. If it doesn't exist ensure your recyclerview import is up to date as it was added recently in favor of deprecated `getViewAdapterPosition`. – Pawel Dec 20 '20 at 21:19
  • It is strange, for me it shows as a non existing option (I even used it as is from your code, in kotlin). maybe I have to import a specific version of the RecyclerView that I do not know? – Shadow Dec 20 '20 at 21:22
  • I can, however, find it if I access it using a view holder: https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/RecyclerView.ViewHolder#getabsoluteadapterposition – Shadow Dec 20 '20 at 21:24
  • Can't find it the way you illustrated tho: https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.LayoutParams#getAbsoluteAdapterPosition() – Shadow Dec 20 '20 at 21:25
  • Found it, had to import the recyclerview version 1.2.0 and above to have that access to the new method, thanks! – Shadow Dec 20 '20 at 21:32
  • 1
    One improvement to this is the start and end paddings must be set when it is set for the first item if we want to use any snap pager helper, otherwise the first item will always be offset a bit until the recyclerview has a padding set on both sides since having only on one throws off its center position calculation. – Shadow Dec 21 '20 at 10:54
0

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.

sajjad
  • 834
  • 6
  • 13
  • Did you actually test your own suggestion? The width of the `onCreateViewHolder` item also returns 0 because it hasn't even been laid out yet. That means `(fullWidth-fr.getWidth())/2` will always be equal to `fullWidth/2` since `fr.getWidth()` is always returning 0. – Shadow Nov 16 '20 at 14:13
  • I tested and it worked except for the item width. Anyways, if each item of yours has fixed size, you may replace it. Otherwise this trick won't work for you. – sajjad Nov 16 '20 at 17:51
  • I literally said in my question "I do not want to set any fixed sizes because the items in the RecyclerView can have different sizes (...)" – Shadow Nov 16 '20 at 18:10