Passion & Opportunity ? continue : break

How to smooth scroll properly for Android AbsListView?
Written: 2019-05-14 14:18:48 Last update: 2019-05-14 14:34:10

Two years ago I was having issue in Android ExpandableListView (ELV), my issue was I need to smooth scroll to a position after set the new adapter to ELV, I was using AbsListView.smoothScrollToPositionFromTop(int position, int offset) and sometimes it does not work properly, the top position is jumping to different position which probably because Android rendering issue, this problem seem to occur only when:

  • Set new adapter to ELV.
  • Many views in adapter (more than 30).
  • Each item view has dynamic/different height (in my Bible scripture app, it is displaying different short and long text).

I found some other people also having similar issue (Android known issue), at that time I used a 'quick solution' (bad solution) using delayed Runnable to fire the call but it could only works properly around 80% of the time.

private void absListViewSmoothScrollTo(final AbsListView view, final int itemPosition) {
  // delay 500ms
  view.postDelayed(() -> {
    // offset = 1 pixel just to trick it (to increase the chance of correct behaviour)
    view.smoothScrollToPositionFromTop(itemPosition, 1); 
  }, 500); 
}

On my free time I got a chance to revisit my old code again, I saw the function absListViewSmoothScrollTo() and changed it with a cleaner/proper solution, it is a bit more complex but without 'dirty hack' (using timer or to loop and wait to make sure the position is set properly).

We know that after the call to smoothScrollToPositionFromTop() then ELV will start scrolling to the specified position, so before the scroll we create a scroll listener to wait for 'scroll idle (stop scroll)' then call to setSelection() to double set and make sure proper position is displayed.

NOTE: the code below is only snippet.
private void absListViewSmoothScrollTo(AbsListView view, final int itemPosition) {
  // set listener to wait until stop scrolling
  view.setOnScrollListener(new AbsListView.OnScrollListener() {
    @Override
    public void onScrollStateChanged(AbsListView view, int scrollState) {
      if (scrollState == SCROLL_STATE_IDLE) {
        // 2nd, to make sure proper position
        view.setSelection(itemPosition);

        // TODO: we need this callback ONLY ONCE, so need to remove this listener
      }
    }

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

  // 1st, smooth scroll it
  view.smoothScrollToPositionFromTop(itemPosition, 1);
}

The solution was very simple but unfortunately there is another problem with solution, if our AbsListView (eg: ListView, GridView, ExpandableListView) already have scroll listener then this solution will override the scroll listener, AbsListView does not have method to add multiple scroll listener like RecyclerView.addOnScrollListener() so we have to create a wrapper class to add multiple scroller listener, below is the small class that I customized from others.

import android.widget.AbsListView;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

public class MultiScrollListener implements AbsListView.OnScrollListener {
  private List<AbsListView.OnScrollListener> mListeners = new ArrayList<>();

  private void addScrollListener(AbsListView.OnScrollListener listener){
    if(listener != null) {
      mListeners.add(listener);
    }
  }

  private void removeListener(AbsListView.OnScrollListener listener) {
    if(listener != null) {
      mListeners.remove(listener);
    }
  }

  @Override
  public void onScrollStateChanged(AbsListView view, int scrollState) {
    for(AbsListView.OnScrollListener listener: mListeners){
      listener.onScrollStateChanged(view,scrollState);
    }
  }

  @Override
  public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
    for(AbsListView.OnScrollListener listener: mListeners){
      listener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
    }
  }

  // get scrollListener of a AbsListView (ie: ListView, GridView and ExpandableListView)
  public final static AbsListView.OnScrollListener getScrollListenerFromAbsListView(AbsListView lv) {
    if (lv == null) {
      return null;
    }
    try {
      Field scrollListenerField = AbsListView.class.getDeclaredField("mOnScrollListener");
      scrollListenerField.setAccessible(true);
      return (AbsListView.OnScrollListener)scrollListenerField.get(lv);
    } catch (IllegalAccessException e) {
      e.printStackTrace();
    } catch (NoSuchFieldException e) {
      e.printStackTrace();
    }
    return null;
  }

  // NOTE: RecyclerView has its own AddOnScrollScroller() --> multiple listeners !!!
  public final static MultiScrollListener addMoreScrollListener(AbsListView view, AbsListView.OnScrollListener listener) {
    if(view == null || listener == null) {
      return null;
    }

    MultiScrollListener msl = null;

    // get the current existing listener (if any)
    AbsListView.OnScrollListener osl = getScrollListenerFromAbsListView(view);
    if(osl == null) {
      // no listener yet, so create new MultiScrollListener
      msl = new MultiScrollListener();
      msl.addScrollListener(listener);

      view.setOnScrollListener(msl);
    } else if (osl instanceof MultiScrollListener) {
      // already using MultiScrollListener, 
      // so add more listener
      msl = (MultiScrollListener) osl;
      msl.addScrollListener(listener);
    } else {
      // AbsListView.OnScrollListener is existed,
      // so wrap it with MultiScrollListener
      msl = new MultiScrollListener();
      msl.addScrollListener(osl);
      msl.addScrollListener(listener);

      view.setOnScrollListener(msl);
    }

    return msl;
  }

  public final static MultiScrollListener removeListener(AbsListView view, AbsListView.OnScrollListener listener) {
    AbsListView.OnScrollListener osl = getScrollListenerFromAbsListView(view);
    if(osl instanceof MultiScrollListener) {
      ((MultiScrollListener) osl).removeListener(listener);
      return (MultiScrollListener) osl;
    }

    return null;
  }
}

With 'MultiScrollListener' class above, I changed the code to:

private AbsListView.OnScrollListener scrollStopListener = new AbsListView.OnScrollListener() {  
  @Override
  public void onScrollStateChanged(AbsListView view, int scrollState) {
    if (scrollState == SCROLL_STATE_IDLE) {

      // 2nd, to make sure proper position
      view.setSelection(mItemPosition);

      // we need this callback ONLY ONCE, so need to remove this listener
      MultiScrollListener.removeListener(view, scrollStopListener);
    }
  }
  
  @Override
  public void onScroll(final AbsListView view,
          final int firstVisibleItem,
          final int visibleItemCount,
          final int totalItemCount) { }
};

private int mItemPosition = -1;
private void absListViewSmoothScrollTo(AbsListView view, final int itemPosition) {
  MultiScrollListener.addMoreScrollListener(view, scrollStopListener);

  // save position 
  mItemPosition = itemPosition;

  // 1st, smooth scroll it
  view.smoothScrollToPositionFromTop(itemPosition, 1);
}

If we don't have existing ScrollListener then we do not need 'MultiScrollListener' class, I hope Google Android dev team will solve this 'rendering' issue so this solution may not be required anymore. This solution is stable and the app will be okay even if Android has fixed this issue.

Do you know other better solution?