【Android】PreferenceActivityで使えるPreferenceを自作する

つい先日、むしゃくしゃしてこのようなアプリを作成、公開しました。

このアプリ自体は内輪ネタの極みなので死ぬほどどうでもいいんですが、色々と個人的に初めての試みがあり、その辺のノウハウをメモしていきたいなと。

と言うわけで、今回は自分でPreferenceを継承し、PreferenceActivityから呼べるクラスを作成してみます。

デフォルトで使用できるPreference

androidではSDK上にいくつかの便利なPreferenceが既に用意されています。PreferenceのKnown Direct Subclassesから引っ張ってみると、こんな感じですね。

更に(RingtonePreferenceはちょっと違うけど)上記のKnown Direct Subclassesを継承して作られているのが以下のPreferenceです。

逆に言えば、上記の中で欲しい機能がなければ自分で実装するしかありません。ただし、見た目上の問題だけであれば、setLayoutResourcesetWidgetLayoutResourceである程度差し替えることが可能です。(違いは後述)

で、今回はSeekBarを持ったPreferenceを作成してみたいと思います。これぐらいデフォルトで実装しておいてほしいものです。

Preferenceのレイアウト

そもそもPreferenceを継承して何かを作るとき、まず最初にどれから作っていけばいいのか、と言うのは、中々難しい問題です。ただ、他のPreferenceと合わせた時に違和感のあるレイアウトだと困ります。と言うわけで、レイアウト関連から考えていきましょう。

Preferenceのデフォルトレイアウトについてはこの記事が非常によくまとまっています。そして結局のところ、preference.xmlpreference_holo.xmlのどちらかが選ばれます。

ただし、setLayoutResourceが呼び出されていると、このリソースへの参照ごと書き換わります。つまり、前述の2ファイルはそのPreferenceには一切適用されないわけです。

ただ、Preferenceを継承するなら必ずXMLのソースは一読しておくべきです。例えば、setLayoutResourceを使用しつつ、Preferenceに設定されたtitleやsummary、iconなどを自動で設定してもらいたい場合は、それぞれidとして

を指定しておけばよい、と言うことがわかります。ListActivityにおけるandroid:id/listと同じ要領です。

また、setWidgetLayoutResourceを呼び出すとtitleやsummaryが入っているRelativeLayoutの下のLinearLayoutに突っ込まれる、と言うこともわかります。これを頭にいれておくと無駄なコードを書かなくて済むようになる…かもしれません。

じゃあこのデフォルトレイアウトはPreferenceのどこで生成されるのかと言うと、onCreateViewです。ソースを確認してみましょう。

protected View onCreateView(ViewGroup parent) {
    final LayoutInflater layoutInflater =
        (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

    final View layout = layoutInflater.inflate(mLayoutResId, parent, false); 

    if (mWidgetLayoutResId != 0) {
        final ViewGroup widgetFrame = (ViewGroup)layout.findViewById(com.android.internal.R.id.widget_frame);
        layoutInflater.inflate(mWidgetLayoutResId, widgetFrame);
    }

    return layout;
}

このソースを見れば、setLayoutResourceを使用しつつsetWidgetLayoutResourceも使用するにはandroid:id/widget_frameを持ったViewGroupがないといけない、と言うこともわかりますね。ライブラリとして公開するつもりがあるなら覚えておきたい事項です。

ただし、setWidgetLayoutResourceで設定するViewはtitleやsummaryの右側に表示されてしまいます。(CheckboxPreferenceのチェックボックスの位置)できれば下にもって行きたいです。

そこで、onCreateViewをオーバーライドしてtitleやsummaryが格納されているRelativeLayoutに対し動的に子ビューを追加して対処していきます。ただ、将来的なアップデートで使えなくなる可能性がなきにしもあらずですし、そもそももっとスマートな解決法がありそうな気がするんですが、わかりませんでした。(断念)

それじゃあ以上を踏まえて今回用のレイアウトファイルを作ってみましょう。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content">

    <SeekBar
        android:id="@+id/seekbar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="10" />

    <TextView
        android:id="@+id/txvValue"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginLeft="10dip"
        android:layout_weight="1"
        android:textSize="18sp" />

</LinearLayout>

次に実際にonCreateViewをオーバーライドしてみましょう。setLayoutResource / setWidgetLayoutResourceを呼び出すタイミングもここがベストなんじゃないでしょうか。

@Override
protected View onCreateView(ViewGroup parent) {
    //setWidgetLayoutResource(R.layout.seekbar_preference);
    //return super.onCreateView(parent);

    // parentはListViewなので、動的に子ビューを追加する場合は
    // super.onCreateViewで手に入るViewを使う
    val root = (LinearLayout) super.onCreateView(parent);
    
    for (int i = 0, size = root.getChildCount(); i < size; i++) {
        val v = root.getChildAt(i);
        if(!(v instanceof RelativeLayout)) continue;

        val r = (RelativeLayout) v;
        val seekbarLayout
            = (LinearLayout) LayoutInflater.from(getContext()).inflate(R.layout.seekbar_preference, null);

        // 動的にRelativeLayout内のViewの位置を設定するには
        // RelativeLayout.LayoutParams#addRuleを使用する
        val params = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.FILL_PARENT
                , RelativeLayout.LayoutParams.WRAP_CONTENT);
        
        // summaryはinternalなidなのでgetIdentifierで強引に取得する
        params.addRule(RelativeLayout.BELOW, Resources.getSystem().getIdentifier("summary", "id", "android"));

        // marginはseekbarLayoutのLayoutParamsから移し変えてもOK.
        val density = getContext().getResources().getDisplayMetrics().density;
        // 10dp
        params.topMargin = (int) (10f / density + 0.5f);

        seekbarLayout.setLayoutParams(params);
        r.addView(seekbarLayout);

        break;
    }
    
    return root;
}

自作Attributeと表示データのバインディング(onBindView)

実際にPreferenceに設定されている値を画面上に表示するのはonCreateViewではなくonBindViewでやるべき、とドキュメントには書かれています。(onCreateViewで値を設定するとこんなバグが発生する可能性がある。)なので、onCreateViewではレイアウト構造の作成までにとどめておき、実際にSeekBarの初期値などを設定するのはonBindViewでやりましょう。

ただ、そうした初期設定なんかを取得するには自作Attributeがあると便利です。この辺は割と頑張ってViewを自作したことがある人なら大体知っていると思いますし、調べるといっぱい出てくるので詳しい説明は割愛します。とりあえずは以下の三つの記事を読んでおけばOKです。

で、とりあえず欲しいのはSeekBarの最大値ぐらいです。また、何故かdefaultValueは後から取得できないので、コンストラクタで取得する必要があります。

ここで注意しなければならないのはandroid.R.styleable配下の値はJavaのコードではどうやっても取得できないと言うことです。もしもandroid.R.styleable配下のattrを取得したい場合はnameを「android:○○」の形で指定する必要があります。

そんなわけでこんなものを適当に作っておきます。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="SeekBarPreference">
        <attr name="max" format="integer" />
        <attr name="android:defaultValue"/>
    </declare-styleable>
</resources>

そろそろコンストラクタとか色々準備し始めましょう。ついでにonBindViewも記述します。

public class SeekBarPreference extends Preference implements OnSeekBarChangeListener {

    @Accessors(prefix="_") @Getter @Setter private int _max;
    @Accessors(prefix="_") @Getter @Setter private int _currentValue;

    public SeekBarPreference(Context context, int max) {
        super(context);
        _max = max;
    }

    public SeekBarPreference(Context context, int max, int currentValue) {
        super(context);
        _max = max;
        _currentValue = currentValue;
    }

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

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

    private void init(Context context, AttributeSet attrs) {
        @Cleanup("recycle") val t = context.obtainStyledAttributes(attrs, R.styleable.SeekBarPreference);

        // get max value
        _max = t.getInt(R.styleable.SeekBarPreference_max, 0);

        // get default value
        // constractor can not get persisted value
        // because SharedPreferences(PreferenceManager) has not initialize yet.
        _currentValue = t.getInt(R.styleable.SeekBarPreference_android_defaultValue, 0);
    }

    @Override
    protected View onCreateView(ViewGroup parent) {
        // set persited value
        val currentValue = super.getPersistedInt(-1);
        _currentValue = currentValue != -1 ? currentValue : _currentValue;

        val root = (LinearLayout) super.onCreateView(parent);
        for (int i = 0, size = root.getChildCount(); i < size; i++) {
            val v = root.getChildAt(i);
            if(!(v instanceof RelativeLayout)) continue;

            val r = (RelativeLayout) v;
            val seekbarLayout
                = (LinearLayout) LayoutInflater.from(getContext()).inflate(R.layout.seekbar_preference, null);

            val params = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.FILL_PARENT
                    , RelativeLayout.LayoutParams.WRAP_CONTENT);
            params.addRule(RelativeLayout.BELOW, Resources.getSystem().getIdentifier("summary", "id", "android"));

            val density = getContext().getResources().getDisplayMetrics().density;
            // 10dp
            params.topMargin = (int) (10f / density + 0.5f);

            seekbarLayout.setLayoutParams(params);
            r.addView(seekbarLayout);

            break;
        }

        return root;
    }

    @Override
    protected void onBindView(View view) {
        super.onBindView(view);
        ((TextView) view.findViewById(R.id.txvValue)).setText(String.format("%d/%d", _currentValue, _max));

        val seekBar = (SeekBar) view.findViewById(R.id.seekbar);
        seekBar.setMax(_max);
        seekBar.setProgress(_currentValue);
        seekBar.setOnSeekBarChangeListener(this);
    }

    @Override
    public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
        //TODO: onProgressChanged
    }

    @Override
    public void onStartTrackingTouch(SeekBar seekBar) {}

    @Override
    public void onStopTrackingTouch(SeekBar seekBar) {
        //TODO: onStopTrackingTouch
    }

}

AttributeSetを受け取るコンストラクタではinitメソッドに飛ばしてしまいます。PreferenceにはgetPersistedで始まるprotectedなメソッドを使うことで自身に設定されている値を取得できるので、persistedで取得できなかったらdefaultValueを使いたいんですが、コメントにも書いてある通りコンストラクタの時点ではPreferenceが所持しているSharedPreferences(を取得するPreferenceManager)が初期化されていないためpersistedは取得できません。仕方ないのでonCreateViewで取得するようにします。

onBindViewは割と見たまんまなので割愛します。後はOnSeekBarChangeListenerのメソッドであるonProgressChangedとonStopTrackingTouchを実装してしまえばクリア…なんですが、一つ問題があります。onCreateView / onBindViewで使用するViewは後から取得できません。getViewと言ういかにもなメソッドはあるんですが、こんな実装です。

public View getView(View convertView, ViewGroup parent) {
    if (convertView == null) {
        convertView = onCreateView(parent);
    }
    onBindView(convertView);
    return convertView;
}

これはPreferenceGroupAdapter(is BaseAdapter)と言うPreferenceActivity(is ListActivity)が持つAdapterから呼ばれるものであり、基本的に我々が呼び出すものではありません。正直これはpackage privateでもよかったんじゃないかってレベルのメソッドです。

本来であれば、Preferenceで表示されているデータが変更された場合はnotifyChangedを呼ぶのが筋です。これを呼び出すとこんな形でメソッドが連鎖していきます。

Preference#notifyChanged
↓
PreferenceGroupAdapter#notifyDataSetChanged
↓
PreferenceGroupAdapter#getView
↓
Preference#getView
↓
(Preference#onCreateView)
↓
Preference#onBindView

結局のところonBindViewを呼び直すことになるので、ちゃんと値が変更されたように見えるわけです。それはそれでいいんですが、「SeekBarの値をちょっといじる度に今表示されているView(Preference)を再更新させるの?」と考えると、流石に無駄が多すぎる気がします。

と言うわけで、onBindViewが呼ばれたらViewの参照を弱参照として保持しておくことにします。更に、findViewByIdの負担を減らすためにViewHolderも用意します。

private WeakReference<TextView> _txvValue;
private WeakReference<SeekBar> _seekBar;

private static final class SeekBarPreferenceViewHolder {
    public final TextView txtValue;
    public final SeekBar seekBar;

    public SeekBarPreferenceViewHolder(View view) {
        txtValue = (TextView) view.findViewById(R.id.txvValue);
        seekBar = (SeekBar) view.findViewById(R.id.seekbar);
    }
}

@Override
protected void onBindView(View view) {
    super.onBindView(view);

    val tag = view.getTag();
    SeekBarPreferenceViewHolder vh = null;

    if(tag != null && tag instanceof SeekBarPreferenceViewHolder) {
        vh = (SeekBarPreferenceViewHolder) tag;
    } else {
        vh = new SeekBarPreferenceViewHolder(view);
        view.setTag(vh);
        vh.seekBar.setOnSeekBarChangeListener(this);
    }

    vh.seekBar.setProgress(_currentValue);
    vh.seekBar.setMax(_max);

    vh.txtValue.setText(String.format("%d/%d", _currentValue, _max));
    if(_txvValue != null) _txvValue.clear();
    _txvValue = new WeakReference<TextView>(vh.txtValue);

    if(_seekBar != null) _seekBar.clear();
    _seekBar = new WeakReference<SeekBar>(vh.seekBar);
}

public void setMax(int max) {
    _max = max;
    changeTextView();

    if(_seekBar == null) return;

    val seekBar = _seekBar.get();
    if(seekBar != null) {
        seekBar.setMax(max);
        seekBar.setProgress(_currentValue);
    }
}

public void setCurrentValue(int currentValue) {
    _currentValue = currentValue;
    changeTextView();

    if(_seekBar == null) return;

    val seekBar = _seekBar.get();
    if(seekBar != null) seekBar.setProgress(currentValue);

    saveValue(_currentValue);
}

private void changeTextView() {
    if(txtView == null) return;

    val txtView = _txvValue.get();
    if(txtView != null) txtView.setText(String.format("%d/%d", _currentValue, _max));
}

private void saveValue(int v) {
    if(super.callChangeListener(v))
        super.getEditor().putInt(super.getKey(), v).commit();
}

Preferenceに値をセットする

当然シークバーを動かしただけで勝手に保存してくれるわけではないので、自分で保存する動きを実装する必要があります。

と言ってもそんなに難しくないです。getEditorメソッドを呼び出せばSharedPreferences.Editorが手に入りますし、自分自身のKeyはgetKeyを呼ぶだけです。

([2014/10/15追記] persistで始まるメソッドを使うことで上記内容と同じ処理を行ってくれます。)

ついでに、値を変更する処理の前にcallChangeListenerを呼び出してあげると紳士的です。

@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
    _currentValue = progress;
    changeTextView();
}

@Override
public void onStartTrackingTouch(SeekBar seekBar) {}

@Override
public void onStopTrackingTouch(SeekBar seekBar) {
    _currentValue = seekBar.getProgress();
    saveValue(_currentValue);
}

private void saveValue(int v) {
    if(super.callChangeListener(v))
        // super.getEditor().putInt(super.getKey(), v).commit();
        super.persistInt(v);
}

まとめ

まぁ大体こんなところでしょうか。他のところは親クラスとしてのPreferenceがよきにはからってくれます。

最後に全体のコードを紹介して終わりにしようかと思いましたが、よくよく考えてみれば全ソースコードをGitHubにあげているのでそっちを見てもらうことにしましょう。文字数も足りないし。

参考