【Android】ListViewとActionModeを併用する

一週間ほど開いてしまいました。珍しくみんな忙しくてですね…。

最近自作のTwitterクライアントを公開しまして、まだまだαリリース段階なので色々と機能を追加しているんですが、ActionModeとListViewのCHOICE_MODE_MULTIPLEを併用しようとしたら三日間ほどハマってしまったので、今回はその辺のことを書きます。

ActionModeってなに?

ActionModeとはAPI Level 11(HONEYCOMB)から追加された機能です。ActionBar自体もMenuItemを持てるんですが、ActionModeを使うことでActionBarのMenuを別のMenuに切り替えることが出来ます。

具体的にどんな見た目なのかはこの辺のサイトを見ればいいと思います。

ActionModeを使う

ActionModeを呼び出すにはいくつか方法があるんですが、一番シンプルなのはActivity#startActionMode (ActionMode.Callback callback) (Support LibraryならActionBarActivity#startSupportActionMode (ActionMode.Callback callback) )を使うものでしょう。

上記のメソッドはあくまでも「ActionModeをはじめまーす」と宣言するだけです。実際にActionModeの制御は引数のActionMode.Callbackと言うインターフェースに記述します。

public class TestActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        startActionMode(new Callback() {
            
            @Override
            public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
                // TODO 自動生成されたメソッド・スタブ
                return false;
            }
            
            @Override
            public void onDestroyActionMode(ActionMode mode) {
                // TODO 自動生成されたメソッド・スタブ
                
            }
            
            @Override
            public boolean onCreateActionMode(ActionMode mode, Menu menu) {
                // TODO 自動生成されたメソッド・スタブ
                return false;
            }
            
            @Override
            public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
                // TODO 自動生成されたメソッド・スタブ
                return false;
            }
        });
    }
}

どのメソッドが何なのかはドキュメントを見てくれって感じなんですが、一応説明しておきましょう。

  • onCreateActionMode:ActionModeが初めて呼び出された時に発生 trueを返さないとその後何もしない
  • onPrepareActionMode:ActionModeが呼び出される度に発生 Menuだのなんだのを動的に変更する場合はここに記述しよう trueを返すと変更が反映される
  • onActionItemClicked:onCreateActionMode / onPrepareActionModeで登録されたMenuItemがクリックされると発生 正常に処理できたらtrueを返す
  • onDestroyActionMode:ActionMode#finish()が呼び出されたりするなどしてActionModeが終了する時に発生

なんかオプションメニュー系のメソッドと似てるなぁと思えればそれでOKです。

ListViewとActionModeを紐付ける(HONEYCOMB以降版)

「ListViewの項目を長押しすると自動で複数選択モードになり、ActionModeが起動する」と言う機能があります。AbsListView#setChoiceModeCHOICE_MODE_MULTIPLE_MODALをセットし、先ほどのActionMode.Callbackと同じような内容をAbsListView#setMultiChoiceModeListenerで渡してあげればOKです。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    ListView listView = (ListView) findViewById(R.id.listView);

    // ListViewはAbsListViewを継承しているのでメソッドをそのまま呼び出せる
    listView.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE_MODAL);
    listView.setMultiChoiceModeListener(new MultiChoiceModeListener() {

        @Override
        public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
            // TODO 自動生成されたメソッド・スタブ
            return false;
        }

        @Override
        public void onDestroyActionMode(ActionMode mode) {
            // TODO 自動生成されたメソッド・スタブ

        }

        @Override
        public boolean onCreateActionMode(ActionMode mode, Menu menu) {
            // TODO 自動生成されたメソッド・スタブ
            return false;
        }

        @Override
        public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
            // TODO 自動生成されたメソッド・スタブ
            return false;
        }

        @Override
        public void onItemCheckedStateChanged(ActionMode mode, int position,
                long id, boolean checked) {
            // TODO 自動生成されたメソッド・スタブ

        }
    });
}

わぁ簡単だなぁ、って感じなんですが、Support LibraryにはCHOICE_MODE_MULTIPLE_MODALもsetMultiChoiceModeListenerもありません。拡張系の機能がサポートされてる言語だったらこんなことにはならなかったろうに…。

ListViewとActionModeを紐付ける(Support Library版)

Support Libraryを使用している場合はもうちょっと手間がかかります。と言っても、setOnItemLongClickListener経由で呼び出すようにし、onDestroyActionModeで色々と片付けるだけですが…。

private ActionMode _actionMode;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    ListView listView = (ListView) findViewById(R.id.listView);

    listView.setOnItemLongClickListener(new OnItemLongClickListener() {
        @Override
        public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
            if(_actionMode != null) return false;

            ListView listView = (ListView) findViewById(R.id.listView);
            listView.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
            listView.setItemChecked(position, true);

            startSupportActionMode(new Callback() {
                @Override
                public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
                    return false;
                }

                @Override
                public boolean onCreateActionMode(ActionMode mode, Menu menu) {
                    _actionMode = mode;
                    // TODO:メニューの作成

                    return false;
                }

                @Override
                public void onDestroyActionMode(ActionMode mode) {
                    _actionMode = null;
                    ListView listView = (ListView) findViewById(R.id.listView);
                    // 選択されている要素をすべてキャンセルする
                    listView.clearChoices();
                    // 元のChoice Modeに戻す
                    listView.setChoiceMode(AbsListView.CHOICE_MODE_NONE);
                }

                @Override
                public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
                    return false;
                }

            });

            return true;
        }
    });
    
    listView.setOnItemClickListener(new OnItemClickListener() {
        @Override
        public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
            if(_actionMode != null) return;
        }
    });
}

基本的にはこれだけです。ここから先の話は「選択可能なListViewの色々なノウハウ」みたいな話になるので、Choice ModeがCHOICE_MODE_MULTIPLE / CHOICE_MODE_SINGLEなListViewを使って色々やったことがある人は特に読む必要はないです。

AdapterにItemが選択されたことを通知する

CHOICE_MODE_MULTIPLE_MODALではなく、CHOICE_MODE_MULTIPLECHOICE_MODE_SINGLEをChoice Modeとした時でもそうですが、自分が今選択されているかどうかをAdapter内部のViewは知りません。そのため、「選択されているItemは背景色を変える」のような機能を実装する場合、ある程度自前で実装する必要があります。

別に実装しなくてもListViewの方はどれが選択されているか知っているんですが、流石にUI的にあまりにもアレなので、ほとんど必ず実装することになるでしょう。

考え方としては「Adapter内部に自分が今選択されているかどうかを知っているViewを持たせる」となります。具体的には、Checkableと言うインターフェースを実装したViewを持たせることになります。

単に選択されているかどうか、であれば、ドキュメントに書いてある「Known Indirect Subclasses」のどれかをAdapterで表示するようにすれば大丈夫です。ただし、Item全体の背景色となると、Adapter内部のViewのroot要素にCheckableを実装させ、DrawableStateを変更しないといけません。具体的にはこんな感じ。

public class CheckableLinearLayout extends LinearLayout implements Checkable {

    private boolean _isChecked;
    private static final int[] CHECKED_STATE = { android.R.attr.state_checked };

    public CheckableLinearLayout(Context context) {
        super(context);
    }

    public CheckableLinearLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CheckableLinearLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    public void setChecked(boolean checked) {
        if(_isChecked != checked) {
            _isChecked = checked;
            refreshDrawableState();
        }
    }

    @Override
    public boolean isChecked() {
        return _isChecked;
    }

    @Override
    public void toggle() {
        setChecked(!_isChecked);
    }

    @Override
    protected int[] onCreateDrawableState(int extraSpace) {
        final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
        if(_isChecked) {
            mergeDrawableStates(drawableState, CHECKED_STATE);
        }

        return drawableState;
    }

}

次にDrawableStateによって色を自動で変更するselectorのXMLをdrawable内に作成します。今回のファイル名は「bk_checkable.xml」としておきましょう。

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android" >
    <item android:state_checked="true">
        <shape>
            <solid android:color="#099CC9" />
        </shape>
    </item>
    <item android:state_checked="false">
        <shape>
            <solid android:color="#00000000" />
        </shape>
    </item>
</selector>

最後にAdapterのXMLを作成します。

<com.expample.view.CheckableLinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_witdh="match_parent"
    android:layout_height="wrap_content"
    android:focusableInTouchMode="false"
    android:focusable="false"
    android:orientation="horizontal"
    android:background="@drawable/bk_checkable"
    android:id="@+id/root">
    <TextView
        android:id="@+id/textView"
        android:layout_witdh="wrap_content"
        android:layout_height="wrap_content" />
</com.expample.view.CheckableLinearLayout>

ぶっちゃけy.a.m女史の丸パクリです。DrawableStateをまともに解説しようとするとちょっとこの記事では収まりきらないぐらいの量があり、そのうち記事にしたいとも思っているので、今はこんなおまじないが必要、とだけ思っていて下さい。おまじないと言うにはあまりにも手間がかかっていますが…。

後、これだけは絶対に覚えておいたほうがいいんですが、ListViewが所持している「Itemが選択されているかどうか」と言うリストと、Adapter内部のCheckableな要素のisCheckedは完全に同期されているわけではありません。単にListViewのOnItemClickが発生する直前にCheckable#toggleが呼び出されている、程度に思っておきましょう。逆に言えば、OnItemClick以外で選択 / 選択解除する時は自分で同期を取る必要があります。

また、何かを選択している状態でAdapterのItemを増減させたり、画面をスクロールさせたりすると、選択されていない要素の背景色が変わってしまうことがあります。(多分、ViewHolderパターンを使ってるからだけど。)

使用するAdapterでhasStableIdsメソッドをOverrideし、trueを返すようにしておきましょう。ArrayAdapterを使用する場合は同時にgetItemIdメソッドもOverideし、一意な値を返すようにする必要もあります。

現在選択されている要素を取得する

単に選択させるだけなら簡単なんですが、「今どの要素が選択されているのか?」を取得するのは地味に面倒だったりします。とは言え、ActionModeを使用する以上ほぼ確実に必要なものでしょう。

AbsListView#getCheckedItemPositionsを使用することで、Keyがposition、ValueがisCheckedなSparseBooleanArrayを取得することができます。

こんなスニペットを作っておくといいでしょう。ってか、これデフォルトでも欲しいよねと言うか、getCheckedItemPositionsってメソッド名でSparseBooleanArrayと言ういわばMapが返ってくるのがまずおかしい気がするんですが、何なんでしょうね。falseの要素を取りたいってこともあるんでしょうが、checkedなListを返すメソッドとuncheckedなListを返すメソッドの二つにわければよかったのでは?

public static ArrayList<Integer> getCheckedPositons(ListView listView) {
    SparseBooleanArray checkedPositions = listView.getCheckedItemPositions();
    ArrayList<Integer> pos = new ArrayList<Integer>();

    if(checkedPositions == null) return pos;

    for(int i = 0, size = checkedPositions.size(); i < size; i++) {
        if(checkedPositions.valueAt(i)) {
            pos.add(checkedPositions.keyAt(i));
        }
    }

    return pos;
}

後は上記のメソッドで返ってきたIntegerのリストからListView#getItemAtPositionしてもいいですし、Adapter#getItemしてもいいです。

Choice Modeを切り替える際にすべての選択を解除する

Support Libraryの方を使う場合…と言うか、厳密にはSupport Libraryの方しか使ったことがないんですけど、ActionModeを起動している時はCHOICE_MODE_MULTIPLE、していない時はCHOICE_MODE_NONEにしておきたいってことが当然あると思います。

これのせいで三日間もハマってしまったので何度でも言いますが、ListViewが所持している「Itemが選択されているかどうか」と言うリストと、Adapter内部のCheckableな要素のisCheckedは完全に同期されているわけではありません。また、CHOICE_MODE_MULTIPLEからCHOICE_MODE_NONEに切り替えても選択された要素は選択されたままですし、当然Adapter内部のViewが所持しているフラグを自動でおろしてくれることもありません。その辺は自分で実装することになります。

ListViewの選択されている要素を解除するにはclearChoicesメソッドを呼び出すだけでOKです。Adapter内部の方はちょっと面倒です。と言うのも、Adapterが所持しているItemを列挙するのは簡単なんですが、Adapterが所持しているViewを列挙するメソッドは存在しないからです。(あったらごめんなさい)

アドホックな解決策としては、「AdapterにCHOICE_MODEを設定した旨を通知するメソッドを作り、それを見てAdapter内部でCheckableなviewのフラグを制御する」と言うものが考えられます。具体的にはこんな感じです。

public class TestAdapter extends ArrayAdapter<String> {

    private LayoutInflater _layoutInflater;
    private boolean _isChoiceMode;

    public TimelineAdapter(Context context, List<TimelineData> list){
        super(context, 0, list);
        _layoutInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }
    
    public void startChoiceMode() {
        _isChoiceMode = true;
    }

    public void endChoiceMode() {
        _isChoiceMode = false;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View view = convertView != null ? convertView
                                        : _layoutInflater.inflate(R.layout.adapter_test, null);
        
        String data = getItem(position);
        
        CheckableLinearLayout root = (CheckableLinearLayout) view.findViewById(R.id.root);
        
        if(!_isChoiceMode && root.isChecked()) {
            root.setChecked(false);
        }
        
        ((TextView) view.findViewById(R.id.textView)).setText(data);
        
        return view;
    }

    @Override
    public long getItemId(int position) {
        return getItem(position).hashCode();
    }

    @Override
    public boolean hasStableIds() {
        return true;
    }

}

あんまりスマートではないんですが仕方ないですね…。

onItemClick以外で要素を選択状態にする

AbsListView#setItemChecked (int position, boolean value) を使うことで制御出来ます。

一応これ経由でもAdapter内部のCheckableは変更されます。特にコード例を書くほど複雑な機能ではないです。

まとめ

やっぱりListViewがAndroidで一番難しいんじゃないかな。

参考