まえおき
とあるアプリ開発で「定休日」の入力フォームを作っていたときのこと。
こういうフォームを作る必要があった。
一昔前だったら「そんなiOSっぽいフォームはAndroidの世界にはありませんよー?」だったんだけど、
今はもうMaterial Designのガイドにも存在している。
調べてみると、Android用のコンポーネントもあるようだ。
https://material.io/develop/android/components/material-button-toggle-group/
そんなわけで、昔からあるCheckBox、ToggleButtonではなく、新しそうなMaterialButtonToggleGroupを使ってみた。
画面横幅にあわせてボタンを配置する
何も考えずにリファレンスのとおりにボタンを配置してみる
MaterialButtonToggleGroupに愚直にボタンを置くだけだと・・・
<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="火" />
(以下略)
こんな感じで、画面をはみ出してしまう。
これは、MaterialButtonはButton継承の部品なので、minWidth, minHeightが設定されているためだ。
じゃあ minWidth=0
を指定すると・・・?
<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="火" />
wrap_parentの挙動そのものになる。でも画面幅ぴったりにはならない。
layout_weightを指定すれば良い!
画面幅にあわせるといえば、layout_weight
だ。
ただ、このプロパティはLinearLayoutが親じゃないと使えない。
そこでもう一度リファレンスを見てみよう。

なんとMaterialButtonToggleGroupはLinearLayout継承のコンポーネントではないか!
<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="火" />
layout_weight=1
を各ボタンに付けることで、期待通りになった!
ViewModelを使って2-wayデータバインディングする

画面回転するとフォームの入力値がリセットされるのでは困る。
よほど凝ったフォームじゃない限りは、フォームの入力値はViewModelに持たせるのが定石である。
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を作り、
<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を使うとこれではうまくいかない。
レイアウトファイルのエラーを見てみると、
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
ソースを読む。
@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は今回使わないので、とりあえず適当にコピペすればいけるだろう。
@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を作る。
これで、ビルドが通る。

めでたしめでたし。
まとめ
MaterialButtonToggleGroup を使うと、ちょっと今風なトグルが作れる。ただし
- 場合によってはMaterialButtonに minWidth=0, layout_weight=1 指定が必要かもしれない
- 2-wayデータバインディングを使うには、MaterialButtonのバインディングアダプタを自前で実装する必要がある
というハマりどころがあった。
お試しソースコードはまとめてここにおいてあります→ https://github.com/YusukeIwaki/MaterialButtonToggleGroupPlayground