Android の ListView を CheckedTextView でカスタムしてハマったメモ

かなりハマったのでメモ。

Android のカスタムリストは BaseAdapter を継承した独自の Adapter を作成し、getView() メソッドを適切に実装することで凝った見た目のリストが表示できる。

やりたかったことは、アイコン付きでかつ場合によってはチェックボックスのが付いているリストの表示。以下のような感じのもの。

これを以下のようなレイアウトで作った。

<CheckableLinearLayout>
    <ImageView />
    <CheckedTextView />
</CheckableLinearLayout>

CheckedTextView に標準のチェックボタンを表示するには以下のように行う。

int[] attrs = { android.R.attr.listChoiceIndicatorMultiple };
TypedArray ta = getContext().getTheme().obtainStyledAttributes(attrs);
Drawable indicator = ta.getDrawable(0);
view.setCheckMarkDrawable(indicator);
ta.recycle();

表示させたくない場合は透明な画像を用意しておき設定すればよい。

view.setCheckMarkDrawable(R.drawable.transparent);

これはこれで正しく動作するんだけど、どうも AbsList が getView() ので作成した View をキャッシュしたりゴニョゴニョしているせいで、意図しない所でチェックボタンが出てしまったりする。

上図はいったん下にスクロールして、上にスクロールしなおした際のものだ。

getView() 内で View を毎回新規作成するようにすると、本来チェックボックスが表示されない場所で表示されてしまうという現象は発生しなくなったんだけど、今度はチェックの状態の表示がおかしくなってしまう。スクロール中にチェックが付いたり外れたり。

内部的なデータの管理に問題はないので、表示上の問題なんだけど、これを View.invalidate() しても View.cb.destroyDrawingCache() しても、コンテナ側でチェック状態を監視しつつ onCreateDrawableState(int extraSpace) で返す値を変えてみたり、試せることは全部試したけどどうしても解決出来なかった。

これでダメなら表示が混在するリストを使用するのは諦めよう、と思って、レイアウトを以下のようにしてみた。

<CheckableLinearLayout>
    <ImageView />
    <TextView />
    <CheckBox />
</CheckableLinearLayout>

なんと、これですんなり期待通りに動作するようになった。表示させたくないときは CheckBox に対して setVisible(GONE) を、表示させたい時は setVisible(VISIBLE) するだけだ。ただ、CheckBox が独立したことで Selector をゴニョゴニョっとしなきゃならないけど、些細なことだ。

CheckedTextView を使ってみた感想。

  • チェックを非表示にできない。チェックマークとして透過画像を設定するという回避策はあるが
  • 現在設定している画像リソースが何なのかを取得できない。これも setTag/getTag で回避可能ではあるが
  • チェックボックスの位置が固定で右。右にあるのが一般的なので移動させるつもりはなかったが
  • チェック画像をどこかにキャッシュしてしまう?という今回の問題。ちなみに全てのリスト項目で表示・非表示を統一する場合はこの問題は発生しない
  • TextView には画像が設定可能であるが、これを動的に変える方法はなく、ListView のような同一のレイアウトを繰り返し使用する場合に不向き(であるため別途 ImageView を使用している)

また、BaseAdapter を使用して getView() を実装する際に、以下に気をつけると良いかも知れない。

  • おそらくスクロールがどれだけあるのかを計算するため、あるいはその他の理由で、非表示部分も繰り返し呼び出される。今回は 8 項目しかないリストを表示させるために、初回に 90 回もこのメソッドが呼び出される。リスト項目の高さを固定にしてみたりしたが、この回数を減らすことは出来なかった。標準の ArrayAdapter も初回表示時に同様に繰り返し呼び出されるため、残念だけどそういうものだという事で、できるだけ getView() の処理を軽くしておく必要がある
  • getView() に渡される convertView という View は、AbsList で再利用するために管理されている。この再利用を無視して毎回作成するようにしたりすると、おかしなことが起こるので、渡された convertView を素直に再利用するように心がけよう
  • この convertView は、基本はリストの高さ分だけ内部に保持され、ひとつ消えてひとつ表示される際に、今消えたものの View が新しく表示させるもののために渡される。ただし、必ずしもこの限りではなく、同時に表示されている項目に対して同じ View が渡されることがある(ログではそのように表示された)。何故表示が正しく機能するのかは全くわからないが内部にキャッシュしている View に内容が既にコピーされているとか、そういう事なのかも知れない。現在 setTag() で View にデータを保持させているが、それが正しく機能しているので、問題ないと思いたい
  • getView() とは関係ないが、アダプターに動的に項目を追加する際は、事後に notifyDataSetChanged() を呼び出すのを忘れないようにしよう
2012/03/17 追記

TextView と CheckBox の連携が非常に難しいので、CheckedTextView ではどういう風に処理しているのかとソースコードを確認してみてわかったこと。

view.setCheckMarkDrawable(R.drawable.transparent);

CheckedTextView でチェックボックスを表示させたくない場合は、わざわざ上記のように透明画像を設定するのではなく、以下のように null を渡せばよいことがわかった。

view.setCheckMarkDrawable(null);

これで、以下のようなおかしな現象が発生することはなくなった。

Selector の挙動が CheckedPreference と多少異なる*1のが気になるけど、ここもおいおい調査してみよう。

2012/03/18 追記

2.2 では 3/17 追記の方法で正常に動作するんだけど 2.1 にすると以下が正しく機能していないようで、

view.setCheckMarkDrawable(null);
2012/03/23 追記

原因が特定できた。

僕のコードは、以下のように条件に合わせてチェックマークを切り替えるようにしている。

if (...) {
    view.setCheckMarkDrawable(null);
} else {
    int[] attrs = { android.R.attr.listChoiceIndicatorMultiple };
    TypedArray ta = getContext().getTheme().obtainStyledAttributes(attrs);
    Drawable indicator = ta.getDrawable(0);
    view.setCheckMarkDrawable(indicator);
    ta.recycle();
}

ここでちょっと欲が出て、getView() はスクロールのたびに何度も呼び出されるので、その都度いろいろインスタンスが生成されるのはよくないな、と思って、以下のように変えた。

if (...) {
    view.setCheckMarkDrawable(null);
} else {
    view.setCheckMarkDrawable(mIndicator);
}

CheckedTextView のソースコードを見ると、setCheckMarkDrawable() が呼び出されると、Drawable#setCallback() が呼び出され、Drawable は mIndicator で使いまわしているから、状態が不正になる、ということだった。

一週間ほど苦しめられた。

*1:CheckedPreference はチェックボックスもちゃんとクリック時にハイライトされるけど CheckedTextView はそのようにはなっていない