いざCheckBoxに初期値を設定…あれ?
ListViewの中にCheckBoxを置きたい!
メールの削除画面など、使いドコロはたくさんある!
ところがCheckBoxに初期値を設定しようとすると上手くいかない!!
やりたいことを具体的に
CheckBox (正しくはその親の CompoundButton) には setChecked()
というメソッドがあって、こいつでチェックボックスの値を設定できます。
一方、 CheckBoxの状態が変化したイベントを取りたいので setOnCheckedChangeListener()
も使いますよね。
これを [Adapter.getView()
](http://developer.android.com/reference/android/widget/Adapter.html#getView(int, android.view.View, android.view.ViewGroup)) の中で使うと、なんと意図通りに動いてくれないのです。
public View getView(int position, View convertView, ViewGroup parent) {
// リストに表示するモノと、その状態を持っているオブジェクト
final SomeListItem item = listItems.get(potision);
CheckBox checkBox = (CheckBox) convertView.findViewById(R.id.checkbox);
// 初期状態を設定
checkBox.setChecked(item.isSelected);
// イベントリスナーを設定
// setChecked() でもイベントが走ってしまうので、初期値設定より後にした
// 上の item がこのリスナーにキャプチャーされる
checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
// 状態を記録するのは、convertViewが使いまわされた際に状態を復帰できるようにしておくため
item.setSelected(isChecked);
}
});
return convertView;
}
すると、なんということでしょう!
チェックボックスを画面外までスクロールしてからまた画面内に戻すと 必ずチェックが外れた状態に!!
原因
言うまでもなく、(あるconvertViewに対しての)二度目の getView()
が原因です。
御存知の通り、AndroidのListViewは、画面外にはみ出した行のViewを再利用して描画を行っています。
その場合は convertView
には、以前の getView()
でreturnしたViewがそのまま入って来ます。
するとどうなるかと言うと…
一度目の getView()
初期状態のままの行のViewが第二引数 (convertView) に渡ってきます。
ですので、上記のように初期値を設定できるわけです。
Viewがリストに表示される 〜 画面外へ消える
その間にユーザーがチェックボックスを操作して、チェックを入れるとします。
onCheckedChanged()
が叩かれて、 item.setSelected(isChecked);
でチェックの状態が記録されます。
二度目の getView()
今度の convertView は初期状態ではありません。
checkBoxにはチェックが入っています!
public View getView(int position, View convertView, ViewGroup parent) {
// 新しくリストに表示するアイテム
// item.isSelected == false とする
final SomeListItem item = listItems.get(potision);
// さっきまで画面に表示されていたチェックボックス
// checkbox.isChecked() == true とする
CheckBox checkBox = (CheckBox) convertView.findViewById(R.id.checkbox);
// 初期状態を設定
// この時点で、以前にセットしたイベントリスナーが走ってしまう!!
// ということは item.setSelected(false); となってしまうということであって…
checkBox.setChecked(item.isSelected);
// 新しいイベントリスナーを設定
// ここでようやく新しいアイテムがリスナーにキャプチャーされる
checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
item.setSelected(isChecked);
}
});
return convertView;
}
解決策
2つあります。
ナイーブな方法
CompoundButton.OnCheckedChangeListener
を使わずに View.OnClickListener
を使う方法です。
直感的で影響範囲も小さいため、気軽に実装ができるのがメリットです。
タッチスクリーンを使わずに操作された場合など、何らかの原因でclickのイベントが発火しなかった場合に動作しないことが考えられるのがデメリットとして挙げられるでしょう。
しかし何よりのデメリットは、 CheckBoxなのにCheckedChangeイベントを使わないのが気持ち悪い ことです!!
public View getView(int position, View convertView, ViewGroup parent) {
final SomeListItem item = listItems.get(potision);
CheckBox checkBox = (CheckBox) convertView.findViewById(R.id.checkbox);
// 初期状態を設定
// OnCheckedChangeListener に何もセットしていないので2週目も安心して使える
checkBox.setChecked(item.isSelected);
// イベントリスナーを設定
checkBox.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// チェック状態は自力で取得する
boolean isChecked = ((CheckBox) view).isChecked();
item.setSelected(isChecked);
}
});
return convertView;
}
OnCheckedChangeListenerを使い通す方法
上の方法を気持ち悪いと思ってしまう潔癖な方はこうしましょう。
ただし、初期状態を設定する段階でイベントが発火してしまっても大丈夫である(冪等性がある)場合に限ります。
そうでない場合は多分新しいバグが出るのでお気をつけて!
public View getView(int position, View convertView, ViewGroup parent) {
// リストに表示するモノと、その状態を持っているオブジェクト
final SomeListItem item = listItems.get(potision);
CheckBox checkBox = (CheckBox) convertView.findViewById(R.id.checkbox);
// 先にイベントリスナーを設定
// ただし、onCheckedChangedの中で行われる操作は冪等性が保たれなければいけない
checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
// ここではitemのメンバにboolean値を代入するだけなので冪等性は保たれているとする
item.setSelected(isChecked);
// もしここで冪等性が保たれない操作をしたりするとバグにつながる
// 例えば選択された個数をメンバに記録しようとする
// selectedCounter += isChecked ? 1 : -1;
// とか
}
});
// 後から初期状態を設定
// ここで一度イベントが発火することに注意
checkBox.setChecked(item.isSelected);
return convertView;
}