본문 바로가기

[Android/Kotlin] 안드로이드 Expandable RecyclerView 만들기

꿈꾸는블로그왕 2021. 2. 8.

안녕하세요. 오늘은 Expandable이라고 불리는 확장가능한 RecyclerView에 대해서 알아보겠습니다.

 

RecyclerView를 사용해서 목록을 보여줍니다.

목록안에 세부적인 정보를 숨기고 사용자가 특정 목록의 내용을 보고 싶을때만 열리고 닫히는게 하는것이

UI적으로도 보기 좋을때가 있습니다. 이러한 기능을 구현하는 방법에 대해서 알아보겠습니다.

 

크게 생각하면 아래 두가지를 구현한다고 생각하시면 됩니다.

1. RecyclerView 아이템 레이아웃에 상세 레이아웃 추가(클릭시 gone -> visible)

2. 화살표 버튼 Toggle 구현

 

완성된 모습은 아래와 같습니다.

 

 

 

 

STEP01. build.gradle/app 추가

circleImageView 라이브러리를 추가합니다. 프로필 사진을 Circle로 만들어줍니다.

implementation 'de.hdodenhof:circleimageview:3.0.0'

 

STEP02. 레이아웃 만들기

[activity_main.xml]

<?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="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

RecyclerView 하나만 있습니다.

 

[item_row.xml]

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:background="#fff"
        android:id="@+id/parent"
        android:focusable="true"
        android:clickable="true"
        android:minHeight="?attr/actionBarSize"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize">

        <de.hdodenhof.circleimageview.CircleImageView
            android:id="@+id/img_photo"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_margin="16dp"
            android:src="@mipmap/ic_launcher"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            />

        <TextView
            android:id="@+id/txt_name"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:text="James Bond"
            android:textColor="#37474F"
            android:textAppearance="@style/TextAppearance.AppCompat.Medium"
            app:layout_constraintStart_toEndOf="@+id/img_photo"
            app:layout_constraintTop_toTopOf="@+id/img_photo"
            app:layout_constraintBottom_toBottomOf="@+id/img_photo"

            />

        <ImageButton
            android:id="@+id/img_more"
            android:src="@drawable/ic_round_keyboard_arrow_down_24"
            android:background="?attr/selectableItemBackgroundBorderless"
            android:layout_width="?attr/actionBarSize"
            android:layout_height="?attr/actionBarSize"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            />

    </androidx.constraintlayout.widget.ConstraintLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="#F2F2F2"/>

    <LinearLayout
        android:id="@+id/layout_expand"
        android:visibility="gone"
        android:background="#F1F1F1"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="15dp"
            android:text="@string/lorem"/>

    </LinearLayout>

</LinearLayout>

 

 

[strings.xml]

<resources>
    <string name="app_name">ExpandableRecyclerView</string>

    <string name="lorem">Lorem Ipsum is simply dummy text of the printing and typesetting industry.
        Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer
        took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries,
        but also the leap into electronic typesetting, remaining essentially unchanged.
        It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages,
        and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
        Why do we use it?</string>

    <string-array name="people">
        <item>단군 할아버지</item>
        <item>동명왕</item>
        <item>은조왕</item>
        <item>혁거세</item>
        <item>광개토대왕</item>
        <item>이사부</item>
        <item>백결선생</item>
        <item>의자왕</item>
        <item>계백</item>
        <item>관창</item>
        <item>김유신</item>
        <item>문무왕</item>
        <item>원효대사</item>
        <item>장보고</item>
        <item>대조영</item>
        <item>강감찬</item>
        <item>서희</item>
        <item>정중부</item>
        <item>최무선</item>
        <item>김부식</item>
    </string-array>

    <integer-array name="images">
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
        <item>@drawable/avatar</item>
    </integer-array>
</resources>

Data를 만들기위한 array 값들입니다. strings.xml 파일에 추가해주시면 됩니다.

사진은 아무거나 쓰셔도 됩니다.

 

STEP03. 데이터 클래스 만들기

[Person.kt]

data class Person(
    var name: String = "",
    var description: String = "",
    var isExpanded: Boolean = false,
    var image: Int = -1
)

정보를 저장할 데이터 클래스 입니다. isExpanded 변수에, 현재 상태가 확장인지 아닌지를 저장합니다.

Default값으로 false를 지정했습니다.

 

STEP04. Adapter 만들기

[ExpandableAdapter.kt]

class ExpandableAdapter(
    private val personList: List<Person>
) : RecyclerView.Adapter<ExpandableAdapter.MyViewHolder>() {

    class MyViewHolder(
        itemView: View
    ) : RecyclerView.ViewHolder(itemView) {
        fun bind(person: Person) {
            val txtName = itemView.findViewById<TextView>(R.id.txt_name)
            val imgPhoto = itemView.findViewById<CircleImageView>(R.id.img_photo)
            val imgMore = itemView.findViewById<ImageButton>(R.id.img_more)
            val layoutExpand = itemView.findViewById<LinearLayout>(R.id.layout_expand)

            txtName.text = person.name
            imgPhoto.setImageResource(person.image)

            imgMore.setOnClickListener {
                // 1
                val show = toggleLayout(!person.isExpanded, it, layoutExpand)
                person.isExpanded = show
            }
        }

        private fun toggleLayout(isExpanded: Boolean, view: View, layoutExpand: LinearLayout): Boolean {
            // 2
            ToggleAnimation.toggleArrow(view, isExpanded)
            if (isExpanded) {
                ToggleAnimation.expand(layoutExpand)
            } else {
                ToggleAnimation.collapse(layoutExpand)
            }
            return isExpanded
        }
    }


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent, false))
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(personList[position])
    }

    override fun getItemCount(): Int {
        return personList.size
    }
    
}

1. 처음에 화살표 이미지를 클릭하면 toggleLayout() 함수를 호출하고, togglerLayout() 함수를 통해 expandcollpase를 구현합니다.

2. ToggleAnimation의 toggleArrow를 호출합니다. 해당 메소드는 isExpaned의 여부에 따라 화살표를 회전합니다.

 

나머지는 일반적인 RecyclerView의 Adapter를 구현하는 코드입니다.

 

[ToggleAnimation.kt]

class ToggleAnimation {

    companion object {

        fun toggleArrow(view: View, isExpanded: Boolean): Boolean {
            if (isExpanded) {
                view.animate().setDuration(200).rotation(180f)
                return true
            } else {
                view.animate().setDuration(200).rotation(0f)
                return false
            }
        }

        fun expand(view: View) {
            val animation = expandAction(view)
            view.startAnimation(animation)
        }

        private fun expandAction(view: View) : Animation {
            view.measure(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
            val actualHeight = view.measuredHeight

            view.layoutParams.height = 0
            view.visibility = View.VISIBLE

            val animation = object : Animation() {
                override fun applyTransformation(interpolatedTime: Float, t: Transformation?) {
                    view.layoutParams.height = if (interpolatedTime == 1f) ViewGroup.LayoutParams.WRAP_CONTENT
                    else (actualHeight * interpolatedTime).toInt()

                    view.requestLayout()
                }
            }

            animation.duration = (actualHeight / view.context.resources.displayMetrics.density).toLong()

            view.startAnimation(animation)

            return animation
        }

        fun collapse(view: View) {
            val actualHeight = view.measuredHeight

            val animation = object : Animation() {
                override fun applyTransformation(interpolatedTime: Float, t: Transformation?) {
                    if (interpolatedTime == 1f) {
                        view.visibility = View.GONE
                    } else {
                        view.layoutParams.height = (actualHeight - (actualHeight * interpolatedTime)).toInt()
                        view.requestLayout()
                    }
                }
            }

            animation.duration = (actualHeight / view.context.resources.displayMetrics.density).toLong()
            view.startAnimation(animation)
        }
    }

}

applyTransformation 메소드를 오버라이드합니다. 애니메이션 동작이 끝나면 interpolatedTime = 1f가 됩니다.

 

[MainActivity.kt]

class MainActivity : AppCompatActivity() {

    private lateinit var personList: List<Person>
    private lateinit var adapter: ExpandableAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recycler_list)

        personList = ArrayList()
        personList = loadData()

        recyclerView.setHasFixedSize(true)
        recyclerView.layoutManager = LinearLayoutManager(this)
        adapter = ExpandableAdapter(personList)
        recyclerView.adapter = adapter

    }

    private fun loadData(): List<Person> {
        val people = ArrayList<Person>()

        val persons = resources.getStringArray(R.array.people)
        val images = resources.obtainTypedArray(R.array.images)

        for (i in persons.indices) {
            val person = Person().apply {
                name = persons[i]
                image = images.getResourceId(i, -1)
            }
            people.add(person)
        }
        return people
    }
}

 

 

 

댓글