読者です 読者をやめる 読者になる 読者になる

【Android】android.widget.Filterがひどすぎるので作り直す

Android snippet

ListViewのデータをフィルタリングするのであればFilterを使うのが一番簡単です。

簡単なんですが、使い物になりません。と言うわけで、継承して作り直します。

android.widget.Filterの挙動

AndroidにおけるFilterっていっぱいありすぎるのでandroid.widget.Filterとパッケージ名も明記しておきます。

これはFilterableインターフェースを実装しているクラスからgetFilterメソッドが呼び出された時に作成されることが想定されている、とOverviewに書かれています。

じゃあFilterableを実装しているクラスとはどんなもんかな?とKnown Indirect Subclassesを見てみると、よく使うAdapterの名前がつらつらと書かれています。そんなわけで、Filterは実質Adapterのデータをフィルタリングすることに使われる、と考えていいです。別に他の用途で使ってもいいとは思いますが。

実際のFilterクラスの中身を見ていきましょう。抽象クラスなのでさくっと抽象メソッドを見ていきます。と言っても、二つしかありません。

performFilteringはCharSequence constraintを受けてFilter.FilterResultを返します。FilterResult#valuesにフィルタ後のデータをセットしておくとpublishResultsで受け取れる、と言う寸法です。

ちなみにperformFilteringは別スレッドで実行されます。まずやらないとは思いますが、UIを操作しないよう注意しましょう。

後は適当にコード例でも見たほうが早いでしょう。

とりあえず適当なPOJOを用意します。

public class TestData {
    private long id;
    private String name;
    private String text;
    
    public long getId() {
        return this.id;
    }
    
    public void setId(long id) {
        this.id = id;
    }
    
    // 以下、面倒なのでgetter / setter省略
}

適当なArrayAdapterも用意します。FilterはこのArrayAdapterのインナークラスにします。

public class TestAdapter extends ArrayAdapter<TestData> {
    
    private TestFilter _filter;
    
    
    public TestAdapter(Context context, int resource, List<TestAdapter> objects) {
        super(context, 0, objects);
    }
    
    // getViewは今回どうでもいいので省略
    
    @Override
    public Filter getFilter() {
        if(_filter == null) {
            _filter = new TestFilter();
        }
        
        return _filter;
    }

    public class TestFilter extends Filter {

        @Override
        protected FilterResults performFiltering(CharSequence constraint) {
           
            List<TestData> items = new ArrayList<TestData>();
           
            // getCount及びgetItemはAdapterのメソッド
            for(int i = 0, size = getCount(); i < size; i++) {
                TestData data = getItem(i);
                if((data.getName() != null && data.getName().contains(constraint))
                    || (data.getText() != null && data.getText().contains(constraint))) {
                    items.add(data);
                }
            }
           
            FilterResults r = new FilterResults();
            r.count = items.size();
            r.value = items;
           
            return items;
        }
        
        @SuppressWarnings("unchecked")
        @Override
        protected void publishResults(CharSequence constraint, FilterResults results) {
            
            // Adapterのメソッドでデータの内容を更新する
            if(results.count > 0) {
                List<TestData> items = (List<TestData>) results.value;
                
                clear();
                addAll(items);
                notifyDataSetChanged();
                
            } else {
                notifyDataSetInvalidated();
            }
        }

    }
    
}

後はFilter#filterを呼び出せばOKです。FilterListenerをセットしておくとpublishResultsとは別にコールバックとしてデータ数のみ受け取ることが出来ます。

getListView().getListAdapter().getFilter().filter("hoge", new FilterListener() {
    @Override
    public void onFilterComplete(int count) {
        Toast.makeText(getApplicationContext(), String.format("%d件抽出しました。", count), Toast.LENGTH_SHORT)
            .show();
    }
});

以上がFilterの基本的な使い方です。どうですか?クソすぎてイライラしてきませんか?

android.widget.Filterのダメなところ

具体的には以下の点がクソです。

  • Adapterのインナークラスとして使われることを前提とした作りになっている(再利用性が全くない)
  • フィルタ後の処理(publishResults)が何故か委譲されず、Filterクラス内に記述する形になっている
  • フィルタを解除する仕組みがない
  • FilterResults#valueの型がObjectなので型安全が保障されない

と言うわけでこの辺をバリバリ修正していきます。

android.widget.Filterを継承した抽象クラスを作成する

public abstract class ListFilter<T> extends Filter {

    /** データをフィルタリングした後のコールバック用リスナー */
    public interface OnPublishResultListener<T> {
        /**
         * データをフィルタリングした後のコールバック
         *
         * @param constraint 検索単語
         * @param results フィルタ後のデータ
         * @param resultsCount フィルタ後のデータ件数
         */
        void onPublishResult(CharSequence constraint, List<T> results, int resultsCount);
    }

    private List<T> _originalDatas;
    private OnPublishResultListener<T> _listener;
    private CharSequence _constraint;

    /**
     * List用Filter
     * @param datas フィルタリングするデータ
     * @param listener フィルタ後のコールバック
     */
    public ListFilter(List<T> datas, OnPublishResultListener<T> listener) {
        _originalDatas = datas;
        _listener = listener;
    }

    @Override
    protected FilterResults performFiltering(CharSequence constraint) {
        _constraint = constraint;
        FilterResults results = new FilterResults();

        if(_originalDatas == null) return results;

        List<T> filtered = new ArrayList<T>();

        for(T d : _originalDatas) {
            if(test(d, constraint)) filtered.add(d);
        }

        results.count = filtered.size();
        results.values = filtered;

        return results;
    }

    @SuppressWarnings("unchecked")
    @Override
    protected void publishResults(CharSequence constraint, FilterResults results) {
        if(_listener == null) return;
        _listener.onPublishResult(constraint, (List<T>) results.values, results.count);
    }

    /**
     * データ追加
     * @param data 追加するデータ
     * @param index 追加する位置(値が0未満の場合は末尾に追加)
     * @return データがフィルタ条件に一致していればtrue
     */
    public synchronized boolean add(T data, int index) {
        if(_originalDatas == null) return false;

        if(index >= 0) {
            _originalDatas.add(index, data);
        } else {
            _originalDatas.add(data);
        }

        return test(data, _constraint);
    }

    /**
     * データ追加
     * @param data 追加するデータ
     * @return データがフィルタ条件に一致していればtrue
     */
    public synchronized boolean add(T data) {
        return add(data, -1);
    }

    /**
     * データ追加
     * @param data 追加するデータ
     * @param index 追加する位置(値が0未満の場合は末尾に追加)
     * @return データがフィルタ条件に一致していればtrue
     */
    public synchronized boolean addWhenNothing(T data, int index) {
        if(_originalDatas.contains(data)) return false;
        return add(data, index);
    }

    /**
     * データ追加
     * @param data 追加するデータ
     * @return データがフィルタ条件に一致していればtrue
     */
    public synchronized boolean addWhenNothing(T data) {
        return addWhenNothing(data, -1);
    }

    /**
     * データ削除
     * @param data 削除するデータ
     */
    public synchronized void remove(T data) {
        if(_originalDatas == null) return;
        _originalDatas.remove(data);
    }

    /**
     * データ削除
     * @param index 削除するデータの位置
     */
    public synchronized void remove(int index) {
        if(_originalDatas == null) return;
        _originalDatas.remove(index);
    }

    /**
     * 全データ取得
     * @return フィルタが適用されていないデータもすべて取得します。
     */
    public synchronized List<T> getOriginalDatas() {
        return _originalDatas;
    }

    /**
     * <p>フィルタ破棄</p>
     * <p>コールバックリスナーとフィルタで所持している全データを破棄します。</p>
     */
    protected synchronized void dispose() {
        _listener = null;
        _originalDatas = null;
    }

    public void setOnPublishResultListener(OnPublishResultListener<T> listener) {
        _listener = listener;
    }

    public CharSequence getConstraint() {
        return _constraint;
    }

    /**
     * フィルタリングルール
     * @param data 検査するデータ
     * @param constraint 検索内容
     * @return フィルタ対象の場合はtrue
     */
    abstract boolean test(T data, CharSequence constraint);

}

修正の観点は以下の通りです。

  • データソースはListであることを前提にし、コンストラクタで受け取る(この受け取ったListを「フィルタ前データ」として扱う)
  • publishResultsはOnPublishResultListenerに完全に委譲する(再利用性の確保・型安全の保障)
  • フィルタ前データの操作と、操作しようとするデータがフィルタリングルールに該当するかどうかをチェックするメソッドの追加(add関連)

後はこのようにListFilterを継承してtestメソッドをオーバーライドするだけで簡単にフィルタが実装出来ます。

public class TestFilter extends ListFilter<TestData> {

    public TimelineFilter(List<TestData> datas, OnPublishResultListener<TestData> listener) {
        super(datas, listener);
    }

    @Override
    public boolean test(TestData data, CharSequence constraint) {
        return (data.getName() != null && data.getName().contains(constraint))
            || (data.getText() != null && data.getText().contains(constraint));
    }

}

ArrayAdapterを継承した抽象クラスを作成する

これで使いやすくはなったんですが、データをフィルタ中にArrayAdapter#addのようなメソッドが呼ばれたらフィルタ条件に合致するかどうかをAdapter側でも調べなくてはなりません。

全部のArrayAdapterを継承しているクラスでその辺のメソッドをオーバーライド祭りするのは流石にだるいです。と言うわけで、ArrayAdapterもラップしてしまいました。

public abstract class FiltableArrayAdapter<T> extends ArrayAdapter<T> {

    private ListFilter<T> _filter;

    public FiltableArrayAdapter(Context context, int resource) {
        super(context, resource);
    }

    public FiltableArrayAdapter(Context context, int resource, int textViewResourceId) {
        super(context,resource,textViewResourceId);
    }

    public FiltableArrayAdapter(Context context, int resource, T[] objects) {
        super(context, resource, objects);
    }

    public FiltableArrayAdapter(Context context, int resource, int textViewResourceId, T[] objects) {
        super(context, resource, textViewResourceId, objects);
    }

    public FiltableArrayAdapter(Context context, int resource, List<T> objects) {
        super(context, resource, objects);
    }

    public FiltableArrayAdapter(Context context, int resource, int textViewResourceId, List<T> objects) {
        super(context, resource, textViewResourceId, objects);
    }

    @Override
    public final Filter getFilter() {
        _filter = getFilterImpl();
        return _filter;
    }

    public boolean isFiltered() {
        return _filter != null;
    }

    @Override
    public final void add(T object) {
        if(_filter == null || _filter.add(object)) {
            super.add(object);
        }
    }

    @SuppressLint("NewApi")
    public final void addAll(Collection<? extends T> collection) {

        if(_filter == null) {
            if(Build.VERSION.SDK_INT >= 11) {
                super.addAll(collection);
            } else {
                for(T data : collection) {
                    super.add(data);
                }
            }

            return;
        }

        for(T data : collection) {
            if(_filter.add(data)) super.add(data);
        }

    }

    @SuppressLint("NewApi")
    public final void addAll(T... items) {
        if(_filter == null) {
            if(Build.VERSION.SDK_INT >= 11) {
                super.addAll(items);
            } else {
                for(T data : items) {
                    super.add(data);
                }
            }

            return;
        }

        for(T data : items) {
            if(_filter.add(data)) super.add(data);
        }

    }

    @Override
    public final void clear() {
        if(_filter != null) {
            _filter.dispose();
            _filter = null;
        }

        super.clear();
    }

    protected final void dataClear() {
        super.clear();
    }

    @Override
    public final void insert(T object, int index) {
        if(_filter == null || _filter.add(object, index)) {
            super.insert(object, index);
        }
    }

    @Override
    public final void remove(T object) {
        if(_filter != null) _filter.remove(object);
        super.remove(object);
    }

    public final ListFilter<T> getCurrentFilter() {
        return _filter;
    }

    public final void resetFilter() {
        if(_filter == null) {
            Log.w("FiltableArrayAdapter", "filter is not found.");
            return;
        }

        List<T> datas = _filter.getOriginalDatas();
        this.clear();

        for(T data : datas) super.add(data);
    }

    protected abstract ListFilter<T> getFilterImpl();

}

先ほどのAdapterを修正してみましょう。

public class TestAdapter extends FiltableArrayAdapter<TestData> {
    
    private TestFilter _filter;
    
    public TestAdapter(Context context, int resource, List<TestAdapter> objects) {
        super(context, 0, objects);
    }
    
    // getViewは今回どうでもいいので省略
    
    @Override
    protected ListFilter<TestData> getFilterImpl() {
        if(_filter == null) {
            List<TestData> items = new ArrayList<TestData>();

            for(int i = 0, size = getCount(); i < size; i++) {
                items.add(getItem(i));
            }
            
            _filter = new TestFilter(items, new OnPublishResultListener() {
                @Override
                public void onPublishResult(CharSequence constraint, List<TestData> results
                    , int resultsCount) {
                    
                    if(resultsCount > 0) {
                        // このdataClearをclearにするとフィルタが消し飛ばされるので元に戻せなくなる
                        // ここは完全に私の設計ミスなのでうまい方法を考えたいところ。
                        dataClear();
                        addAll(results);
                        notifyDataSetChanged();
                        
                    } else {
                        notifyDataSetInvalidated();
                    }
                    
                }
            });
        }
        
        return _filter;
    }
}

実際にフィルタをかける方法はFilter#filterを呼び出すだけです。フィルタを解除する場合はFiltableArrayAdapter#resetFilterを呼び出せば元に戻してくれます。フィルタ中にデータの操作が発生していてもそれと同期したデータで戻してくれるので安心です。

まとめ

と言うわけで、これぐらいやらないと使い物にならないFilterのお話でした。

「フィルタをかけるかどうかをチェックするための型がCharSequenceだけなのもクソじゃないの???」と言う意見は最もですが、それだとFilterを継承するだけではどうにもなりません。(出来なくはないけど、全くもってスマートではない)

Filterのソースを見ながら適当にジェネリクスを設定するだけで簡単に実装出来るので、そう言うのが欲しい方は自分で作りましょう。なければ作るしかないんですよ。私は別にいらないので作りませんでした。