27

我正在构建一个类似于 Google Hangouts 聊天界面的界面。新消息将添加到列表底部。向上滚动到列表顶部将触发加载以前的消息历史记录。当历史来自网络时,这些消息将被添加到列表的顶部,并且不应从触发加载时用户停止的位置触发任何类型的滚动。换句话说,“加载指示器”显示在列表顶部:

在此处输入图像描述

然后将其替换为任何加载的历史记录。

在此处输入图像描述

我已经完成了所有这些工作......除了我不得不诉诸反思来完成的一件事。在将项目添加到附加到 ListView 的适配器时,有很多问题和答案仅涉及保存和恢复滚动位置。我的问题是,当我执行以下操作时(简化但应该不言自明):

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 顶部,然后快速闪回到正确的位置。问题比较明显,也被很多人发现:setSelection()一直不爽,直到notifyDataSetChanged()重画了ListView。所以我们必须给post()视图给它一个绘制的机会。但这看起来很可怕。

我已经通过使用反射“修复”了它。我讨厌它。在其核心,我想要完成的是重置第一个位置,而ListView无需经历绘制周期的繁琐,直到设置了位置。为此,ListView 有一个有用的字段:mFirstPosition。天哪,这正是我需要调整的!不幸的是,它是包私有的。同样不幸的是,似乎没有任何方法可以以编程方式设置它或以任何不涉及无效循环的方式影响它......产生丑陋的行为。

因此,以失败为后备的反思:

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);
            }
        });
    }
}

它有效吗?是的。可怕吗?是的。将来会起作用吗?谁知道?有没有更好的办法?那是我的问题。

我如何在没有反思的情况下做到这一点?

答案可能是“自己编写ListView可以处理的”。我只想问你是否看过ListView.

编辑:根据 Luksprog 的评论/答案,没有反思的工作解决方案。

Luksprog推荐了一个OnPreDrawListener(). 迷人!我以前搞过 ViewTreeObservers,但从来没有一个。经过一番折腾,以下类型的东西似乎工作得非常完美。

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;
            }
        }
    });
}

很酷。

4

3 回答 3

18

正如我在评论中所说,aOnPreDrawlistener可能是解决问题的另一种选择。使用侦听器的想法是跳过显示ListView两种状态之间的状态(在添加数据和将选择设置到正确位置之后)。在OnPreDrawListener(使用 设置listViewReference.getViewTreeObserver().addOnPreDrawListener(listener);)中,您将检查 的当前可见位置,并根据应显示ListView的位置对其进行测试。ListView如果这些不匹配,则使侦听器的方法返回false以跳过帧并将选择设置在ListView正确的位置。设置正确的选择将再次触发绘图侦听器,这次位置将匹配,在这种情况下,您将取消注册OnPreDrawlistener并返回true

于 2013-10-17T09:32:58.707 回答
4

在找到与此类似的解决方案之前,我一直在崩溃。在添加一组项目之前,您必须保存 firstVisible 项目的顶部距离,并在添加项目之后执行 setSelectionFromTop()。

这是代码:

// 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);

它对我来说没有任何跳跃,有大约 500 个项目的列表:)

我从这个 SO 帖子中获取了这段代码:Retaining position in ListView after calling notifyDataSetChanged

于 2014-05-13T08:35:40.793 回答
0

问题作者建议的代码有效,但很危险。
例如,这个条件:

listView.getFirstVisiblePosition() == positionToSave

如果没有更改任何项目,则可能始终为真。

在当前元素的上方和下方添加了任意数量的元素的情况下,我对这种方法有一些问题。所以我想出了一个稍微改进的版本:

/* 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);
        }
    });
}
于 2014-10-23T18:16:32.647 回答