【Android】アイテムクリック時にイベントを発生させるListPreferenceを作成する

相変わらずこのような愚にもつかないクソアプリを作っています。まぁ、楽しいんですが…。それなりには。

設計上Preferenceを多用するのが一番楽だと判断しましたし、それは恐らく間違ってないんですが、PreferenceそのものがAndroid SDKにおけるBad Partの一つと言っても過言ではないので、ちょっと気を利かせたPreferenceを自作しようとすると結構面倒です。

と言うか、毎回継承しようとするクラスのソースを読まないと動作が理解できないって言うのはフレームワークとして普通に大失敗だと思うんですが、どうなんでしょう。どうなんでしょうって言うか、いい加減にして欲しいんですけどね。

今回の要件

Notificationのプロパティとしてvibrateと言うものがあります。読んで字の如く、通知する際に端末を振動させてくれます。そして型を見てもらえばわかる通りlongの配列を渡す必要があります。

具体的にどうやって指定するかと言うと、off,on,off,on…の周期をミリ秒の配列として渡してやればOKです。see alsoとして挙げられているVibrator#vibrateにそんな説明が書いてあります。

Notification n = new Notification();
n.vibrate = new long[]{ 0, 1000, 500, 1000};
// NotificationManagerからNotificationを発行するところは省略

このバイブのパターンをListPreferenceでユーザに設定させたい、と言うのが今回の趣旨です。

ListPreferenceの使い方

まぁこれぐらいなら普通のListPreferenceでも設定できるんですけどね。とりあえずドキュメントを読んでみましょう。

ListPreference独自のAttrとしてandroid:entriesandroid:entryValuesがあります。これはどちらもStringArrayのリソースIDを指定する必要があります。(StringArray以外のarrayは渡すことができないので注意

実例がないとわかりにくいと思うので他の部分で使っているものを紹介しておきましょう。LEDの色をListPreferenceで設定させています。

arrays.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="LightColorList">
        <item>赤</item>
        <item>青</item>
        <item>緑</item>
    </string-array>
    <string-array name="LightColorValues">
        <item>0xffff0000</item>
        <item>0xff0000ff</item>
        <item>0xff00ff00</item>
    </string-array>
</resources>

main.xml(抜粋)

<ListPreference
    android:title="光の色"
    android:summary="@string/summary_light"
    android:entries="@array/LightColorList"
    android:entryValues="@array/LightColorValues"
    android:key="lightColor"
    android:dependency="isLight"/>

このように設定すると画面上は赤、青、緑のリスト(LightColorList = android:entries)が表示されますが、実際に設定されている値はそれに対応するカラーコード(LightColorValues = android:entryValues)になっている、と言った按配です。

実際に値を取得する時はこんな感じになります。

public static long getLightColor(Context context) {
    SharedPreference pref = PreferenceManager.getDefaultSharedPreferences(context);
    return Long.decode(pref.getString("lightColor", "0xffff0000"));
}

ListPreferenceのダメなところ

今回の要件から言えばダメなところが二つあります。

  1. entryValuesの型がStringArrayで固定されている
  2. リストから選択すると即ダイアログが閉じてしまう

特に前者の方は引っかかったことがある人も多いんじゃないでしょうか?IntegerArrayTypedArrayだって受け取れそうなものなのに、と言いたいところなんですが、ちゃんと考えてみると結構難しいんですよこれが。

なんでこうなっているのかを知るにはListPreferenceのソースからコンストラクタを見てみるのが一番早いでしょう。

private CharSequence[] mEntries;
private CharSequence[] mEntryValues;

public ListPreference(Context context, AttributeSet attrs) {
    super(context, attrs);
    
    TypedArray a = context.obtainStyledAttributes(attrs,
            com.android.internal.R.styleable.ListPreference, 0, 0);
    mEntries = a.getTextArray(com.android.internal.R.styleable.ListPreference_entries);
    mEntryValues = a.getTextArray(com.android.internal.R.styleable.ListPreference_entryValues);
    a.recycle();
}

とまぁ、ここで型を決め打ちしてしまっています。これを対処するとなると何らかの型情報をattrsで渡すしかなさそうですし、mEntries / mEntryValuesの型はObjectにしないといけません。(Javaの配列は共変であり、ObjectにStringもIntegerも突っ込めてしまうが絶対にやるべきではない。)

じゃあジェネリクスにすれば、と思うんですが、残念ながらxmlに記述する際に型パラメータを指定することは出来ませんし、結局何かしらの型情報を渡さないと最終的にSharedPreferenceに保存できないです。

どうするにせよ型の問題をどうにかしようとするとListPreferenceを一から作り直さなくてはなりません。そして一から作り直したにしてはあまりにもお粗末なものが出来上がることが目に見えています。ここは涙を飲んで諦めましょう。まぁ、Stringならどうとでもなりますしね。

後者を直すのはさほど難しくないです。と言うか、選択された時点でダイアログを閉じてしまっても問題がないことの方が多いんですが、いちいちパターンを選択する→プレビューで試してみる→パターンを選び直す…と言う作業をユーザにやらせるのはちょっとクールじゃないってだけなので、ぶっちゃけ対処しなくてもいいレベルです。それじゃ話にならないのでちゃんと直しますけども。

ListPreferenceの実装を調べる

と言うわけで、もう一度ListPreferenceのソースに戻ってその辺の制御をしているところを探してみます。ListPrederenceはDialogPreferenceを継承しているので、恐らくAlertDialog.BuilderにListViewか何かを突っ込んでるのかなとあたりをつけていくと、onPrepareDialogBuilderと言うメソッドにぶつかります。

@Override
protected void onPrepareDialogBuilder(Builder builder) {
    super.onPrepareDialogBuilder(builder);
    
    if (mEntries == null || mEntryValues == null) {
        throw new IllegalStateException(
                "ListPreference requires an entries array and an entryValues array.");
    }

    mClickedDialogEntryIndex = getValueIndex();
    builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, 
            new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int which) {
                    mClickedDialogEntryIndex = which;

                    /*
                     * Clicking on an item simulates the positive button
                     * click, and dismisses the dialog.
                     */
                    ListPreference.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);
                    dialog.dismiss();
                }
    });
    
    /*
     * The typical interaction for list-based dialogs is to have
     * click-on-an-item dismiss the dialog instead of the user having to
     * press 'Ok'.
     */
    builder.setPositiveButton(null, null);
}

ListViewは現れませんでしたがAlertDialog.Builder#setSingleChoiceItemsを使って制御しているようです。このメソッドで渡しているDialogInterface.OnClickListenerの中身を見ていきましょう。

new DialogInterface.OnClickListener() {
    public void onClick(DialogInterface dialog, int which) {
        mClickedDialogEntryIndex = which;

        /*
         * Clicking on an item simulates the positive button
         * click, and dismisses the dialog.
         */
        ListPreference.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);
        dialog.dismiss();
    }
}

mClickedDialogEntryIndexは選択されたリストアイテムのindexです。実際に値を保存する時に使います。(と言うか、それ以外で使われない)

ここのdialog.dismissだけ消せればなぁ、と思うんですが、どうしようもありません。

さて、DialogPreferenceで表示されているdialogがdismissされるとonDialogClosedと言うメソッドに飛びます。わざわざcallChangeListenerを呼び出していることから推測するに、ListPreferenceではここで値を保存しているようですね。

@Override
protected void onDialogClosed(boolean positiveResult) {
    super.onDialogClosed(positiveResult);
    
    if (positiveResult && mClickedDialogEntryIndex >= 0 && mEntryValues != null) {
        String value = mEntryValues[mClickedDialogEntryIndex].toString();
        if (callChangeListener(value)) {
            setValue(value);
        }
    }
}

このpositiveResultとは何ぞや、って言うのは、DialogPreferenceのソースを見に行く必要があります。

public void onDismiss(DialogInterface dialog) {
    
    getPreferenceManager().unregisterOnActivityDestroyListener(this);
    
    mDialog = null;
    onDialogClosed(mWhichButtonClicked == DialogInterface.BUTTON_POSITIVE);
}

/**
 * Called when the dialog is dismissed and should be used to save data to
 * the {@link SharedPreferences}.
 * 
 * @param positiveResult Whether the positive button was clicked (true), or
 *            the negative button was clicked or the dialog was canceled (false).
 */
protected void onDialogClosed(boolean positiveResult) {
}

ListPreferenceがonPrepareDialogBuilderでやっていた内容を思い出しましょう。ListPreference.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);とbuilder.setPositiveButton(null, null);なんてコードがありました。リストのアイテムを選択した瞬間にDialogPreference#mWhichButtonClickedをDialogInterface.BUTTON_POSITIVEにし、dialog.dismissすることで強制的にpositiveResultをtrueにしているわけです。うーん。アドホック感漂ってますね。

ListPreferenceを継承して作り直す

これでListPreferenceが何をやっているのかなんとなくわかりました。次はこれをどう直していくかを考えましょう。

極端な話、onPrepareDialogBuilderで設定しているDialogInterface.OnClickListenerを差し替えてしまう、あるいは、何らかの形で委譲できるようにしてしまえば目的は達成されるように見えます。

単純にDialogInterface.OnClickListenerを受け取れるようにするならこんな感じですね。ただ、onDialogClosedのpositiveResultをtrueで貰えるようにしなきゃいけないので、PositiveButtonはちゃんと作成します。

public class EventableListPreference extends ListPreference {

    @Accessors(prefix="_") @Setter private DialogInterface.OnClickListener _onClickListener;

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

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

    @Override
    protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {

        val entries = super.getEntries();
        val entryValues = super.getEntryValues();

        if (entries == null || entryValues == null) {
            throw new IllegalStateException(
                    "EventableListPreference requires an entries array and an entryValues array.");
        }

        val entryIndex = super.findIndexOfValue(super.getValue());

        builder.setSingleChoiceItems(entries, entryIndex, _onClickListener);

        builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                // いるのかな…?
                EventableListPreference.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);
                dialog.dismiss();
            }
        });
    }
}

とは言えこのままだと動きません。ListPreference#mClickedDialogEntryIndexが変わらないので、どれだけ選択しても最終的にはずっと同じ値で保存されます。仕方ないので同じようなプロパティをこっちでも所持し、onDialogClosedをオーバーライドして全く同じことをするようにします。

また、DialogInterface.OnClickListenerを受け取るようにするとやっぱり意味がないので、別のコールバック用インターフェースを作成します。

public class EventableListPreference extends ListPreference {

    public interface OnChosenListener {
        public void onChosen(int index, String entry, String entryValue);
    }

    private int _selectedEntryIndex;
    @Accessors(prefix="_") @Setter private OnChosenListener _onChosenListener;

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

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

    @Override
    protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {

        val entries = super.getEntries();
        val entryValues = super.getEntryValues();

        if (entries == null || entryValues == null) {
            throw new IllegalStateException(
                    "EventableListPreference requires an entries array and an entryValues array.");
        }

        builder.setSingleChoiceItems(entries, super.findIndexOfValue(super.getValue())
            , new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    _selectedEntryIndex = which;
                    if(_onChosenListener != null) {
                        _onChosenListener.onChosen(which
                                , EventableListPreference.super.getEntries()[which].toString()
                                , EventableListPreference.super.getEntryValues()[which].toString());
                    }
                }
        });

        builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                EventableListPreference.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);
                dialog.dismiss();
            }
        });
    }

    @Override
    protected void onDialogClosed(boolean positiveResult) {
        val entryValues = super.getEntryValues();

        if (positiveResult && _selectedEntryIndex >= 0 && entryValues != null) {
            val value = entryValues[_selectedEntryIndex].toString();
            if (callChangeListener(value)) {
                super.setValue(value);
            }
        }
    }

}

onPrepareDialogBuilderもonDialogClosedもsuperを呼び出す必要はありません。ListPreferenceの方はそもそも呼び出したくないですし、DialogPreferenceの方はソースを見ればわかる通りどちらも空っぽです。

まとめ

後は実例…と思ったんですが、PreferenceActivityからfindPreferenceでEventableListPreferenceを取得し、setOnChosenListenerを呼び出すだけです。その中でentryValueをlongの配列に変換し、Vibratorをゴニョゴニョすればいけます。

arrays.xmlに記述するバイブのパターンはこんな風にでもしておけばいいでしょう。

<resources>
    <string-array name="VibePatternList">
        <item>1</item>
        <item>2</item>
    </string-array>
    <string-array name="VibePatternValues">
        <item>0,1000,500,1000</item>
        <item>500,1500,1000,1500</item>
    </string-array>
</resources>

参考