【Android】RadioButtonを複数行に配置する

複数行のRadioButtonが標準で用意されてなかったので、作ってみたところ、意外と詰まったのでまとめてみました。
イメージは下の通りです。少しレイアウトが弄ってありますが、詳しい説明は省かせていただきます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/985195/bcf472a5-5bfa-db00-d23b-13558d7ef58e.gif" width=450 />

忙しい方は[3. RadioGroupをカスタマイズする](#3-radiogroupをカスタマイズする)をご覧ください。

# 1. TableLayoutをカスタマイズする
**おすすめ度 ★★★★☆**

TableLayoutをRadioGroup代わりに利用する方法です。調べると大体がこの方法に行き着くと思います。

### 参考
https://stackoverflow.com/a/33802142

```java:RadioGridGroup.java
import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.IdRes;
import android.util.AttributeSet;
import android.view.View;
import android.widget.RadioButton;
import android.widget.TableLayout;
import android.widget.TableRow;

public class RadioGridGroup extends TableLayout implements View.OnClickListener {

    private static final String TAG = "ToggleButtonGroupTableLayout";
    private int checkedButtonID = -1;

    /**
     * @param context
     */
    public RadioGridGroup(Context context) {
        super(context);
        // TODO Auto-generated constructor stub
    }

    /**
     * @param context
     * @param attrs
     */
    public RadioGridGroup(Context context, AttributeSet attrs) {
        super(context, attrs);
        // TODO Auto-generated constructor stub
    }

    @Override
    public void onClick(View v) {
        if (v instanceof RadioButton) {
            int id = v.getId();
            check(id);
        }
    }

    private void setCheckedStateForView(int viewId, boolean checked) {
        View checkedView = findViewById(viewId);
        if (checkedView != null && checkedView instanceof RadioButton) {
            ((RadioButton) checkedView).setChecked(checked);
        }
    }

    /* (non-Javadoc)
     * @see android.widget.TableLayout#addView(android.view.View, int, android.view.ViewGroup.LayoutParams)
     */
    @Override
    public void addView(View child, int index,
                        android.view.ViewGroup.LayoutParams params) {
        super.addView(child, index, params);
        setChildrenOnClickListener((TableRow) child);
    }


    /* (non-Javadoc)
     * @see android.widget.TableLayout#addView(android.view.View, android.view.ViewGroup.LayoutParams)
     */
    @Override
    public void addView(View child, android.view.ViewGroup.LayoutParams params) {
        super.addView(child, params);
        setChildrenOnClickListener((TableRow) child);
    }


    private void setChildrenOnClickListener(TableRow tr) {
        final int c = tr.getChildCount();
        for (int i = 0; i < c; i++) {
            final View v = tr.getChildAt(i);
            if (v instanceof RadioButton) {
                v.setOnClickListener(this);
            }
        }
    }


    /**
     * @return the checked button Id
     */
    public int getCheckedRadioButtonId() {
        return checkedButtonID;
    }


    /**
     * Check the id
     *
     * @param id
     */
    public void check(@IdRes int id) {
        // don't even bother
        if (id != -1 && (id == checkedButtonID)) {
            return;
        }
        if (checkedButtonID != -1) {
            setCheckedStateForView(checkedButtonID, false);
        }
        if (id != -1) {
            setCheckedStateForView(id, true);
        }
        setCheckedId(id);
    }

    /**
     * set the checked button Id
     *
     * @param id
     */
    private void setCheckedId(int id) {
        this.checkedButtonID = id;
    }

    public void clearCheck() {
        check(-1);
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (!(state instanceof SavedState)) {
            super.onRestoreInstanceState(state);
            return;
        }

        SavedState ss = (SavedState) state;
        super.onRestoreInstanceState(ss.getSuperState());

        this.checkedButtonID = ss.buttonId;
        setCheckedStateForView(checkedButtonID, true);
    }

    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        SavedState savedState = new SavedState(superState);
        savedState.buttonId = checkedButtonID;
        return savedState;
    }

    static class SavedState extends BaseSavedState {
        int buttonId;

        /**
         * Constructor used when reading from a parcel. Reads the state of the superclass.
         *
         * @param source
         */
        public SavedState(Parcel source) {
            super(source);
            buttonId = source.readInt();
        }

        /**
         * Constructor called by derived classes when creating their SavedState objects
         *
         * @param superState The state of the superclass of this view
         */
        public SavedState(Parcelable superState) {
            super(superState);
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(buttonId);
        }

        public static final Parcelable.Creator<SavedState> CREATOR =
                new Parcelable.Creator<SavedState>() {
                    public SavedState createFromParcel(Parcel in) {
                        return new SavedState(in);
                    }

                    public SavedState[] newArray(int size) {
                        return new SavedState[size];
                    }
                };
    }
}
```

3×2のレイアウトです。
`<com.test.customviews.RadioGridGroup`の部分は自分の環境に合ったものに変更しておいて下さい。

```xml:activity_main.xml
<com.test.customviews.RadioGridGroup
    android:id="@+id/group"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <TableRow>
        <RadioButton
            android:id="@+id/rad1"
            android:checked="true"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text1" />

        <RadioButton
            android:id="@+id/rad2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text2" />
    </TableRow>

    <TableRow>
        <RadioButton
            android:id="@+id/rad3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text3" />

        <RadioButton
            android:id="@+id/rad4"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text4" />
    </TableRow>

    <TableRow>
        <RadioButton
            android:id="@+id/rad5"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text5" />

        <RadioButton
            android:id="@+id/rad6"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text6" />
    </TableRow>
</com.test.customviews.RadioGridGroup>
```

XMLで`android:checked="true"`を指定すると、RadioButtonのチェックが外れなくなりバグるためコード上で指定する必要があります。

```kotlin:MainActivity.kt
val group = findViewById<GridRadioGroup>(R.id.radioSubject)
group.check(R.id.group)
```

### メリット
- TableLayoutなので比較的自由にレイアウトをカスタマイズすることができる

### デメリット
- RadioButtonのチェック状態が変更された時に呼ばれるリスナー`setOnCheckedChangeListener`が無い  
→ 独自リスナーを実装することで解決

```java:RadioGridGroup.java
public class RadioGridGroup extends TableLayout implements View.OnClickListener {

    private static final String TAG = "ToggleButtonGroupTableLayout";
    private int checkedButtonID = -1;
    private OnCheckedChangeListener mOnCheckedChangeListener;

    // ・・・ 略

    private void setCheckedId(int id) {
        if (mOnCheckedChangeListener != null) {
            mOnCheckedChangeListener.onCheckedChanged(this, id);
        }
        this.checkedButtonID = id;
    }

    // ・・・ 略

    public void setOnCheckedChangeListener(OnCheckedChangeListener listener) {
        mOnCheckedChangeListener = listener;
    }

    public interface OnCheckedChangeListener {
        void onCheckedChanged(GridRadioGroup gridRadioGroup, int checkedId);
    }
}
```
```kotlin:MainActivity.kt
group.setOnCheckedChangeListener { _, checkedId ->
    Log.d("GridRadioGroup", "${checkedId}が選択されました")
}
```

- RadioButtonに`setOnClickListener`を呼んだときにバグる  
→ 当てはまる人はごく少数だと思われますが、私の場合このバグが致命的だったため別の方法を探すことにしました

基本的なデメリットはRadioGroupが継承されてないことのみだと思います。

# 2. RadioGroupを3つ並べる
**おすすめ度 ★★☆☆☆**

RadioGroupを3つ並べてコードで操作する方法です。クラスの拡張等が必要ありません。

### 参考
https://stackoverflow.com/questions/10425569/radiogroup-with-two-columns-which-have-ten-radiobuttons

```xml:activity_main.xml
<RadioGroup
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <RadioGroup
        android:id="@+id/group1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <RadioButton
            android:id="@+id/rad1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text1" />

        <RadioButton
            android:id="@+id/rad2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text2" />
    </RadioGroup>

    <RadioGroup
        android:id="@+id/group2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <RadioButton
            android:id="@+id/rad3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text3" />

        <RadioButton
            android:id="@+id/rad4"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text4" />
    </RadioGroup>

    <RadioGroup
        android:id="@+id/group3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <RadioButton
            android:id="@+id/rad5"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text5" />

        <RadioButton
            android:id="@+id/rad6"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text6" />
    </RadioGroup>
</RadioGroup>
```

コード側です。仕組みは簡単で、一行目のRadioGroupが選択されたら二行目・三行目のチェックを解除。二行目のRadioGroupが選択されたら一行目・三行目のチェックを解除、といった仕組みです。

```kotlin:MainActivity.kt
val group1 = root.findViewById<RadioGroup>(R.id.group1)
val group2 = root.findViewById<RadioGroup>(R.id.group2)
val group3 = root.findViewById<RadioGroup>(R.id.group3)

listener = RadioGroup.OnCheckedChangeListener { group, checkedId ->
    if (checkedId != -1) when (group.id) {
        R.id.group1 -> {
            group2.clearCheck()
            group3.clearCheck()
        }
        R.id.group2 -> {
            group1.clearCheck()
            group3.clearCheck()
        }
        R.id.group3 -> {
            group1.clearCheck()
            group2.clearCheck()
        }
    }
}

group1.setOnCheckedChangeListener(listener)
group2.setOnCheckedChangeListener(listener)
group3.setOnCheckedChangeListener(listener)
```
一見動きそうに見えますが、実はこの状態では動きません。`clearCheck()`が呼ばれた時にも`OnCheckedChangeListener`が呼ばれるからです。
そのため、対処方法としては`clearCheck()`を呼ぶ度にリスナーの登録を解除する必要があります。

https://stackoverflow.com/questions/64591757/android-rdiogroup-clearcheck-conflict-with-setoncheckedchangelistener

```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {
    private lateinit var listener: RadioGroup.OnCheckedChangeListener

    override fun onCreate(savedInstanceState: Bundle?) {

        // ・・・ 略

        val group1 = findViewById<RadioGroup>(R.id.group1)
        val group2 = findViewById<RadioGroup>(R.id.group2)
        val group3 = findViewById<RadioGroup>(R.id.group3)

        listener = RadioGroup.OnCheckedChangeListener { group, checkedId ->
            if (checkedId != -1) when (group.id) {
                R.id.group1 -> {
                    checkAnswer(group2)
                    checkAnswer(group3)
                }
                R.id.group2 -> {
                    checkAnswer(group1)
                    checkAnswer(group3)
                }
                R.id.group3 -> {
                    checkAnswer(group1)
                    checkAnswer(group2)
                }
            }
        }

        group1.setOnCheckedChangeListener(listener)
        group2.setOnCheckedChangeListener(listener)
        group3.setOnCheckedChangeListener(listener)

        // ・・・ 略

    }

    private fun checkAnswer(radioGroup: RadioGroup) {
        radioGroup.setOnCheckedChangeListener(null)
        radioGroup.clearCheck()
        radioGroup.setOnCheckedChangeListener(listener)
    }
}
```

### メリット
- RadioGroupのメソッドが使える

### デメリット
- コードが多い ＆ 見た目が良くない
- listenerをグローバル変数に置く必要がある

RadioGroupが使えるため独自実装等が必要ありませんが、保守性があまり良くないです。

# 3. RadioGroupをカスタマイズする
**おすすめ度 ★★★★★**

見つけるのに結構時間がかかりました。仕組みは [2.](#2-radiogroupを3つ並べる) と同じです。
RadioGroup代わりに利用できるようカスタマイズされていたので有り難く使わせていただきます。

https://github.com/linfaxin/MultiRowsRadioGroup

```Java:MultiRowsRadioGroup.java
import android.content.Context;
import android.os.Build.VERSION;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CompoundButton;
import android.widget.RadioButton;
import android.widget.RadioGroup;

import java.util.ArrayList;

/**
 * 可以多行布局的RadioGroup，但是会用掉子RadioButton的OnCheckedChangeListener
 * A RadioGroup allow multiple rows layout, will use the RadioButton's OnCheckedChangeListener
 */
public class MultiRowsRadioGroup extends RadioGroup {

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

    public MultiRowsRadioGroup(Context context) {
        super(context);
        init();
    }

    private void init() {
        setOnHierarchyChangeListener(new OnHierarchyChangeListener() {
            public void onChildViewRemoved(View parent, View child) {
                if (parent == MultiRowsRadioGroup.this && child instanceof ViewGroup) {
                    for (RadioButton radioButton : getRadioButtonFromGroup((ViewGroup) child)) {
                        radioButton.setOnCheckedChangeListener(null);
                    }
                }
            }

            @Override
            public void onChildViewAdded(View parent, View child) {
                if (parent == MultiRowsRadioGroup.this && child instanceof ViewGroup) {
                    for (final RadioButton radioButton : getRadioButtonFromGroup((ViewGroup) child)) {
                        int id = radioButton.getId();
                        // generates an id if it's missing
                        if (id == View.NO_ID) {
                            if (VERSION.SDK_INT >= 17) id = View.generateViewId();
                            else id = radioButton.hashCode();
                            radioButton.setId(id);
                        }
                        if (radioButton.isChecked()) {
                            check(id);
                        }

                        radioButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
                            public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                                if (isChecked) {
                                    radioButton.setOnCheckedChangeListener(null);
                                    check(buttonView.getId());
                                    radioButton.setOnCheckedChangeListener(this);
                                }
                            }
                        });

                    }
                }
            }
        });
    }

    private boolean checking = false;

    @Override
    public void check(int id) {
        if (checking) return;
        checking = true;
        super.check(id);
        checking = false;
    }

    private ArrayList<RadioButton> getRadioButtonFromGroup(ViewGroup group) {
        if (group == null) return new ArrayList<>();
        ArrayList<RadioButton> list = new ArrayList<>();
        getRadioButtonFromGroup(group, list);
        return list;
    }

    private void getRadioButtonFromGroup(ViewGroup group, ArrayList<RadioButton> list) {
        for (int i = 0, count = group.getChildCount(); i < count; i++) {
            View child = group.getChildAt(i);
            if (child instanceof RadioButton) {
                list.add((RadioButton) child);

            } else if (child instanceof ViewGroup) {
                getRadioButtonFromGroup((ViewGroup) child, list);
            }
        }
    }
}
```

`<com.test.customviews.RadioGridGroup`の部分は自分の環境に合ったものに変更しておいて下さい。

```xml:activity_main.xml
<com.test.customviews.MultiRowsRadioGroup
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <RadioButton
            android:id="@+id/rad1"
            android:checked="true"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text1" />

        <RadioButton
            android:id="@+id/rad2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text2" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <RadioButton
            android:id="@+id/rad3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text3" />

        <RadioButton
            android:id="@+id/rad4"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text4" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <RadioButton
            android:id="@+id/rad5"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text5" />

        <RadioButton
            android:id="@+id/rad6"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="text6" />
    </LinearLayout>
</com.test.customviews.MultiRowsRadioGroup>
```

### メリット
- RadioGroupのメソッドが使える
- XMLだけで完結している

### デメリット
- 多分無し

# おわりに
自由にレイアウトをカスタマイズしたい方以外は [3.](#3-radiogroupをカスタマイズする) がおすすめです。