📝

androidのマルチ選択に全選択ボタンを追加する

2022/02/05に公開

TL;DR


(雑な撮影ですみません)

以下のダイアログフラグメントを追加して使い方にあるのをボタンのonClickに設定すればok。もとのフラグメントが必要なのでobserverなどを使って発火させる必要がある

class CheckBoxListDialogFragment : DialogFragment() {
    override fun onCreateDialog(@Nullable savedInstanceState: Bundle?): Dialog {
        val list = requireArguments().getStringArrayList(ARGS_LIST)?.toTypedArray()
        val checkList = requireArguments().getBooleanArray(ARGS_CHECK_LIST)

        val title = createTitleText()
        val allSelectBtn = createAllSelectBtn(checkList)
        val titleWrapper = createTitleWrapper(title, allSelectBtn)

        val builder = AlertDialog.Builder(requireActivity()).apply {
            setMultiChoiceItems(list, checkList) { dialog, which, isChecked ->
                val count = (dialog as AlertDialog).listView.checkedItemCount
                list?.let {
                    allSelectBtn.isChecked = count == list.size
                }
                checkList?.set(which, isChecked)
            }
            setPositiveButton("ok") { _, _ ->
                setFragmentResult(REQUEST_KEY, bundleOf("check_list" to checkList))
            }
            setNegativeButton("cancel") { _, _ ->
                dialog?.cancel()
            }

            setCustomTitle(titleWrapper)
        }

        return builder.create()
    }

    private fun createTitleText(): TextView {
        return TextView(context).apply {
            val lp = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT,
            )
            lp.setMargins(
                lp.leftMargin,
                UIHelper.convertDp2PxAsInt(20, context),
                lp.rightMargin,
                UIHelper.convertDp2PxAsInt(20, context)
            )
            layoutParams = lp
            gravity = Gravity.CENTER

            text = "Title"
            setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
        }
    }

    private fun createAllSelectBtn(checkList: BooleanArray?): CheckBox {
        return CheckBox(context).apply {
            layoutParams = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT,
            ).apply {
                // about checkbox and text
                setMargins(
                    UIHelper.convertDp2PxAsInt(20, context),
                    topMargin,
                    rightMargin,
                    bottomMargin
                )
            }

            // about text (except checkbox)
            setPadding(
                UIHelper.convertDp2PxAsInt(20, context),
                paddingTop,
                paddingRight,
                paddingBottom
            )

            text = "select all"
            setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
            isChecked = checkList != null && checkList.all { it }

            setOnClickListener {
                val isChecked = (it as CheckBox).isChecked
                val listView = (dialog as (AlertDialog)).listView

                checkList?.let {
                    for (i in checkList.indices) {
                        // toggle check (display)
                        listView.setItemChecked(i, isChecked)
                        // toggle check (data)
                        checkList[i] = isChecked
                    }
                }
            }
        }
    }

    private fun createTitleWrapper(title: TextView, allSelectBtn: CheckBox): LinearLayout {
        return LinearLayout(context).apply {
            layoutParams = ViewGroup.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT,
            )
            orientation = LinearLayout.VERTICAL
            addView(title)
            addView(allSelectBtn)
        }
    }

    companion object {
        const val ARGS_LIST = "list"
        const val ARGS_CHECK_LIST = "check_list"
        private const val REQUEST_KEY = "request_key"

        fun show(
            target: Fragment,
            list: ArrayList<String>,
            checkList: BooleanArray,
            onClickOK: (checkList: BooleanArray) -> Unit,
        ) {
            list.size
            newInstance(list, checkList).run {
                target
                    .childFragmentManager
                    .setFragmentResultListener(
                        REQUEST_KEY,
                        target.viewLifecycleOwner
                    ) { requestKey, bundle ->
                        if (requestKey != REQUEST_KEY) return@setFragmentResultListener

                        if (bundle.containsKey("check_list")) {
                            bundle.getBooleanArray("check_list")?.let {
                                onClickOK(it)
                            }
                        }
                    }
                show(target.childFragmentManager, "CheckBoxListDialogFragment")
            }
        }

        private fun newInstance(
            list: ArrayList<String>,
            checklist: BooleanArray,
        ): CheckBoxListDialogFragment {
            val fragment = CheckBoxListDialogFragment()
            val args = Bundle().apply {
                putStringArrayList(ARGS_LIST, list)
                putBooleanArray(ARGS_CHECK_LIST, checklist)
            }

            fragment.arguments = args
            return fragment
        }
    }
}

使い方

fun onClickShow () {
    val list = arrayOfNulls<String>(15).mapIndexed { index, _ -> "test${index}" }

    CheckBoxListDialogFragment.show(this, ArrayList(list) , BooleanArray(list.size)) { resultBooleanArray ->
        val checkedList = list.filterIndexed { index, _ -> resultBooleanArray[index]}
	
	// do something
    }
}

解説

CheckBoxListDialogFragment.showの引数について

引数 説明
target
呼び出し元のフラグメント
list
表示するテキストのリスト
checklist
listに対応したbooleanの配列。したがってlist.size == checklist.sizeである。
trueのものはチェック済み、falseは未チェックである。
onClickOK ダイアログをokボタンで閉じたときに発火する。項目のチェックについてのbooleanArrayが返ってくるので、大体の場合はこれを使ってチェックしたものだけを取り出す。

時系列順に軽く説明

  1. onClickShowがバインドされたボタンをタップするとCheckBoxListDialogFragmentのcompanion objectに記述されているshowメソッドが発火する
  2. showメソッド内部ではnewInstanceが発火して、今回の主役であるCheckBoxListDialogFragmentを作成する
  3. 作成が終わったら次にFragmentResultListenerを登録する。ここでonClickOKを使う。
  4. FragmentResultListenerの登録が終わったらDialogFragment.showを呼び出す。
  5. showが呼び出されたあとにonCreateDialogが発火する。
  6. タイトルと全選択ボタンを作ってラッパーでまとめる。
  7. AlertDialog.Builderを作成してタイトル(6で作ったもの)、チェックリスト、okボタン、キャンセルボタンをセットする。
  8. okボタンではsetFragmentResultcheck_listという名前でbooleanArrayをbundleに追加。その後3で登録したlistenerが発火してコールバックを呼び出す

全選択ボタンについて詳細に説明

レイアウトの説明

setMarginsetPaddingで左側にそれぞれ20dpずつ設定する必要がある。

  • setMarginではチェックボックスとテキストの両方をまとめたコンポーネントに対してマージンを設定する。
  • setPaddingではチェックボックスは関係なく、テキストのpaddingだけを設定する


    だめ

    marginだけ40dpを設定したとき


    だめ

    paddingだけ40dpを設定したとき


    OK

    両方に20dpを設定したとき

他の説明

以下で初期値を決める。全部チェックされていたら全選択ボタンもチェック、どれか1つでもチェックされていない場合は全選択ボタンもチェックされない。

isChecked = checkList != null && checkList.all { it }



以下でクリックリスナーを登録。動作はなんとなくわかると思うが実装は少し工夫する必要がある。

setOnClickListener {
    val isChecked = (it as CheckBox).isChecked
    val listView = (dialog as (AlertDialog)).listView

    checkList?.let {
        for (i in checkList.indices) {
            // toggle check (display)
            listView.setItemChecked(i, isChecked)
            // toggle check (data)
            checkList[i] = isChecked
        }
    }
}

ここは2つ注意点がある。

  • checkList.indicesの部分は、0..listView.sizeを使ってはダメ。listView.sizeは表示している数なので100個とかある場合100がほしいのに12とかになる。
  • チェックの切替は表示と内部データの両方に対して行う必要がある。

チェックの切替について。
両方行わないと見た目と中身が違ってくるので、全部チェックしたのに何も変わってない、などが起こってしまう。

// ここではチェックの見た目をトグルしている
listView.setItemChecked(i, isChecked)

// ここでは内部データのチェックをトグルしている
checkList[i] = isChecked



onCreateDialogのbuilderのところでも全選択ボタンについて書いている。
これはそれぞれをタップして全部チェックしたときに全選択ボタンもチェックされるようにするもの。
逆に全部チェックした状態から1つだけチェックを外すと全選択ボタンのチェックも外れる。

setMultiChoiceItems(list, checkList) { dialog, which, isChecked ->
    val count = (dialog as AlertDialog).listView.checkedItemCount
    list?.let {
        allSelectBtn.isChecked = count == list.size
    }
    checkList?.set(which, isChecked)
}

補足

setMarginなどで使われているUIHelper.convertDp2PxAsInt(20, context)の内容は以下。
引数として与えられたdpをpxに変換している。

fun convertDp2PxAsInt(dp: Int, context: Context): Int {
    return (dp * context.resources.displayMetrics.density).toInt()
}

Discussion