27

I'm building an interface similar to the Google Hangouts chat interface. New messages are added to the bottom of the list. Scrolling up to the top of the list will trigger a load of previous message history. When the history comes in from the network, those messages are added to the top of the list and should not trigger any kind of scroll from the position the user had stopped when the load was triggered. In other words, a "loading indicator" is shown at the top of the list:

enter image description here

Which is then replaced in-situ with any loaded history.

enter image description here

I have all of this working... except one thing that I've had to resort to reflection to accomplish. There are plenty of questions and answers involving merely saving and restoring a scroll position when adding items to the adapter attached to a ListView. My problem is that when I do something like the following (simplified but should be self-explanatory):

public void addNewItems(List<Item> items) {
    final int positionToSave = listView.getFirstVisiblePosition();
    adapter.addAll(items);
    listView.post(new Runnable() {

        @Override
        public void run() {
            listView.setSelection(positionToSave);
        }
    });
}

Then what the user will see is a quick flash to the top of the ListView, then a quick flash back to the right location. The problem is fairly obvious and discovered by many people: setSelection() is unhappy until after notifyDataSetChanged() and a redraw of ListView. So we have to post() to the view to give it a chance to draw. But that looks terrible.

I've "fixed" it by using reflection. I hate it. At its core, what I want to accomplish is reset the first position of the ListView without going through the rigamarole of the draw cycle until after I've set the position. To do that, there's a helpful field of ListView: mFirstPosition. By gawd, that's exactly what I need to adjust! Unfortunately, it's package-private. Also unfortunately, there doesn't appear to be any way to set it programmatically or influence it in any way that doesn't involve an invalidate cycle... yielding the ugly behavior.

So, reflection with a fallback on failure:

try {
    Field field = AdapterView.class.getDeclaredField("mFirstPosition");
    field.setAccessible(true);
    field.setInt(listView, positionToSave);
}
catch (Exception e) { // CATCH ALL THE EXCEPTIONS </meme>
    e.printStackTrace();
    listView.post(new Runnable() {

        @Override
            public void run() {
                listView.setSelection(positionToSave);
            }
        });
    }
}

Does it work? Yes. Is it hideous? Yes. Will it work in the future? Who knows? Is there a better way? That's my question.

How do I accomplish this without reflection?

An answer might be "write your own ListView that can handle this." I'll merely ask whether you've seen the code for ListView.

EDIT: Working solution with no reflection based on Luksprog's comment/answer.

Luksprog recommended an OnPreDrawListener(). Fascinating! I've messed with ViewTreeObservers before, but never one of these. After some messing around, the following type of thing appears to work quite perfectly.

public void addNewItems(List<Item> items) {
    final int positionToSave = listView.getFirstVisiblePosition();
    adapter.addAll(items);
    listView.post(new Runnable() {

        @Override
        public void run() {
            listView.setSelection(positionToSave);
        }
    });

    listView.getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() {

        @Override
        public boolean onPreDraw() {
            if(listView.getFirstVisiblePosition() == positionToSave) {
                listView.getViewTreeObserver().removeOnPreDrawListener(this);
                return true;
            }
            else {
                return false;
            }
        }
    });
}

Very cool.

Community
  • 1
  • 1
Brian Dupuis
  • 7,924
  • 2
  • 23
  • 27
  • 1
    I think you could try using a `OnPreDrawListener`. In that listener check if the current first visible position of the `ListView` is equal to the previous first visible item position + the number the items added, if it's not then set the selection on the `ListView` to the proper position and return false(skipping this frame). If the positions match then in the same `OnPreDrawListener` unregister the listener(itself) and return true. – user Oct 16 '13 at 08:22
  • Interesting thought! Let me give that idea a spin... if it pans out create a real answer and claim your bounty. – Brian Dupuis Oct 16 '13 at 13:15
  • Post your answer, Luksprog, after some diddling that ended up working perfectly. Nice thinking! – Brian Dupuis Oct 16 '13 at 19:22
  • May be this is what you are looking for: http://stackoverflow.com/a/28149739/1468093 – Kamran Ahmed Jan 26 '15 at 12:08

3 Answers3

18

As I said in my comment, a OnPreDrawlistener could be another option to solve the problem. The idea of using the listener is to skip showing the ListView between the two states(after adding the data and after setting the selection to the right position). In the OnPreDrawListener(set with listViewReference.getViewTreeObserver().addOnPreDrawListener(listener);) you'll check the current visible position of the ListView and test it against the position which the ListView should show. If those don't match then make the listener's method return false to skip the frame and set the selection on the ListView to the right position. Setting the proper selection will trigger the draw listener again, this time the positions will match, in which case you'd unregister the OnPreDrawlistener and return true.

user
  • 85,380
  • 17
  • 189
  • 186
  • My UI is blocked for some seconds after doing this, Do you know y? – Viswanath Lekshmanan Feb 03 '14 at 07:13
  • @Arju Do you have the exact same scenario as in the question or did you added other stuff as well? – user Feb 03 '14 at 07:29
  • I have a position to which the listview must scrollto, Then i use the code above , I think it works ,but UI is blocked – Viswanath Lekshmanan Feb 03 '14 at 08:17
  • @arju I don't see why that would happen from the my code, it basically skips drawing a frame(so the time would be measured in ms). Are you calling `setSelection()` in the `else` clause in the listener? – user Feb 03 '14 at 08:26
  • @Arju Sorry but I don't see why the hold up would happen. Make sure you're not doing something else that modifies the ListView and keep it from registering the proper position. – user Feb 03 '14 at 09:26
  • Thanks for your suggestions, I dont know whats wrong :) – Viswanath Lekshmanan Feb 03 '14 at 09:28
  • @Viswanath I get the same problem you have. Maybe it's too late but the cause is removeOnPreDrawListener(this); never get called and it makes an infinite loop calling onPreDraw() method. My solution is to change if(listView.getFirstVisiblePosition() == positionToSave) to if(listView.getFirstVisiblePosition() <= positionToSave) – Oscar Yuandinata Aug 05 '15 at 09:33
4

I was breaking up my head until I found a solution similar to this. Before adding a set of items you have to save top distance of the firstVisible item and after adding the items do setSelectionFromTop().

Here is the code:

// save index and top position
int index = mList.getFirstVisiblePosition();
View v = mList.getChildAt(0);
int top = (v == null) ? 0 : v.getTop();

// for (Item item : items){
    mListAdapter.add(item);
}

// restore index and top position
mList.setSelectionFromTop(index, top);

It works without any jump for me with a list of about 500 items :)

I took this code from this SO post: Retaining position in ListView after calling notifyDataSetChanged

Community
  • 1
  • 1
BamsBamx
  • 3,333
  • 3
  • 31
  • 57
  • 1
    It might not be clean code, but it's the best solution in this thread. The other solution forces the list to redraw on the exact location of the last visible item, which makes the list 'jump' when the first visible item is partially visible. This solution doesn't do that. Absolutely the best here. Thanks @BamsBamx – bmeulmeester Jul 19 '14 at 15:31
  • There is no jump if you add items to the bottom of the list, no matter how you do it. If you, however, add any items above current visible item, the code in this answer will not work. – Alexey Oct 23 '14 at 17:40
0

The code suggested by the question author works, but it's dangerous.
For instance, this condition:

listView.getFirstVisiblePosition() == positionToSave

may always be true if no items were changed.

I had some problems with this aproach in a situation where any number of elements were added both above and below the current element. So I came up with a sligtly improved version:

/* This listener will block any listView redraws utils unlock() is called */
private class ListViewPredrawListener implements OnPreDrawListener {

    private View view;
    private boolean locked;

    private ListViewPredrawListener(View view) {
        this.view = view;
    }

    public void lock() {
        if (!locked) {
            locked = true;
            view.getViewTreeObserver().addOnPreDrawListener(this);
        }
    }

    public void unlock() {
        if (locked) {
            locked = false;
            view.getViewTreeObserver().removeOnPreDrawListener(this);
        }
    }

    @Override
    public boolean onPreDraw() {
        return false;
    }
}

/* Method inside our BaseAdapter */
private updateList(List<Item> newItems) {
    int pos = listView.getFirstVisiblePosition();
    View cell = listView.getChildAt(pos);
    String savedId = adapter.getItemId(pos); // item the user is currently looking at
    savedPositionOffset = cell == null ? 0 : cell.getTop(); // current item top offset

    // Now we block listView drawing until after setSelectionFromTop() is called
    final ListViewPredrawListener predrawListener = new ListViewPredrawListener(listView);
    predrawListener.lock();

    // We have no idea what changed between items and newItems, the only assumption
    // that we make is that item with savedId is still in the newItems list
    items = newItems; 
    notifyDataSetChanged();
    // or for ArrayAdapter:
    //clear(); 
    //addAll(newItems);

    listView.post(new Runnable() {
        @Override
        public void run() {
            // Now we can finally unlock listView drawing
            // Note that this code will always be executed
            predrawListener.unlock();

            int newPosition = ...; // Calculate new position based on the savedId
            listView.setSelectionFromTop(newPosition, savedPositionOffset);
        }
    });
}
Alexey
  • 6,550
  • 3
  • 44
  • 63