2013年5月20日月曜日

ListViewで無限スクロール (Android)

ListViewで最後までスクロールしたら続きのデータを読み込んで、いくらでもスクロールできるようにするコード例です。実際のところ無限ということはあり得ないのですけど、データがどれだけ大量にあっても対応できるということです。

大量のデータをListViewで表示したければ普通はContentProviderを作ってLoaderManagerを使ってCursorAdapterにCursorをセットして作ればいいのですが、ContentProviderというものは何かと文字列に持っていこうとするので個人的にあまり好んでいません。引数にSQL断片を渡されても、ここから値を取り出して値域のチェックなんてやってられません。アプリ内部で使うなら引数のチェックなんてしないでSQLにぶち込んでしまえ、という考え方もあるかもしれませんが。

そこでContentProviderのかわりにServiceでデータアクセスを提供し、ListViewに常に一定以下のデータだけを持たせて端までスクロールしたら続きのデータをロードする、そしてあふれたデータはリストから削除する、というプログラムを作ってみました。

肝はSortedMapAdapter

データの追加、削除ができて、且つ常にソートされた状態にするデータホルダーが欲しいのでSortedMapをベースにしたSortedMapAdapterを作りました。長いので下のコードにはありません。GitHubにあります。
[https://github.com/pljp/libpljp-android]

大まかな流れ

リストをスクロールするとOnScrollListener_#onScroll()が呼ばれます。ListViewの最後の10行が表示されたら続きのデータをreadMore()でリクエストします。

readMore()メソッドではデータアクセスを提供するsvcオブジェクトにデータの検索を依頼します(getLogs())。getLogs()メソッドはワーカースレッドでDBを検索して、結果を引数で渡したGetLogsListenerにメインスレッドで返してきます。

結果を受け取ったGetLogsListener#done()メソッドではadapterにデータを追加する前にListViewのスクロール位置をとっておき、データの追加・削除後にさっきまでListViewの先頭に表示していた行を探し出してスクロール位置を復元します。

private static final int MAX_LIST_ITEMS = 200;
private static final int MAX_READ_ITEMS = 100;
private PkgLog head;
private PkgLog tail;
private Adapter_ adapter;
private boolean busy;

/**
* PkgLogを保持するAdapter。
* キーの降順にソートされる。
*/
private final class Adapter_ extends SortedMapAdapter<Integer, PkgLog> {

    private final LayoutInflater inflater;

    Adapter_() {

        super(new Comparator<Integer>() {
            @Override
            public int compare(Integer lhs, Integer rhs) {
                return rhs - lhs;
            }
        });
        inflater = LayoutInflater.from(getActivity());

    }

    @Override
    protected View getView(int position, Integer key, PkgLog item, View convertView, ViewGroup parent) {

        … 省略 …
        return convertView;

    }
}

private final class OnScrollListener_ implements OnScrollListener {

    @Override
    public void onScrollStateChanged(AbsListView view, int scrollState) {
    }

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

        if ( adapter.getCount() == 0 ) return;

        int firstItemId = adapter.getFirstItem().getId();
        int lastItemId = adapter.getLastItem().getId();

        if ( !busy ) {

            if ( totalItemCount <= firstVisibleItem + visibleItemCount + 10 && lastItemId != tail.getId() ) {
                readMore(Direction.OLDER);
            }
            else if ( firstVisibleItem <= 10 && firstItemId != head.getId() ) {
                readMore(Direction.NEWER);
            }

        }
    } // onScroll
} // OnScrollListener_


private enum Direction { NEWER, OLDER }
private void readMore(final Direction dir) {

    if ( svc == null || busy ) return;
    busy = true;

    // svc.getLogs()の結果を受け取るListener。

    GetLogsListener getLogsListener = new GetLogsListener() {

        // データの読み込みが終わったらこのメソッドで通知される。
        // head_, tail_はDBにあるデータ全体の先頭と最後。
        @Override
        public void done(List<PkgLog> logs, PkgLog head_, PkgLog tail_) {

            // スクロール位置をとっておく

            ListView view = getListView();
            int pos = view.getFirstVisiblePosition();
            int firstVisibleId = -1;
            int yOffset = 0;
            if ( adapter.getCount() > pos ) {

                firstVisibleId = adapter.getKey(pos);
                yOffset = view.getChildAt(0).getTop();

            }

            // 読み込んだデータをadapterに取り込む

            for (PkgLog log : logs)
                adapter.put(log.getId(), log);
            head = head_;
            tail = tail_;

            // データが多すぎたら捨てる

            int n = adapter.getCount();
            if ( n > MAX_LIST_ITEMS ) {

                if ( dir == Direction.OLDER )
                    removeLogs(0, n - MAX_LIST_ITEMS);
                else
                    removeLogs(MAX_LIST_ITEMS, n - MAX_LIST_ITEMS);

            }

            // データの更新を通知

            adapter.notifyDataSetChanged();

            // スクロール位置を戻す

            if ( firstVisibleId > -1 ) {

                int pos = adapter.positionOf(firstVisibleId);
                if ( pos > -1 ) {
                    view.setSelectionFromTop(pos, yOffset);
                }

            }

            busy = false;

        }
    };

    if ( dir == Direction.OLDER ) {

        int id = adapter.getLastItem().getId();

        // svc.getLogs()はDBからデータを非同期でロードするメソッド。
        // ロードが終わったらGetLogsListenerに通知される。
        svc.getLogs(query, id, MAX_READ_ITEMS, getLogsListener);

    }
    else {

        int id = adapter.getFirstItem().getId();
        svc.getLogs(query, id, -MAX_READ_ITEMS, getLogsListener);

    }
}