Reputation: 4763
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 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
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
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
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