본문 바로가기

Android/공부

ExpandableListView - 확장 가능한 리스트뷰

안녕하세요? 닉네임간편입니다. 이번 시간에는 확장이 가능한 리스트뷰에 대해서 다루어보겠습니다.

1. 개요

아래 그림처럼 한 아이템을 클릭하면 리스트 형식의 아이템이 보였다가 사라지는 위젯입니다.

ListView의 일종이기에 사용 방법은 유사하지만, ExpandableListView는 부모 아이템과 자식 아이템이 있다는 차이가 있습니다.

2. 만드는 법

앞서 말씀드린듯 ListView를 만드는 방식과 유사합니다.

먼저 이 뷰를 구현하기 위해선 다음과 같은 요소들이 필요합니다.

1) ExpandableListView

이 뷰를 표시할 레이아웃에 위젯을 추가해야 합니다.

전 ConstraintLayout 내부에서 사용했으므로 아래와 같이 추가했습니다.

<ExpandableListView
    android:id="@+id/expandableListView"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:nestedScrollingEnabled="true"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/fromText"
    app:layout_constraintBottom_toTopOf="@+id/linear_from"
    android:groupIndicator="@null">
</ExpandableListView>

이때 주의해야 할 점이 하나 있습니다.

개발자 문서에 따르면 ExpandableListView는 높이를 설정할 때 부모 뷰의 높이가 확실하게 정해져 있지 않다면 wrap_content를 사용하지 않아야 한다고 나와있습니다.

예를 들어서 ScrollView의 경우 높이가 확정적이지 않기 때문에 이 경우 ExpandableListView의 높이를 wrap_content로 설정하면 안 됩니다.

그러나 저는 메서드를 사용해 동적으로 높이를 조정할 것이기 때문에 괜찮습니다 ㅎ

물론 메서드를 사용하여서 높이를 설정할 것이 아니라면 높이를 확정적으로 설정해주셔야 합니다.

2) 부모 아이템 및 레이아웃

뷰에 사용할 부모 아이템과 레이아웃을 만들어줍니다.

아래 코드는 부모 아이템의 정보를 설정할 데이터 클래스입니다.

data class MenuTitle(val title: String, var index: Int)

아래 코드는 부모 아이템의 레이아웃이며, 시연 화면은 다음과 같습니다.

[코드]

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/main_menu">

    <TextView
        android:id="@+id/expand_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="기술스택"
        android:textColor="@color/black"
        android:textSize="16sp"
        android:layout_margin="15dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <ImageView
        android:id="@+id/explainIcon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:src="@drawable/ic_close_explain"
        android:layout_marginTop="15dp"
        android:layout_marginEnd="20dp"/>
    <View
        android:id="@+id/custom_line"
        android:layout_width="0dp"
        android:layout_height="1dp"
        android:background="#C1C1C1"
        android:layout_marginTop="15dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/expand_title"/>
</androidx.constraintlayout.widget.ConstraintLayout>

3) 자식 아이템 및 레이아웃

부모 아이템을 클릭하면 자식 아이템이 리스트 형식으로 나열됩니다. 이때 필요한 자식 아이템 및 레이아웃을 만들어줍니다.

아래 코드는 자식 아이템의 정보를 설정할 데이터 클래스입니다. 저는 이 정보를 인텐트로 전달하려고 했기 때문에 Parcelize 어노테이션을 붙였습니다.(Parcelize에 대해선 여기를 참조하세요)

@Parcelize
data class MenuSpecific(val title: String, val detail: String?, val from: String, val img: Int?) :
    Parcelable

[코드]

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="140dp"
    android:background="@color/white"
    android:padding="15dp">

    <TextView
        android:id="@+id/expand_child_title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginBottom="10dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:text="Android"
        android:textColor="@color/black"
        android:textSize="17sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@+id/expand_child_detail"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="@+id/guideline"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/expand_child_detail"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginBottom="10dp"
        android:text="with Kotlin"
        android:textColor="@color/gray"
        android:textSize="15sp"
        app:layout_constraintBottom_toTopOf="@+id/expand_child_num"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/guideline"
        app:layout_constraintTop_toBottomOf="@id/expand_child_title" />

    <TextView
        android:id="@+id/expand_child_num"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="10dp"
        android:text="by CreativeDuck"
        android:textColor="@color/black"
        android:textSize="17sp"

        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/expand_child_detail" />

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/guideline"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_percent="0.70" />

    <com.google.android.material.imageview.ShapeableImageView
        android:id="@+id/expand_child_image"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginStart="15dp"
        android:scaleType="centerCrop"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintStart_toStartOf="@+id/guideline"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:shapeAppearanceOverlay="@style/roundedLittleCorners"
        android:src="@drawable/ic_launcher_foreground"
        android:background="#88EA8C"/>

</androidx.constraintlayout.widget.ConstraintLayout>

 

4) BaseExpandableListAdapter()

리스트뷰에서도 어댑터가 필요했던 것처럼 어댑터를 만들어줍니다.

저는 커스텀 어댑터를 만들어 사용했으며, 코드는 아래와 같습니다.

[코드]

class ExpandableListAdapter(
    private val context: Context,
    private val parents: MutableList<MenuTitle>,
    private val childList: MutableList<MutableList<MenuSpecific>>
    ) : BaseExpandableListAdapter() {
    override fun getGroupCount(): Int {
        return parents.size
    }

    override fun getChildrenCount(parent: Int): Int {
        return childList[parent].size
    }

    override fun getGroup(parent: Int): Any {
        return parents[parent]
    }

    override fun getChild(parent: Int, child: Int): Any {
        return childList[parent][child]
    }

    override fun getGroupId(parent: Int): Long {
        return parent.toLong()
    }

    override fun getChildId(parent: Int, child: Int): Long {
        return child.toLong()
    }

    override fun hasStableIds(): Boolean {
        return false
    }

    override fun getGroupView(parent: Int, isExpanded: Boolean, convertView: View?, parentView: ViewGroup?): View {
        val binding = ExpandMenuTitleBinding.inflate(LayoutInflater.from(context), parentView, false)
        binding.expandTitle.text= parents[parent].title
        setArrow(binding, isExpanded)

        return binding.root
}

    override fun getChildView(parent: Int, child: Int, isLastChild: Boolean, convertView: View?, parentView: ViewGroup?): View {
        val binding = ExpandChildBinding.inflate(LayoutInflater.from(context), parentView, false)
        val item = getChild(parent, child) as MenuSpecific

        binding.expandChildTitle.text= item.title
        binding.expandChildDetail.text= item.detail
        binding.expandChildFrom.text= from
        if(item.img == null) {
            binding.expandChildImage.visibility= View.GONE
} else {
            binding.expandChildImage.setImageResource(item.img)
        }

        return binding.root
}

    override fun isChildSelectable(p0: Int, p1: Int): Boolean {
        return true
    }
    fun setArrow(binding: ExpandMenuTitleBinding, isExpanded: Boolean) {
        if(isExpanded) {
            binding.explainIcon.setImageResource(R.drawable.ic_close_explain)
        } else {
            binding.explainIcon.setImageResource(R.drawable.ic_show_explain)
        }
    }
}

 

5) 액티비티에 설정

이제 액티비티에 부모 아이템, 자식 아이템, 어댑터를 만든 후 ExpandableListView 객체에 설정해주면 완성입니다.

[코드]

fun setExpandableList() {
    val parentList = mutableListOf<MenuTitle>(MenuTitle("Android", 0),
            MenuTitle("IOS", 1),
            MenuTitle("Server", 2))
        val childList = mutableListOf<MutableList<MenuSpecific>>(
            mutableListOf(
                MenuSpecific("Kotlin", "사용 횟수",
                    16, R.drawable.ic_launcher_foreground),
                MenuSpecific("Java", "사용 횟수",
                    20, R.drawable.ic_launcher_foreground)),
            mutableListOf(
                MenuSpecific("Swift", "사용횟수", 0, null),
                MenuSpecific("Object C", "사용횟수", 0, null)
            ),
            mutableListOf(
                MenuSpecific("Python", "사용횟수", 0, null),
                MenuSpecific("PHP", "사용횟수",0, null),
                MenuSpecific("Node.js", "사용횟수", 0, null)
            )
        )
    val adapter = ExpandableListAdapter(requireContext(), parentList, childList)
    expandableListView.setAdapter(adapter)
    expandableListView.setOnGroupClickListener{parent, view, groupPosition, id->
				setListViewHeight(parent, groupPosition)
        false
}

코드 하단을 보면 setListViewHeight() 메서드가 나옵니다. 이 메서드는 동적으로 뷰 객체의 높이를 설정하는 코드이며 코드는 아래와 같습니다.

[코드]

fun setListViewHeight(listView: ExpandableListView, group: Int) {
    val listAdapter = listView.expandableListAdapteras ExpandableListAdapter
    var totalHeight = 0
    val desiredWidth: Int = View.MeasureSpec.makeMeasureSpec(
        listView.width,
        View.MeasureSpec.EXACTLY
)
    for (i in 0untillistAdapter.groupCount) {
        val groupItem: View = listAdapter.getGroupView(i, false, null, listView)
        groupItem.measure(desiredWidth, View.MeasureSpec.UNSPECIFIED)
        totalHeight += groupItem.measuredHeight
if(listView.isGroupExpanded(i) && i != group
            || !listView.isGroupExpanded(i) && i == group) {
            for (j in 0untillistAdapter.getChildrenCount(i)) {
                val listItem: View = listAdapter.getChildView(
                    i, j, false, null, listView
                )
                listItem.measure(desiredWidth, View.MeasureSpec.UNSPECIFIED)
                totalHeight += listItem.measuredHeight
}
        }
        val params = listView.layoutParams
var height = (totalHeight + listView.dividerHeight* (listAdapter.groupCount-1))
        if(height < 10) {
            height = 200
        }
        params.height = height
        listView.layoutParams= params
        listView.requestLayout()
    }
}

시연 화면입니다

3. 마무리

이번 시간에는 ExpandableListView에 대해서 알아보았습니다. 상용 서비스 중에도 이런 기능을 구현한 것이 많기 때문에, 이번 시간에 잘 정리되었으면 좋겠습니다.

728x90
반응형