24

I've been trying for a while to get smoothScrollToPositionFromTop() working, but it doesn't always scroll to the correct position.

I've got a ListView (with 10 items) in a layout with 10 buttons on the side, so I can scroll to every item in the list. Usually when I scroll one position back or forward it works fine, but often when I try to scroll more then 3 positions back or forward the ListView does not exactly end at the selected position. When it fails, it usually ends up 0,5 to 1,5 items off and it is not really predictable when the scroll fails.

I have also checked out smoothScrollToPosition after notifyDataSetChanged not working in android, but this fix is not working for me and I don't change any data.

I would really like to automatically scroll to the selected listitems, but not I can't figure out how. Has anybody had this problem before and knows how to fix it?

Community
  • 1
  • 1
mennovogel
  • 339
  • 1
  • 2
  • 9
  • setSelection() works but it is not smooth. The function has bugs. If the item is in the layout it does not do anything. I suffer from it as well. – tasomaniac Jan 23 '13 at 12:00
  • Yes, setSelection() works fine, but unfortunately it is not animated. – mennovogel Jan 23 '13 at 13:04
  • I have found the same problem here: http://code.google.com/p/android/issues/detail?id=36062. I have used their solution for now "_A workaround for now is listening for SCROLL_STATE_IDLE when starting the scroll, and smoothScrollToPositionFromTop again to the same position._". – mennovogel Jan 24 '13 at 16:39
  • Interesting. I will do that. – tasomaniac Jan 25 '13 at 18:51

5 Answers5

52

This is a known bug. See https://code.google.com/p/android/issues/detail?id=36062

However, I implemented this workaround that deals with all edge cases that might occur:

First call smothScrollToPositionFromTop(position) and then, when scrolling has finished, call setSelection(position). The latter call corrects the incomplete scrolling by jumping directly to the desired position. Doing so the user still has the impression that it is being animation-scrolled to this position.

I implemented this workaround within two helper methods:

smoothScrollToPositionFromTop()

public static void smoothScrollToPositionFromTop(final AbsListView view, final int position) {
    View child = getChildAtPosition(view, position);
    // There's no need to scroll if child is already at top or view is already scrolled to its end
    if ((child != null) && ((child.getTop() == 0) || ((child.getTop() > 0) && !view.canScrollVertically(1)))) {
        return;
    }

    view.setOnScrollListener(new AbsListView.OnScrollListener() {
        @Override
        public void onScrollStateChanged(final AbsListView view, final int scrollState) {
            if (scrollState == SCROLL_STATE_IDLE) {
                view.setOnScrollListener(null);

                // Fix for scrolling bug
                new Handler().post(new Runnable() {
                    @Override
                    public void run() {
                        view.setSelection(position);
                    }
                });
            }
        }

        @Override
        public void onScroll(final AbsListView view, final int firstVisibleItem, final int visibleItemCount,
                                 final int totalItemCount) { }
    });

    // Perform scrolling to position
    new Handler().post(new Runnable() {
        @Override
        public void run() {
            view.smoothScrollToPositionFromTop(position, 0);
        }
    });
}

getChildAtPosition()

public static View getChildAtPosition(final AdapterView view, final int position) {
    final int index = position - view.getFirstVisiblePosition();
    if ((index >= 0) && (index < view.getChildCount())) {
        return view.getChildAt(index);
    } else {
        return null;
    }
}
Lars Blumberg
  • 19,326
  • 11
  • 90
  • 127
  • 1
    Thanks for this workaround. `View.canScrollVertically(int)` requires **API Level 14**. You can use `ViewCompat.canScrollVertically(View, int)` instead. – jenzz Jan 19 '14 at 15:50
  • Hi, can you please provide a working code with an offset that represent the desired distance in pixels of position from the top of the view when scrolling is finished? – StErMi Apr 30 '14 at 14:18
  • @StErMi What do you mean by working code? You need to call ``smoothScrollToPositionFromTop()`` from the code example with appropriate parameters. – Lars Blumberg May 01 '14 at 11:01
  • not sure if AdapterView changed, but I instead just put AbsListview in your getChildAtPosition() method instead of using AdapterView – nommer Aug 04 '14 at 17:48
  • `AdapterView` is the base class of `AbsListview`. So this method might be used in other contexts as well. – Lars Blumberg Aug 05 '14 at 10:08
  • For more accurate results, use smoothScrollToPosition method instead of smoothScrollToPositionFromTop. – Ayman Mahgoub Sep 06 '14 at 11:47
  • Yes, it also works for me, but I need to postDelay at 1000ms, and plus 1 for the position. Just for some guy's reference. [link]http://stackoverflow.com/questions/30802348/get-listview-item-position-after-soft-keyboard-shown, this is my question, I also fixed it by using this approach! Thanks! – LiuWenbin_NO. Jun 12 '15 at 15:59
  • 1
    Use setSelectionFromTop method of AbsListView added in API level 21 when SCROLL_STATE_IDLE to avoid incorrect positioning. – dira Dec 27 '15 at 15:39
3

Here is an implementation of the solution.

    void smoothScrollToPositionFromTopWithBugWorkAround(final AbsListView listView,
                                                    final int position,
                                                    final int offset, 
                                                    final int duration){

    //the bug workaround involves listening to when it has finished scrolling, and then 
    //firing a new scroll to the same position.

    //the bug is the case that sometimes smooth Scroll To Position sort of misses its intended position. 
    //more info here : https://code.google.com/p/android/issues/detail?id=36062
    listView.smoothScrollToPositionFromTop(position, offset, duration);
    listView.setOnScrollListener(new OnScrollListener() {

        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            if(scrollState==OnScrollListener.SCROLL_STATE_IDLE){
                listView.setOnScrollListener(null);
                listView.smoothScrollToPositionFromTop(position, offset, duration);
            }

        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        }
    });
}
havchr
  • 1,132
  • 1
  • 10
  • 25
  • The second call to smoothScrollToPositionFromTop() does not always perform a scrolling. Though it works *sometimes*. See my answer for another solution. – Lars Blumberg Jan 08 '14 at 13:47
  • the second call to smoothScrollToPositionFromTop has the starting issue :( – StErMi Apr 30 '14 at 14:23
0

As is mention in the google issuetracker page on the fourth floor:https://issuetracker.google.com/issues/36952786

The workaround given earlier, "A workaround for now is listening for SCROLL_STATE_IDLE when starting the scroll, and smoothScrollToPositionFromTop again to the same position." won't always work either.

Actually, the call to onScrollStateChanged with SCROLL_STATE_IDLE doesn't necessarily mean the scrolling has finished. As a result, it still can’t guarantee that the Listview scrolls to a correct position every time, especially when list item views are not all in same height.

After researching, I found another approach that work perfectly correctly and reasonably. As is known, Listview provides a method scrollListBy(int y), which enables us to scroll the Listview up with y pixels instantly. Then, with the help of a timer, we can scroll the list smoothly and correctly by ourselves.

The first thing we need to do is computing the height of each list item view, including the views outside the screen. As the list data and the types of child views are already known before, it is feasible to compute the height of each list item view. So, given a target position to scroll to smoothly, we can calculate its scroll distance in y direction. In addition, the calculation should be done after finishing initializing the ListView.

The second thing is combining a timer and the scrollListBy(int) method. Actually we can use the sendEmptyMessageDelayed() method of android.os.Handler. Thus, the solution can be:

/**
 * Created by CaiHaozhong on 2017/9/29.
 */
public class ListViewSmoothScroller {

    private final static int MSG_ACTION_SCROLL = 1;

    private final static int MSG_ACTION_ADJUST = 2;

    private ListView mListView = null;

    /* The accumulated height of each list item view */
    protected int[] mItemAccumulateHeight = null;

    protected int mTimeStep = 20;

    protected int mHeaderViewHeight;

    private int mPos;

    private Method mTrackMotionScrollMethod = null;

    protected int mScrollUnit = 0;

    protected int mTotalMove = 0;

    protected int mTargetScrollDis = 0;

    private Handler mMainHandler = new Handler(Looper.getMainLooper()){

        public void handleMessage(Message msg) {
            int what = msg.what;
            switch (what){
                case MSG_ACTION_SCROLL: {
                    int scrollDis = mScrollUnit;
                    if(mTotalMove + mScrollUnit > mTargetScrollDis){
                        scrollDis = mTargetScrollDis - mTotalMove;
                    }
                    if(Build.VERSION.SDK_INT >= 19) {
                        mListView.scrollListBy(scrollDis);
                    }
                    else{
                        if(mTrackMotionScrollMethod != null){
                            try {
                                mTrackMotionScrollMethod.invoke(mListView, -scrollDis, -scrollDis);
                            }catch(Exception ex){
                                ex.printStackTrace();
                            }
                        }
                    }
                    mTotalMove += scrollDis;
                    if(mTotalMove < mTargetScrollDis){
                        mMainHandler.sendEmptyMessageDelayed(MSG_ACTION_SCROLL, mTimeStep);
                    }else {
                        mMainHandler.sendEmptyMessageDelayed(MSG_ACTION_ADJUST, mTimeStep);
                    }
                    break;
                }
                case MSG_ACTION_ADJUST: {
                    mListView.setSelection(mPos);                     
                    break;
                }
            }
        }
    };

    public ListViewSmoothScroller(Context context, ListView listView){
        mListView = listView;
        mScrollUnit = Tools.dip2px(context, 60);
        mPos = -1;
        try {
            mTrackMotionScrollMethod = AbsListView.class.getDeclaredMethod("trackMotionScroll", int.class, int.class);
        }catch (NoSuchMethodException ex){
            ex.printStackTrace();
            mTrackMotionScrollMethod = null;
        }
        if(mTrackMotionScrollMethod != null){
            mTrackMotionScrollMethod.setAccessible(true);
        }
    }

    /* scroll to a target position smoothly */
    public void smoothScrollToPosition(int pos){
        if(mListView == null)
            return;
        if(mItemAccumulateHeight == null || pos >= mItemAccumulateHeight.length){
            return ;
        }
        mPos = pos;
        mTargetScrollDis = mItemAccumulateHeight[pos];
        mMainHandler.sendEmptyMessage(MSG_ACTION_SCROLL);
    }

    /* call after initializing ListView */
    public void doMeasureOnLayoutChange(){
        if(mListView == null){
            return;
        }
        int headerCount = mListView.getHeaderViewsCount();
        /* if no list item */
        if(mListView.getChildCount() < headerCount + 1){
            return ;
        }
        mHeaderViewHeight = 0;
        for(int i = 0; i < headerCount; i++){
            mHeaderViewHeight += mListView.getChildAt(i).getHeight();
        }
        View firstListItemView = mListView.getChildAt(headerCount);
        computeAccumulateHeight(firstListItemView);
    }

    /* calculate the accumulated height of each list item */
    protected void computeAccumulateHeight(View firstListItemView){
        int len = listdata.size();// count of list item
        mItemAccumulateHeight = new int[len + 2];
        mItemAccumulateHeight[0] = 0;
        mItemAccumulateHeight[1] = mHeaderViewHeight;
        int currentHeight = mHeaderViewHeight;
        for(int i = 2; i < len + 2; i++){
            currentHeight += getItemHeight(firstListItemView);
            mItemAccumulateHeight[i] = currentHeight;
        }
    }

    /* get height of a list item. You may need to pass the listdata of the list item as parameter*/
    protected int getItemHeight(View firstListItemView){
        // Considering the structure of listitem View and the list data in order to calculate the height.
    }

}

After finishing initializing our ListView, we invoke the doMeasureOnLayoutChange() method. After that, we can scroll the ListView by the method smoothScrollToPosition(int pos). We can invoke doMeasureOnLayoutChange() method like this:

mListAdapter.notifyDataSetChanged();
mListView.post(new Runnable() {
    @Override
    public void run() {
        mListViewSmoothScroller.doMeasureOnLayoutChange();
    }
});

Finally, our ListView can be scrolled to a target position smoothly, and more important, correctly.

CaiHaozhong
  • 11
  • 1
  • 6
0

try changing the height from "wrap_content" to "match_parent"

<RecyclerView   
android: layout_height="match_parent" 
... >

smothScrollToPosition(0)     // works ok 
Dan Alboteanu
  • 9,404
  • 1
  • 52
  • 40
0

Here is Lars Blumberg answer in Kotlin including dira's comment, it is working for me.

private fun smoothScrollToPositionFromTop(listView: AbsListView, position: Int, offset: Int) {

    listView.setOnScrollListener(object : AbsListView.OnScrollListener {

        override fun onScroll(
            view: AbsListView?,
            firstVisibleItem: Int,
            visibleItemCount: Int,
            totalItemCount: Int
        ) { }

        override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) {
            view?.setOnScrollListener(null)

            // Fix for scrolling bug.
            if (scrollState == SCROLL_STATE_IDLE) {
                Handler(Looper.getMainLooper()).post {
                    listView.setSelectionFromTop(position, offset)
                }
            }
        }
    })

    // Perform scrolling to position
    Handler(Looper.getMainLooper()).post {
        listView.smoothScrollToPositionFromTop(position, offset)
    }
}
tagy22
  • 1,353
  • 17
  • 26