How would you give a vertical offset to items in a horizontal RecyclerView based on their position on the screen? As the user scrolls left or right, I want the objects to rise as they approach the middle and to lower as they approach the ends/sides of the screen.
Here's a picture of the effect I'm going for. Blue indicates scrolling left and right. Red indicates vertical offset for each item based on their position on the screen. I'd like for them to rise and lower smoothly based on position as the user scrolls left or right.
You have to create your custom ItemDecoration, which will look like something like this:
public class MyItemDecoration extends RecyclerView.ItemDecoration {
#Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
final int childCount = parent.getAdapter().getItemCount();
final int center = childCount >> 1;
final int currentPosition = parent.getChildAdapterPosition(view);
if (currentPosition < center) {
outRect.set(0, 0, 0, currentPosition * 10);
} else {
outRect.set(0, 0, 0, (childCount - currentPosition) * 10);
}
}
}
Usage:
recyclerView.addItemDecoration(new MyItemDecoration());
Result:
Related
I have a grid recycler view that I update using diff utils, but if something in the list data causes an item to go from any row below the first row, up to the first row then the decoration lacks padding top, this is kind of expected as I'm only adding padding to the top of an item in the list if it is in the first row. I then add bottom padding to every item,
Recycler View Decoration
public class GridSpacing extends RecyclerView.ItemDecoration {
private final int spacing;
private final int numColumns;
public GridSpacing(int spacing, int numColumns) {
this.spacing = spacing;
this.numColumns = numColumns;
}
#Override
public void getItemOffsets(#NonNull Rect outRect, #NonNull View view, #NonNull RecyclerView parent, #NonNull RecyclerView.State state) {
int position = parent.getChildLayoutPosition(view);
if (position < numColumns){
outRect.top = spacing;
}
if (position % 2 != 0) {
outRect.right = spacing;
}
outRect.left = spacing;
outRect.bottom = spacing;
}
}
If I call notifyDatasetChanged, this will recalculate my decoration and add the padding to the top row, but diff utils is not calling notifyDatasetChanged (think it calls a combination of item range changed, item inserted, item removed, to keep animations) so my decoration doesn't get reapplied, I could probably reapply it myself but that seems like a hack to have to remove and reapply the decoration every time an item changes, does anyone know of a better approach?
so instead of trying to reapply the decorator I'm going to remove the padding top in the decorator and instead add padding to the top and clip to padding false on the recycler view
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="8dp"
android:clipToPadding="false"
android:id="#+id/recyclerView"/>
I am new to android and hence RV and I am trying to achieve the layout where the first and last card are not centered and instead show more of the cards after and before them. Maybe at In this case I can see 16dp for the second cards and same thing for the penultimate card which makes the first and last card not centered.
But 8dp each for the rest of the cards so the intermediate cards appear centered. Maybe using itemDecoration somehow for the 2nd and the penultimate card somehow.
I was able to achieve showing parts of next and prev cards by following what is suggested here, but that only centers all the cards uniformly :
How to show part of next/previous card RecyclerView
I tried overriding getItemOffsets but it gets triggered everytime I scroll to the first or the last card and moves the 2nd and 2nd to last card incorrectly
and also doesn't center them correctly when I scroll to them.
public static class MyItemDecoration extends 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 itemPosition = parent.getChildAdapterPosition(view);
if (itemPosition == RecyclerView.NO_POSITION) {
return;
}
final int itemCount = state.getItemCount();
if (itemCount > 0 && itemPosition == 1) {
outRect.left -= 16;
outRect.right -= 16;
}
else if (itemCount > 0 && itemPosition == itemCount - 1) {
outRect.left += 16;
outRect.right += 16;
}
}
}
RV Setup
SnapHelper snapHelper = new PagerSnapHelper();
RecyclerView rv = getBinding().rv;
rv.setOnFlingListener(null);
snapHelper.attachToRecyclerView(rv);
PagerSnapHelper centers the RecyclerView items including the decorations, so, unless the decoration widths are balanced, they won't always be centered. This may be what you are seeing.
Try the following for the decoration. This code applies the full-width decoration to the start of the first item and the end of the last item; otherwise, a half decoration width is used. By setting up the decorations this way, you are centering items that have balanced left and right decorations.
DividerItemDecoration decoration =
new DividerItemDecoration(getApplicationContext(), HORIZONTAL) {
private int mDecorationWidth = (int) (getResources().getDisplayMetrics().density * 8);
#Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
final int pos = parent.getChildAdapterPosition(view);
if (pos == RecyclerView.NO_POSITION) {
return;
}
if (pos == 0) {
outRect.set(mDecorationWidth, 0, mDecorationWidth / 2, 0);
} else if (pos == parent.getAdapter().getItemCount() - 1) {
outRect.set(mDecorationWidth / 2, 0, mDecorationWidth, 0);
} else {
outRect.set(mDecorationWidth / 2, 0, mDecorationWidth / 2, 0);
}
}
};
Here is a video showing the results with gray vertical dividers.
If you already have the decorations working to your satisfaction, you can override calculateDistanceToFinalSnap() in PagerSnapHelper to center all views except the first and last view as follows. See calculatedistancetofinalsnap(). Once the PageSnapHelper identifies a target view to snap to, calculatedistancetofinalsnap() is called to determine how many pixels to move to perform the snap. Here, we are moving just enough pixels to center the view (without decorations) in the RecyclerView. PageSnapHelper does the right thing for the first and last items, so we just call the super for these.
PagerSnapHelper pagerSnapHelper = new PagerSnapHelper() {
#Override
public int[] calculateDistanceToFinalSnap(#NonNull RecyclerView.LayoutManager layoutManager,
#NonNull View targetView) {
LinearLayoutManager lm = (LinearLayoutManager) layoutManager;
int pos = mRecycler.getChildAdapterPosition(targetView);
// If first or last view, the default implementation works.
if (pos == 0 || pos == lm.getItemCount() - 1) {
return super.calculateDistanceToFinalSnap(layoutManager, targetView);
}
// Force centering in the view without its decorations.
// targetCenter is the location of the center of the view we want to center.
int targetCenter = targetView.getLeft() + targetView.getWidth() / 2;
// Distance is the number of pixels to move the target so that its center
// lines up with the center of the RecyclerView (mRecycler.getWidth() / 2)
int distance = targetCenter - mRecycler.getWidth() / 2;
return new int[]{distance, 0};
}
};
Either way will work.
Is that possible to add footer with item decoration, and not using adapter? Since I'm working with a very complex adapter with already lot of different viewholder types, I'd like to add an identical footer seamlessly to every list in my app.
As far as i can tell, this inst't best practice.
Here's description from RecyclerView.ItemDecoration class:
/**
* An ItemDecoration allows the application to add a special drawing and layout offset
* to specific item views from the adapter's data set. This can be useful for drawing dividers
* between items, highlights, visual grouping boundaries and more.
However You can set specific behaviour when implementing your own divider based on adapter viewtype divider has to deal with. Here's an example code i used in one online course:
public class Divider extends RecyclerView.ItemDecoration {
private Drawable mDivider;
private int mOrientation;
public Divider(Context context, int orientation) {
mDivider = ContextCompat.getDrawable(context, R.drawable.divider);
if (orientation != LinearLayoutManager.VERTICAL) {
throw new IllegalArgumentException("This Item Decoration can be used only with a RecyclerView that uses a LinearLayoutManager with vertical orientation");
}
mOrientation = orientation;
}
#Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (mOrientation == LinearLayoutManager.VERTICAL) {
drawHorizontalDivider(c, parent, state);
}
}
private void drawHorizontalDivider(Canvas c, RecyclerView parent, RecyclerView.State state) {
int left, top, right, bottom;
left = parent.getPaddingLeft();
right = parent.getWidth() - parent.getPaddingRight();
int count = parent.getChildCount();
for (int i = 0; i < count; i++) {
//here we check the itemViewType we deal with, you can implement your own behaviour for Footer type.
// In this example i draw a drawable below every item that IS NOT Footer, as i defined Footer as a button in view
if (Adapter.FOOTER != parent.getAdapter().getItemViewType(i)) {
View current = parent.getChildAt(i);
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) current.getLayoutParams();
top = current.getTop() - params.topMargin;
bottom = top + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
}
#Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
if (mOrientation == LinearLayoutManager.VERTICAL) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
}
}
Or you can use a library called Flexible Divider that allows for using a custom drawable or resource to be set.
I’m using a staggered recycler view layout for a list of photos. I want the spacing on the sides to be zero while still having space between the two columns. I’m using an item decoration sub class to get the spacing seen in the attached photo. I know I have control over the left and right spacing but the problem is that I never know which column the photo is in. It seems like the staggered layout manager does some of its own reordering. I've tried using getChildAdapterPosition but it seems to return the position in the data source array and not the actual position of the photo in the layout. Any idea how I should approach this?
I managed to get it working. In my case, I don't need any borders on the left or right edges of the screen. I just need borders in the middle and bottom. The solution is to get the layout parameters of the view that are of type StaggeredGridLayoutManager.LayoutParams. In those parameters you can get the spanIndex that tells you on which index the view is. So if you have a spanCount of 2, the left view will have a spanIndex of 0 and the right view will have a spanIndex of 1.
Here is my code, maybe it help you.
public class SpaceItemDecoration extends RecyclerView.ItemDecoration {
private int space;
public SpaceItemDecoration(int space) {
this.space = space;
}
#Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
int position = parent.getChildAdapterPosition(view);
StaggeredGridLayoutManager.LayoutParams lp = (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams();
int spanIndex = lp.getSpanIndex();
if (position > 0) {
if (spanIndex == 1) {
outRect.left = space;
} else {
outRect.right = space;
}
outRect.bottom = space * 2;
}
}
}
In my case, firstly I have to get the position, since on the index 0 I have a header View, which doesn't have any borders. After that, I get the span index and depending on it I set the borders that I need on that View. And finally I set the bottom border on every View.
so the one solution I was able to use was with an item decorator but it definitely is a little weird/hacky feeling.
Basically you'll adjust the outer rectangle of the item based on its column position (or something similar). My understanding is that the outer rectangle is more or less the spacing you want to change. Give the code below a try, obviously you'll need to make your own adjustments and logic to 'calculate' which column the item is on but this should be enough to figure it out, hopefully:
recyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
int left = outRect.left;
int right = outRect.right;
int top = outRect.top;
int bottom = outRect.bottom;
int idx = parent.getChildPosition(view);
int perRow = gridLayoutManager.getSpanCount();
int adj = blahh... // some adjustment
if (idx < itemsPerRow) {
// on first row, adjust top if needed
}
if(idx % perRow == 0){
// on first column, adjust. Left magically adjusts bottom, so adjust it too...
left += adj;
bottom -= adj;
}
if(idx % itemsPerRow == perRow - 1){
// on last column, adjust. Right magically adjusts bottom, so adjust it too...
right += adjustment;
bottom -= adjustment;
}
outRect.set(left, top, right, bottom);
}
});
Again this is hacky and takes some trial and error to get right.
Another solution I have tried with some success is to define different views for the different columns. In your case the columns would have views with different, negative margins, on the left and right to get the effect you want.
As a side note, I assume you are using an elevation on the card view. One thing I've noticed is that if the card view does NOT have elevation and instead you handle it yourself (yeah, i know, isn't the point to not handle elevation yourself) much of this difficulty goes away and things start to behave, likely because of the elevation/shadow calculations. But anyway... Hope this is at least somewhat helpful...
Sorry for the confusing title, I cannot express the problem very concisely...
I have an Android app with a ListView that uses a circular / "infinite" adapter, which basically means I can scroll it up or down as much as I want and the items will wrap around when it reaches the top or bottom, making it seem to the user as if he is spinning an infinitely long list of (~100) repeating items.
The point of this setup is to let the user select a random item, simply by spinning / flinging the listview and waiting to see where it stops. I decreased the friction of the Listview so it flings a bit faster and longer and this seems to work really nice. Finally I placed a partially transparent image on top of the ListView to block out the top and bottom items (with a transition from transparent to black), making it seem as if the user is "selecting" the item in the middle, as if they were on a rotating "wheel" that they control by flinging.
There is one obvious problem: after flinging the ListView does not stop at a particular item, but it can stop hovering between two items (where the first visible item is then only partially shown). I want to avoid this because in that case it is not obvious which item has been "randomly selected".
Long story short: after the ListView has finished scrolling after flinging, I want it to stop on a "whole" row, instead of on a partially visible row.
Right now I implemented this behavior by checking when the scrolling has stopped, and then selecting the first visible item, as such:
lv = this.getListView();
lv.setFriction(0.005f);
lv.setOnScrollListener(new OnScrollListener() {
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {}
public void onScrollStateChanged(AbsListView view, int scrollState) {
if (scrollState == OnScrollListener.SCROLL_STATE_IDLE)
{
if (isAutoScrolling) return;
isAutoScrolling = true;
int pos = lv.getFirstVisiblePosition();
lv.setSelection(pos);
isAutoScrolling = false;
}
}
});
This works reasonably well, apart from one glaringly obvious problem... The first visible item might only be visible for a pixel or two. In that case, I want the ListView to jump "up" for those two pixels so that the second visible item is selected. Instead, of course, the first visible item is selected which means the ListView jumps "down" almost an entire row (minus those two pixels).
In short, instead of jumping to the first visible item, I want it to jump to the item that is visible the most. If the first visible item is less than half visible, I want it to jump to the second visible item.
Here's an illustration that hopefully conveys my point. The left most ListView (of each pair) shows the state after flinging has stopped (where it comes to a halt), and the right ListView shows how it looks after it made the "jump" by selecting the first visible item. On the left I show the current (wrong) situation: Item B is only barely visible, but it is still the first visible item so the listView jumps to select that item - which is not logical because it has to scroll almost an entire item height to get there. It would be much more logical to scroll to Item C (which is depicted on the right) because that is "closer".
(source: nickthissen.nl)
How can I achieve this behavior? The only way I can think of is to somehow measure how much of the first visible item is visible. If that is more than 50%, then I jump to that position. If it is less than 50%, I jump to that position + 1. However I have no clue how to measure that...
Any idea's?
You can get the visible dimensions of a child using the getChildVisibleRect method. When you have that, and you get the total height of the child, you can scroll to the appropriate child.
In the example below I check whether at least half of the child is visible:
View child = lv.getChildAt (0); // first visible child
Rect r = new Rect (0, 0, child.getWidth(), child.getHeight()); // set this initially, as required by the docs
double height = child.getHeight () * 1.0;
lv.getChildVisibleRect (child, r, null);
if (Math.abs (r.height ()) < height / 2.0) {
// show next child
}
else {
// show this child
}
Here's my final code inspired by Shade's answer.
I forgot to add "if(Math.abs(r.height())!=height)" at first. Then it just scrolls twice after it scroll to correct position because it's always greater than height/2 of childView.
Hope it helps.
listView.setOnScrollListener(new AbsListView.OnScrollListener(){
#Override
public void onScrollStateChanged(AbsListView view,int scrollState) {
if (scrollState == SCROLL_STATE_IDLE){
View child = listView.getChildAt (0); // first visible child
Rect r = new Rect (0, 0, child.getWidth(), child.getHeight()); // set this initially, as required by the docs
double height = child.getHeight () * 1.0;
listView.getChildVisibleRect (child, r, null);
if(Math.abs(r.height())!=height){//only smooth scroll when not scroll to correct position
if (Math.abs (r.height ()) < height / 2.0) {
listView.smoothScrollToPosition(listView.getLastVisiblePosition());
}
else if(Math.abs (r.height ()) > height / 2.0){
listView.smoothScrollToPosition(listView.getFirstVisiblePosition());
}
else{
listView.smoothScrollToPosition(listView.getFirstVisiblePosition());
}
}
}
}
#Override
public void onScroll(AbsListView view, int firstVisibleItem,int visibleItemCount, int totalItemCount) {
}});
Follow these 3 steps, then you can get exactly what you want!!!!
1.Initialize the two variable for scrolling up and down:
int scrollingUp=0,scrollingDown=0;
2.Then increment the value of the variable based on scrolling:
#Override
public void onScroll(AbsListView view, int firstVisibleItem,
int visibleItemCount, int totalItemCount) {
if(mLastFirstVisibleItem<firstVisibleItem)
{
scrollingDown=1;
}
if(mLastFirstVisibleItem>firstVisibleItem)
{
scrollingUp=1;
}
mLastFirstVisibleItem=firstVisibleItem;
}
3.Then do the changes in the onScrollStateChanged():
#Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
switch (scrollState) {
case SCROLL_STATE_IDLE:
if(scrollingUp==1)
{
mainListView.post(new Runnable() {
public void run() {
View child = mainListView.getChildAt (0); // first visible child
Rect r = new Rect (0, 0, child.getWidth(), child.getHeight()); // set this initially, as required by the docs
double height = child.getHeight () * 1.0;
mainListView.getChildVisibleRect (child, r, null);
int dpDistance=Math.abs (r.height());
double minusDistance=dpDistance-height;
if (Math.abs (r.height()) < height/2)
{
mainListView.smoothScrollBy(dpDistance, 1500);
}
else
{
mainListView.smoothScrollBy((int)minusDistance, 1500);
}
scrollingUp=0;
}
});
}
if(scrollingDown==1)
{
mainListView.post(new Runnable() {
public void run() {
View child = mainListView.getChildAt (0); // first visible child
Rect r = new Rect (0, 0, child.getWidth(), child.getHeight()); // set this initially, as required by the docs
double height = child.getHeight () * 1.0;
mainListView.getChildVisibleRect (child, r, null);
int dpDistance=Math.abs (r.height());
double minusDistance=dpDistance-height;
if (Math.abs (r.height()) < height/2)
{
mainListView.smoothScrollBy(dpDistance, 1500);
}
else
{
mainListView.smoothScrollBy((int)minusDistance, 1500);
}
scrollingDown=0;
}
});
}
break;
case SCROLL_STATE_TOUCH_SCROLL:
break;
}
}
You probably solved this problem but I think that this solution should work
if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) {
View firstChild = lv.getChildAt(0);
int pos = lv.getFirstVisiblePosition();
//if first visible item is higher than the half of its height
if (-firstChild.getTop() > firstChild.getHeight()/2) {
pos++;
}
lv.setSelection(pos);
}
getTop() for first item view always return nonpositive value so I don't use Math.abs(firstChild.getTop()) but just -firstChild.getTop(). Even if this value will be >0 then this condition is still working.
If you want to make this smoother then you can try to use lv.smoothScrollToPosition(pos) and enclose all above piece of code in
if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) {
post(new Runnable() {
#Override
public void run() {
//put above code here
//and change lv.setSelection(pos) to lv.smoothScrollToPosition(pos)
}
});
}
Once you know the first visible position, you should be able to use View.getLocationinWindow() or View.getLocationOnScreen() on the next position's view to get the visible height of the first. Compare that to the View's height, and scroll to the next position if appropriate.
You may need to tweak it to account for padding, depending on what your rows look like.
I haven't tried the above, but it seems like it should work. If it doesn't, here's another, probably less robust idea:
getLastVisiblePosition(). If you take the difference between last and first, you can see how many positions are visible on the screen. Compare that to how many positions were visible when the list was first populated(scroll position 0).
If the same number of positions are visible, simply scroll to the first visible position as you are doing. If there is one more visible, scroll to "first + 1" position.
If you can get the position of the row that needs to be scrolled to, you can use the method:
smoothScrollToPosition
So something like:
int pos = lv.getFirstVisiblePosition();
lv.smoothScrollToPosition(pos);
Edit
Try this, sorry I don't have time to test, I'm out and about.
ImageView iv = //Code to find the image view
Rect rect = new Rect(iv.getLeft(), iv.getTop(), iv.getRight(), iv.getBottom());
lv.requestChildRectangleOnScreen(lv, rect, false);
My Solution:
#Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)
{
if (swipeLayout.isRefreshing()) {
swipeLayout.setRefreshing(false);
} else {
int pos = firstVisibleItem;
if (pos == 0 && lv_post_list.getAdapter().getCount()>0) {
int topOfNext = lv_post_list.getChildAt(pos + 1).getTop();
int heightOfFirst = lv_post_list.getChildAt(pos).getHeight();
if (topOfNext > heightOfFirst) {
swipeLayout.setEnabled(true);
} else {
swipeLayout.setEnabled(false);
}
}
else
swipeLayout.setEnabled(false);
}
}