I have a RecyclerView of items and a layoutManager that is of type StaggeredGridLayoutManager. I was in an interesting situation, I wanted my items staggered to look like this:
but my views are all the same size, so they would not stagger. To correct the problem I needed to add an offset at the start of the 2nd column. Since I was also creating my own custom decorator class I figured the best way to accomplish this was to just add an offset for the first right column item in my list using the getItemsOffsets method.
Here is the relevant code for my decorator class:
public class StampListDecoration extends RecyclerView.ItemDecoration {
...
#Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
// good example here: https://stackoverflow.com/questions/29666598/android-recyclerview-finding-out-first-and-last-view-on-itemdecoration/30404499#30404499
/**
* Special case. Te first right side item in the list should have an extra 50% top
* offset so that these equal sized views are perfectly staggered.
*/
if (parent.getChildAdapterPosition(view) == 1) {
/**
* We would normally do a outRect.top = view.getHeight()/2 to create a 50% top offset on the first right item in the list.
* However, problems would arise if we paused the app when the top right item was scrolled off screen.
* In this situation, when we re-inflated the recyclerview since the view was off screen
* Android would say the height of the view was zero. So instead I added code that
* looked for the height of the top most view that was visible (and would therefore
* have a height.
*
* see https://stackoverflow.com/questions/29463560/findfirstvisibleitempositions-doesnt-work-for-recycleview-android
* because as a staggeredGrid layout you have a special case first visible method
* findFirstVisibleItemPositions that returns an array of (notice the S on the end of
* the method name.
*/
StaggeredGridLayoutManager layoutMngr = ((StaggeredGridLayoutManager) parent.getLayoutManager());
int firstVisibleItemPosition = layoutMngr.findFirstVisibleItemPositions(null)[0];
int topPos = 0;
try {
topPos = parent.getChildAt(firstVisibleItemPosition).getMeasuredHeight()/2;
} catch (Exception e) {
e.printStackTrace();
}
outRect.set(0, topPos, 0, 0);
} else {
outRect.set(0, 0, 0, 0);
}
}
}
my problem is that these offsets are not getting saved to state when my activity pauses/resumes. So when I switch to another app and switch back, the right column in my RecyclerView slides back to the top...and I lose my stagger.
Can someone show me how to save my offset state? Where are offsets supposed to be saved? I was assuming the LayoutManager would save this information, and I'm saving the LayoutManager state, but that does not seem to be working.
Background:
I'm developing a tournament management software and I want to show the current contenders classification on a screen that is feed by an android device.
The number of contenders exceeds the vertical size of the screen, so I would like to show it using a nice vertical animate smoth scroll from bottom to top that would be endless (when the list shows the last element it would start showing the first as it would be a loop.
Approach:
After some research (sorry I don't have the all the references) I come up with the following implementation:
I'm using a recyclerview with an adapter on a fragment. In order to automatically scroll it up I have added a timer that would scroll the recycleview on fixed itervals:
CountUpTimer recyclerViewAutoScroll = new CountUpTimer(1) {
public void onTick(long millis) {
recyclerView.scrollBy(0, mSpeed * 2);
if (!recyclerView.canScrollVertically(1)) {
recyclerView.scrollToPosition(0);
}
}
};
This timer is being created and launched on a global layout listener:
#Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
layoutListener = () -> {
recyclerView.post(() -> recyclerViewAutoScroll.start());
recyclerView.getViewTreeObserver().addOnGlobalLayoutListener(layoutListener);
}
The CountUpTimer class is just an "infinite" timer implemented using an android CountDowntimer.
With this piece of code I can scroll up at regular times, the problem is that is not smoth at all. There are two parameters to play with: the timer tick time, and the amount of vertical scroll that is made each time the timer "ticks". The only way to make it "smooth" is to reducing so much the scroll movement that it ended being very very very slow for the user.
Just for completeness, to achieve the endless scroll efect I added an scroll listener to the recycler view:
recyclerView.addOnScrollListener(new EndlessRecyclerOnScrollListener(5) {
#Override
public void onLoadMore(int currentPage) {
Timber.d("new Page " + currentPage);
recyclerView.post(() ->
{
itemAdapter.addModel(currentClassificationList);
if (currentPage > 2)
itemAdapter.removeModelRange(0, currentClassificationList.size());
});
}
What I do there is just add twice the classificationList to have enough space on the bottom to show the top of the list after the last element, and I keep adding the list to the bottom and removing from the top every time the recyclerview is about to reach the end of the list.
So my question is: How can I implement a really smoth scroll animation of the recyclerview? As I said this is being shown WITHOUT any user interaction, just on a screen where the classification is continuously being shown and scrolled.
UPDATE
After one comment suggestion I substituted the count timer by a TimeAnimator class this way:
timeAnimator.setTimeListener(new TimeAnimator.TimeListener() {
#Override
public void onTimeUpdate(TimeAnimator timeAnimator, long l, long l1) {
Timber.d("elapsed: " + l1);
recyclerView.scrollBy(0, (int)(l1/ mSpeed));
if (!recyclerView.canScrollVertically(1)) {
recyclerView.scrollToPosition(0);
}
}
});
Also I removed the post calls as they are not needed.
Now the smoothness has improved, but is not perfect, it still hessitate a bit when a new item is about to appear from the bottom.
I'm looking now to the itemadapter to improve any calculation made there and any image loading (I added Picasso for image loading also) But still it is not perfect, they are some flickering there.
I have a callback that is fired after a programmatic scroll to a certain item of a RecyclerView via LinearLayoutManager.smoothScrollToPosition(). The user taps on an item and the right item is scrolled to the top of the RecyclerView. I subclassed LinearLayoutManager to have it always snap to the top of the item.
This works in case the scroll event is fired, but when the RecyclerView is already in the right position, I don't get the onScrollStateChanged callback, as no scrolling occurs. Is there a way to get that event anyway? Like decide beforehand whether or not the RecyclerView needs to scroll or not?
Hope the following code would help
if(LinearLayoutManager.findFirstCompletelyVisibleItem() == yourDesiredPosition) {
//do your stuff
} else {
LinearLayoutManager.scrollToPositionWithOffset(yourDesiredPosition, offset);
//onScrollStateChanged would be trigger then.
}
I found the following solution myself:
// get the view the user selected
View view = mLayoutManager.findViewByPosition(index);
// get top offset
int offset = view.getTop();
if (offset == 0) { // the view is at the top of the scrollview
showDetailViewInternal(event);
} else {
// scrolling
}
Goal
Build a Circular ViewPager.
The first element lets you peak to the last element and swipe to it, and vice versa. You should be able to swipe in either direction forever.
Now this has been accomplished before, but these questions do not work for my implementation. Here are a few for reference:
how to create circular viewpager?
ViewPager as a circular queue / wrapping
https://github.com/antonyt/InfiniteViewPager
How I Tried to Solve the Problem
We will use an array of size 7 as an example. The elements are as follows:
[0][1][2][3][4][5][6]
When you are at element 0, ViewPagers do not let you swipe left! How terrible :(. To get around this, I added 1 element to the front and end.
[0][1][2][3][4][5][6] // Original
[0][1][2][3][4][5][6][7][8] // New mapping
When the ViewPageAdapter asks for (instantiateItem()) element 0, we return element 7. When the ViewPageAdapter asks for element 8 we return element 1.
Likewise in the OnPageChangeListener in the ViewPager, when the onPageSelected is called with 0, we setCurrentItem(7), and when it's called with 8 we setCurrentItem(1).
This works.
The Problem
When you swipe to the left from 1 to 0, and we setCurrentItem(7), it will animate all the way to right by 6 full screens. This doesn't give the appearance of a circular ViewPager, it gives the appearence rushing to the last element in the opposite direction the user requested with their swipe motion!
This is very very jarring.
How I Tried to Solve This
My first inclination was to turn off smooth (ie, all) animations. It's a bit better, but it's now choppy when you move from the last element to the first and vice versa.
I then made my own Scroller.
http://developer.android.com/reference/android/widget/Scroller.html
What I found was that there is always 1 call to startScroll() when moving between elements, except when I move from 1 to 7 and 7 to 1.
The first call is the correct animation in direction and amount.
The second call is the animation that moves everything to the right by multiple pages.
This is where things got really tricky.
I thought the solution was to just skip the second animation. So I did. What happens is a smooth animation from 1 to 7 with 0 hiccups. Perfect! However, if you swipe, or even tap the screen, you are suddenly (with no animation) at element 6! If you had swiped from 7 to 1, you'll actually be at element 2. There is no call to setCurrentItem(2) or even a call to the OnPageChangeListener indicating that you arrived at 2 at any point in time.
But you're not actually at element 2, which is kind of good. You are still at element 1, but the view for element 2 will be shown. And then when you swipe to the left, you go to element 1. Even though you were really at element 1 already.. How about some code to help clear things up:
Animation is broken, but no weird side effects
#Override
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
super.startScroll(startX, startY, dx, dy, duration);
}
Animation works! But everything is strange and scary...
#Override
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
if (dx > 480 || dx < -480) {
} else {
super.startScroll(startX, startY, dx, dy, duration);
}
}
The ONLY difference is that when the second animation (bigger than the width of the 480 pixel screen) is called, we ignore it.
After reading through the Android Source code for Scroller, I found that startScroll does not start scrolling anything. It sets up all the data to be scrolled, but doesn't initiate anything.
My Hunch
When you do the circular action (1 to 7 or 7 to 1), there are two calls to startScroll(). I think something in between the two calls is causing an issue.
User scrolls from element 1 to element 7 causing a jump from 0 to 7. This should animate to the left.
startScroll() is called indicating a short animation to the left.
STUFF HAPPENS THAT MAKES ME CRY PROBABLY I THINK
startScroll() is called indicating a long animation to the right.
Long animation to the right occurs.
If I comment out 4, then 5 becomes "Short correct animation to the left, things go crazy"
Summary
My implementation of a Circular ViewPager works, but the animation is broken. Upon trying to fix the animation, it breaks the functionality of the ViewPager. I am currently spinning my wheels trying to figure out how to make it work. Help me! :)
If anything is unclear please comment below and I will clarify. I realize I was not very precise with how things are broken. It's difficult to describe because it's not even clear what I'm seeing on the screen. If my explanation is an issue I can work on it, let me know!
Cheers,
Coltin
Code
This code is slightly modified to make it more readable on its own, though the functionality is identical to my current iteration of the code.
OnPageChangeListener.onPageSelected
#Override
public void onPageSelected(int _position) {
boolean animate = true;
if (_position < 1) {
// Swiping left past the first element, go to element (9 - 2)=7
setCurrentItem(getAdapter().getCount() - 2, animate);
} else if (_position >= getAdapter().getCount() - 1) {
// Swiping right past the last element
setCurrentItem(1, animate);
}
}
CircularScroller.startScroll
#Override
public void startScroll(int _startX, int _startY, int _dx, int _dy, int _duration) {
// 480 is the width of the screen
if (dx > 480 || dx < -480) {
// Doing nothing in this block shows the correct animation,
// but it causes the issues mentioned above
// Uncomment to do the big scroll!
// super.startScroll(_startX, _startY, _dx, _dy, _duration);
// lastDX was to attempt to reset the scroll to be the previous
// correct scroll distance; it had no effect
// super.startScroll(_startX, _startY, lastDx, _dy, _duration);
} else {
lastDx = _dx;
super.startScroll(_startX, _startY, _dx, _dy, _duration);
}
}
CircularViewPageAdapter.CircularViewPageAdapter
private static final int m_Length = 7; // For our example only
private static Context m_Context;
private boolean[] created = null; // Not the best practice..
public CircularViewPageAdapter(Context _context) {
m_Context = _context;
created = new boolean[m_Length];
for (int i = 0; i < m_Length; i++) {
// So that we do not create things multiple times
// I thought this was causing my issues, but it was not
created[i] = false;
}
}
CircularViewPageAdapter.getCount
#Override
public int getCount() {
return m_Length + 2;
}
CircularViewPageAdapter.instantiateItem
#Override
public Object instantiateItem(View _collection, int _position) {
int virtualPosition = getVirtualPosition(_position);
if (created[virtualPosition - 1]) {
return null;
}
TextView tv = new TextView(m_Context);
// The first view is element 1 with label 0! :)
tv.setText("Bonjour, merci! " + (virtualPosition - 1));
tv.setTextColor(Color.WHITE);
tv.setTextSize(30);
((ViewPager) _collection).addView(tv, 0);
return tv;
}
CircularViewPageAdapter.destroyItem
#Override
public void destroyItem(ViewGroup container, int position, Object view) {
ViewPager viewPager = (ViewPager) container;
// If the virtual distance is distance 2 away, it should be destroyed.
// If it's not intuitive why this is the case, please comment below
// and I will clarify
int virtualDistance = getVirtualDistance(viewPager.getCurrentItem(), getVirtualPosition(position));
if ((virtualDistance == 2) || ((m_Length - virtualDistance) == 2)) {
((ViewPager) container).removeView((View) view);
created[getVirtualPosition(position) - 1] = false;
}
}
I think the best doable approach would be instead of using a normal list to have a wrapper to the List that when the get(pos) method is executed to obtain the object to create the view, you make something like this get(pos % numberOfViews) and when it ask for the size of the List you put that the List is Integer.MAX_VALUE and you start your List in the middle of it so you can say that is mostly impossible to have an error, unless they actually swipe to the same side until the reach the end of the List. I will try to post a proof of concept later this weak if the time allows me to do so.
EDIT:
I have tried this piece of code, i know is a simple textbox shown on each view, but the fact is that it works perfectly, it might be slower depending on the total amount of views but the proof of concept is here. What i have done is that the MAX_NUMBER_VIEWS represents what is the maximum numbers of times a user can completely give before he is stopped. and as you can see i started the viewpager at the length of my array so that would be the second time it appears so you have one turn extra to the left and right but you can change it as you need it. I hope i do not get more negative points for a solution that in fact does work.
ACTIVITY:
pager = (ViewPager)findViewById(R.id.viewpager);
String[] articles = {"ARTICLE 1","ARTICLE 2","ARTICLE 3","ARTICLE 4"};
pager.setAdapter(new ViewPagerAdapter(this, articles));
pager.setCurrentItem(articles.length);
ADAPTER:
public class ViewPagerAdapter extends PagerAdapter {
private Context ctx;
private String[] articles;
private final int MAX_NUMBER_VIEWS = 3;
public ViewPagerAdapter(Context ctx, String[] articles) {
this.ctx = ctx;
this.articles = articles.clone();
}
#Override
public int getCount() {
return articles.length * this.MAX_NUMBER_VIEWS;
}
#Override
public Object instantiateItem(ViewGroup container, int position) {
TextView view = new TextView(ctx);
view.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT));
int realPosition = position % articles.length;
view.setText(this.articles[realPosition]);
((ViewPager) container).addView(view);
return view;
}
#Override
public void destroyItem(ViewGroup container, int position, Object object) {
((ViewPager) container).removeView((View) object);
}
#Override
public boolean isViewFromObject(View view, Object object) {
return view == ((View) object);
}
#Override
public Parcelable saveState() {
return null;
}
}
I am using it, but it always returns 0, even though I have scrolled till the end of the list.
getScrollY() is actually a method on View, not ListView. It is referring to the scroll amount of the entire view, so it will almost always be 0.
If you want to know how far the ListView's contents are scrolled, you can use listView.getFirstVisiblePosition();
It does work, it returns the top part of the scrolled portion of the view in pixels from the top of the visible view. See the getScrollY() documentation. Basically if your list is taking up the full view then you will always get 0, because the top of the scrolled portion of the list is always at the top of the screen.
What you want to do to see if you are at the end of a list is something like this:
public void onCreate(final Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.main);
// The list defined as field elswhere
this.view = (ListView) findViewById(R.id.searchResults);
this.view.setOnScrollListener(new OnScrollListener() {
private int priorFirst = -1;
#Override
public void onScroll(final AbsListView view, final int first, final int visible, final int total) {
// detect if last item is visible
if (visible < total && (first + visible == total)) {
// see if we have more results
if (first != priorFirst) {
priorFirst = first;
//Do stuff here, last item is displayed, end of list reached
}
}
}
});
}
The reason for the priorFirst counter is that sometimes scroll events can be generated multiple times, so you only need to react to the first time the end of the list is reached.
If you are trying to do an auto-growing list, I'd suggest this tutorial.
You need two things to precisely define the scroll position of a listView:
To get current position:
int firstVisiblePosition = listView.getFirstVisiblePosition();
int topEdge=listView.getChildAt(0).getTop(); //This gives how much the top view has been scrolled.
To set the position:
listView.setSelectionFromTop(firstVisiblePosition,0);
// Note the '-' sign for scrollTo..
listView.scrollTo(0,-topEdge);