MaterialButtonToggleGroupを使ってみる

## まえおき

とあるアプリ開発で「定休日」の入力フォームを作っていたときのこと。
![Rectangle.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/d87e8be8-3bad-af24-ec27-59e2704fbd9d.png)
こういうフォームを作る必要があった。

一昔前だったら「そんなiOSっぽいフォームはAndroidの世界にはありませんよー？」だったんだけど、
今はもうMaterial Designのガイドにも存在している。
<img width="480" alt="貼り付けた画像_2020_01_19_17_29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/3e5f3fea-7fa4-1d4e-78d7-1d99d016cb33.png">

調べてみると、Android用のコンポーネントもあるようだ。
https://material.io/develop/android/components/material-button-toggle-group/

そんなわけで、昔からあるCheckBox、ToggleButtonではなく、新しそうな[MaterialButtonToggleGroup](https://developer.android.com/reference/com/google/android/material/button/MaterialButtonToggleGroup)を使ってみた。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/184b7dd6-6ff6-fe61-9be2-76326f7512d8.png)

## 画面横幅にあわせてボタンを配置する

### 何も考えずにリファレンスのとおりにボタンを配置してみる

MaterialButtonToggleGroupに愚直にボタンを置くだけだと・・・

```res/layout/activity.xml
    <com.google.android.material.button.MaterialButtonToggleGroup
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="日" />

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="月" />

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="火" />

　　(以下略)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/0dcca8ac-07c2-8e72-54c9-ead300bc0c55.png)


こんな感じで、画面をはみ出してしまう。
これは、MaterialButtonはButton継承の部品なので、minWidth, minHeightが設定されているためだ。


### じゃあ `minWidth=0` を指定すると・・・？

```res/layout/activity.xml
    <com.google.android.material.button.MaterialButtonToggleGroup
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:minWidth="0dp"
            android:text="日" />

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:minWidth="0dp"
            android:text="月" />

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:minWidth="0dp"
            android:text="火" />
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/3fb55885-6589-9350-b822-b511ba06d52e.png)

wrap_parentの挙動そのものになる。でも画面幅ぴったりにはならない。

### layout_weightを指定すれば良い！

画面幅にあわせるといえば、`layout_weight`だ。
ただ、このプロパティはLinearLayoutが親じゃないと使えない。

そこでもう一度リファレンスを見てみよう。

<img width="600" alt="貼り付けた画像_2020_01_19_21_10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/67710f7e-82c0-f4fb-188b-0d95b708c386.png">

なんと**MaterialButtonToggleGroupはLinearLayout継承**のコンポーネントではないか！

```res/layout/activity.xml
    <com.google.android.material.button.MaterialButtonToggleGroup
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:minWidth="0dp"
            android:text="日" />

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:minWidth="0dp"
            android:text="月" />

        <com.google.android.material.button.MaterialButton
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:minWidth="0dp"
            android:text="火" />
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/9e92c925-3715-0d2a-21b0-9087afdf67fc.png)

**`layout_weight=1`を各ボタンに付ける**ことで、期待通りになった！

## ViewModelを使って2-wayデータバインディングする

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/c19574b7-ac23-2b96-45bc-be10b58f719b.gif" width="480" />

画面回転するとフォームの入力値がリセットされるのでは困る。
よほど凝ったフォームじゃない限りは、フォームの入力値はViewModelに持たせるのが定石である。

```MainActivityViewModel.kt

class MainActivityViewModel : ViewModel() {
    val title = MutableLiveData<String>()
    val closedOnSun = MutableLiveData<Boolean>()
    val closedOnMon = MutableLiveData<Boolean>()
    val closedOnTue = MutableLiveData<Boolean>()
    val closedOnWed = MutableLiveData<Boolean>()
    val closedOnThu = MutableLiveData<Boolean>()
    val closedOnFri = MutableLiveData<Boolean>()
    val closedOnSat = MutableLiveData<Boolean>()
}
```

超適当だけど、とりあえずこんな感じで各ボタンのチェック状態を覚えておくLiveDataをもったViewModelを作り、

```res/layout/activity.xml
<layout>
    <data>
        <variable
            name="viewModel"
            type="io.github.yusukeiwaki.materialbuttontogglegroupplayground.MainActivityViewModel" />
    </data>

　　(中略)

        <com.google.android.material.button.MaterialButtonToggleGroup
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <com.google.android.material.button.MaterialButton
                style="?attr/materialButtonOutlinedStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:checked="@={viewModel.closedOnSun}"
                android:minWidth="0dp"
                android:text="日" />

            <com.google.android.material.button.MaterialButton
                style="?attr/materialButtonOutlinedStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:checked="@={viewModel.closedOnMon}"
                android:minWidth="0dp"
                android:text="月" />

            <com.google.android.material.button.MaterialButton
                style="?attr/materialButtonOutlinedStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:checked="@={viewModel.closedOnTue}"
                android:minWidth="0dp"
                android:text="火" />

```

こんな感じで、 `android:checked` にそれを指定する。

CheckBoxやToggleButtonだとこれでうまくいくはずだ。
しかしながらMaterialButtonToggleGroupを使うとこれではうまくいかない。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/9626612d-4d94-12ea-f0bf-41d3816d3528.png)

レイアウトファイルのエラーを見てみると、

```
Cannot find a getter for <com.google.android.material.button.MaterialButton android:checked> that accepts parameter type 'java.lang.Boolean'

If a binding adapter provides the getter, check that the adapter is annotated correctly and that the parameter type matches.
```

ようするにbinding adapterが無いよって言われている。

MaterialButtonには `setChecked(Bool)` / `isChecked: Bool` の setter/getterは定義されてるんだけども、CompoundButton（CheckBoxやToggleButtonの基底クラス）を継承はしていない。
きっとcheckが変化したリスナーを自動では見つけられないのだろうと推測。

### とりあえずbinding adapterを作る

CompoundButtonのbinding adapterをコピペすれば動くだろう、ということで
https://android.googlesource.com/platform/frameworks/data-binding/+/master/extensions/baseAdapters/src/main/java/android/databinding/adapters
ソースを読む。

```CompoundButtonBindingAdapter.java
@BindingMethods({
        @BindingMethod(type = CompoundButton.class, attribute = "android:buttonTint", method = "setButtonTintList"),
        @BindingMethod(type = CompoundButton.class, attribute = "android:onCheckedChanged", method = "setOnCheckedChangeListener"),
})
@InverseBindingMethods({
        @InverseBindingMethod(type = CompoundButton.class, attribute = "android:checked"),
})
public class CompoundButtonBindingAdapter {
    @BindingAdapter("android:checked")
    public static void setChecked(CompoundButton view, boolean checked) {
        if (view.isChecked() != checked) {
            view.setChecked(checked);
        }
    }
    @BindingAdapter(value = {"android:onCheckedChanged", "android:checkedAttrChanged"},
            requireAll = false)
    public static void setListeners(CompoundButton view, final OnCheckedChangeListener listener,
            final InverseBindingListener attrChange) {
        if (attrChange == null) {
            view.setOnCheckedChangeListener(listener);
        } else {
            view.setOnCheckedChangeListener(new OnCheckedChangeListener() {
                @Override
                public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                    if (listener != null) {
                        listener.onCheckedChanged(buttonView, isChecked);
                    }
                    attrChange.onChange();
                }
            });
        }
    }
}
```

なるほど。onCheckedChangedは今回使わないので、とりあえず適当にコピペすればいけるだろう。


```MaterialButtonBindingAdapter.java
@InverseBindingMethods({
        @InverseBindingMethod(type = MaterialButton.class, attribute = "android:checked"),
})
public class MaterialButtonBindingAdapter {
    @BindingAdapter("android:checked")
    public static void setChecked(MaterialButton view, boolean checked) {
        if (view.isChecked() != checked) {
            view.setChecked(checked);
        }
    }

    @BindingAdapter(value = {"android:checkedAttrChanged"}, requireAll = false)
    public static void setListeners(MaterialButton view, final InverseBindingListener attrChange) {
        if (attrChange != null) {
            // TODO:
            //   丁寧に実装するには、TextViewBindingAdapterのようにListenerUtilというクラスを使って
            //   前回仕掛けたリスナーを明示的に解除する必要がある。
            //   参考: https://android.googlesource.com/platform/frameworks/data-binding/+/master/extensions/baseAdapters/src/main/java/android/databinding/adapters/TextViewBindingAdapter.java
            view.clearOnCheckedChangeListeners();

            view.addOnCheckedChangeListener(new MaterialButton.OnCheckedChangeListener() {
                @Override
                public void onCheckedChanged(MaterialButton buttonView, boolean isChecked) {
                    attrChange.onChange();
                }
            });
        }
    }
}
```

こんな感じでbinding adapterを作る。

これで、ビルドが通る。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/d9af3998-a230-8387-9d0b-1c7821fab6ce.gif" width="480" />

めでたしめでたし。

## まとめ

MaterialButtonToggleGroup を使うと、ちょっと今風なトグルが作れる。ただし

* 場合によってはMaterialButtonに minWidth=0, layout_weight=1 指定が必要かもしれない
* 2-wayデータバインディングを使うには、MaterialButtonのバインディングアダプタを自前で実装する必要がある

というハマりどころがあった。

　

お試しソースコードはまとめてここにおいてあります→ https://github.com/YusukeIwaki/MaterialButtonToggleGroupPlayground