회원 가입 창을 만들 때면 봉착하는 난감한 상황이 있다.
주로 약관 동의에 들어가는 전체 선택 기능...
참 별거 아닌데 가볍게 로직을 짜 보면 원하는 대로 기능이 동작하지 않거나 리스너 콜백이 서로 물려서 무한루프에 빠지거나..
암튼 대체 왜 이게 기본 기능으로 구현이 안 되어 있을까 싶고,
MaterialDesign 페이지를 봐도 전체 선택 기능에 애매한 상태(indeterminate state)를 나타내는 것도 있지만, Implementation탭 에는 눈을 씻고 찾아봐도 해당 기능을 구현할 수 있는 속성은 없다.
구글링을 해봐도 간단하고 유연하게(여러 단계의 적용 등) 써먹을 수 있는 코드는 안 보여서, 만들어 놓으면 필요할 때마다 써먹을 수 있을 것 같아 최대한 간단하게 사용할 수 있도록 구상을 했다. Indeterminate state는 제외.
Simply Use In XML
기능의 구현은 다음 섹션에서 확인하고 우선은 사용법부터 알아보면
<com.raonstudio.multilevelcheckbox.MultiLevelCheckBox
android:id="@+id/parent_checkbox"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="전체선택" />
<com.raonstudio.multilevelcheckbox.MultiLevelCheckBox
android:id="@+id/checkbox1"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_marginStart="24dp"
android:text="옵션1"
app:parentCheckBox="@id/parent_checkbox" />
<com.raonstudio.multilevelcheckbox.MultiLevelCheckBox
android:id="@+id/checkbox2"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_marginStart="48dp"
android:text="옵션2"
app:parentCheckBox="@id/parent_checkbox" />
위처럼 parentCheckBox 속성에 다른 CheckBox의 아이디를 넣어주면 된다.
그 외의 로직은 MultiLevelCheckBox에 구현이 되어있기에 따로 리스너를 달아주거나 하는 수고는 없다.
선택 결과는 checkbox.isChecked 로 확인해주면 된다.
Presentation
아래의 시연 영상은 여러 단계를 중첩해서 적용한 예시이다.
좀 빠르긴 하지만 터치 하는 체크박스에 따라 상위 레벨의 체크박스와 하위 레벨의 체크박스가 원하는 방식대로 변화함을 확인할 수 있다.
Implementaion
우선 xml layout에서 써먹을 수 있게 parentCheckBox속성 정의가 필요하다.
values 폴더에 attrs.xml 파일을 만들어 아래의 코드를 넣어야 한다.
<!--values/attrs.xml-->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MultiLevelCheckBox">
<attr name="parentCheckBox" format="reference" />
</declare-styleable>
</resources>
strings.xml, themes.xml, colors.xml 등이 들어있는 values 폴더에 attrs.xml을 만들어 위 코드를 넣어준다.
이렇게 작성하게 되면 레이아웃 xml 을 작성할 때 MultiLevelCheckBox 태그에서 parentCheckBox에 값을 넣어줄 수 있다.
format은 자료형인데 여기서는 다른 뷰의 참조를 넣어주어야 하기에 reference로 지정했다.
ConstraintLayout에서 layout_constriantTop_toTopOf 같은 속성의 format도 reference 이다.
궁금하다면 xml 에서 속성을 control(command)+click 해보기!
다음으로는 CheckBox를 상속하는 서브 클래스이다.
MultiLevelCheckBox 파일을 만들어 아래의 클래스를 작성해야한다.
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.checkbox.MaterialCheckBox
class MultiLevelCheckBox(context: Context, attributeSet: AttributeSet) :
MaterialCheckBox(context, attributeSet) {
private val parentId: Int
private val parentCheckBox get() = rootView.findViewById<MultiLevelCheckBox>(parentId)
private val checkedChildrenId = mutableListOf<Int>()
private val childrenId = mutableListOf<Int>()
private val childrenCheckBox get() = childrenId.map { rootView.findViewById<MultiLevelCheckBox>(it) }
init {
context.theme.obtainStyledAttributes(
attributeSet, R.styleable.MultiLevelCheckBox, 0, 0
).apply {
try {
parentId = getResourceId(R.styleable.MultiLevelCheckBox_parentCheckBox, -1)
} finally {
recycle()
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
parentCheckBox?.childrenId?.add(id)
}
override fun performClick(): Boolean {
val willBeChecked = !isChecked
updateChildren(willBeChecked)
updateParent(willBeChecked)
return super.performClick()
}
private fun updateChildren(checked: Boolean) {
childrenCheckBox.forEach {
it.isChecked = checked
it.updateChildren(checked)
}
with(checkedChildrenId) {
if (checked) {
clear()
addAll(childrenId)
} else {
clear()
}
}
}
private fun updateParent(checked: Boolean) {
parentCheckBox?.notifyChildChange(id, checked)
}
private fun notifyChildChange(childCheckBoxId: Int, checked: Boolean) {
with(checkedChildrenId) {
if (checked) {
add(childCheckBoxId)
takeIf { size == childrenId.size }?.let {
isChecked = true
updateParent(true)
}
} else {
remove(childCheckBoxId)
isChecked = false
updateParent(false)
}
}
}
}
* 복붙 할 땐 하더라도 이건 읽어주세요 *
MultiLevelCheckBox는 MaterialCheckBox를 상속받고 있습니다.
이렇게 할 경우 material 라이브러리를 추가하지 않았거나 theme가 MaterialComponents를 상속받은 style이 아니면 제대로 동작하지 않을 수 있습니다. 요즘 프로젝트 만들면 기본으로 Material 디자인이 적용되어있길래 문제없을 것 같아 범용성을 위해 MaterialCheckBox에서만 사용할 수 있는 속성(큰 의미는 없으나 app:useMaterialThemeColors 등)의 제약이 생기지 않게 MaterialCheckBox를 상속을 받았습니다.
theme관련 문제로 제대로 동작하지 않으면(CheckBox는 아니었지만, 레이아웃을 그리지 못해 크래시 나는 경우를 봄) Material Design을 적용하거나(themes.xml의 메인 style의 parent를 Theme.MaterialComponents~~로 변경), MaterialCheckBox대신 AppCompatCheckBox를 상속받도록 수정해도 문제없습니다.
AppCompatCheckBox를 사용하는 이유
This will automatically be used when you use CheckBox in your layouts and the top-level activity / dialog is provided by appcompat. You should only need to manually use this class when writing custom views.
MultiLevelCheckBox Class
우선 선언부
private val parentId: Int
private val parentCheckBox get() = rootView.findViewById<MultiLevelCheckBox>(parentId)
private val checkedChildrenId = mutableListOf<Int>()
private val childrenId = mutableListOf<Int>()
private val childrenCheckBox get() = childrenId.map { rootView.findViewById<MultiLevelCheckBox>(it) }
init {
context.theme.obtainStyledAttributes(attributeSet, R.styleable.MultiLevelCheckBox, 0, 0).apply {
try {
parentId = getResourceId(R.styleable.MultiLevelCheckBox_parentCheckBox, -1)
} finally {
recycle()
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
parentCheckBox?.childrenId?.add(id)
}
view의 id를 이용해 parent와 children을 링크시켜주었다.
모든 MultiLevelCheckBox는 parent객체와(제일 최상위 체크박스라면 null) child로 달린 체크박스들의 리스트를 가지고 있다.
init 블록의 구현은 뷰 클래스 만들기 - 맞춤 속성 정의 를 참고하여 CheckBox가 layout에서 설정한 parentId를 불러온다.
부모 CheckBox의 childrenId 추가를 init이 아닌 onAttachedToWindow에서 한 이유는 init에서는 id만 가져올 뿐 View 객체가 만들어지기 전이라 그런지 findViewById를 사용하는 parentCheckBox가 null을 반환해서다.
override fun performClick(): Boolean {
val willBeChecked = !isChecked
updateChildren(willBeChecked)
updateParent(willBeChecked)
return super.performClick()
}
예전에 전체 체크를 구현하려고 했을 때는 당연히 setOnCheckedChangListner에 내용을 구현했다가 무한루프에 빠져들었다.
setOnCheckedChangListner는 checkBox.isChecked가 변경이 되면 무조건 호출이 된다.
전체 체크를 구현하기 위한 규칙을 구현하다보면 parentCheckBox가 변하면 childCheckBox를 udpate해야하고 childCheckBox가 변하면 children의 상태에 따라 parentCheckBox를 체크 할지 말지에 대한 로직이 필요하다.
조건을 잘 달아주지 않으면 서로 값을 바꾸면서 무한의 구렁텅이에 빠지게 된다.
이를 해결하기 위해 onClick에 전체 체크 로직을 넣어 무한루프에 빠지지 않게 처리했다.
CustomView를 작성할 때 onClick에 대응하는 함수는 performClick이므로 이 메소드를 override하여 전체체크를 위한 로직을 넣었다.
performClick은 체크가 되기 전에 호출되므로 앞으로 변화될 체크 상태를 가지고 update를 진행한다.
private fun updateChildren(checked: Boolean) {
childrenCheckBox.forEach {
it.isChecked = checked
it.updateChildren(checked)
}
with(checkedChildrenId) {
if (checked) {
clear()
addAll(childrenId)
} else {
clear()
}
}
}
자신에게 달린 child를 변화시키고 checked 된 id list를 갱신시킨다. 변화된 자식은 또 자신의 자식들을 변화시킨다.
private fun updateParent(checked: Boolean) {
parentCheckBox?.notifyChildChange(id, checked)
}
private fun notifyChildChange(childCheckBoxId: Int, checked: Boolean) {
with(checkedChildrenId) {
if (checked) {
add(childCheckBoxId)
takeIf { size == childrenId.size }?.let {
isChecked = true
updateParent(true)
}
} else {
remove(childCheckBoxId)
isChecked = false
updateParent(false)
}
}
}
부모에게 자신의 체크 상태 변경을 알리고 부모는 checked 된 id list를 업데이트하여 자식들이 사용자에 의해 전부 체크가 되었다면 자신도 체크한다. 마찬가지로 변화된 부모는 자신의 부모에게 다시 자신의 상태 변경을 알린다.
https://github.com/raonian96/MultiLevelCheckBox