안드로이드 리사이클러뷰

이영준·2022년 9월 29일
1

📌 AdapterView

화면에서 보이지 않는 부분을 생성하지 않고 화면에서 사라진 뷰를 재사용
동적으로 동일한 뷰 들이 늘어나는 화면 구조 사용
Adapter에 따라 다양한 뷰들이 존재

Adapter에 등록된 데이터들을 보여주는 뷰가 AdapterView로 종류는 다음과 같다.

Adapter 종류

📌 Adapter

Adapter은 내가 만든 아이템 홀더(아이템이 보여질 xml 파일)에 각각의 데이터들을 결합해 화면을 구성하게끔 해준다.

기본적으로 ArrayAdapter등의 adapter을 상속받아 만들 수 있다.

class MyAdapter(context: Context, val resource: Int, val objects: Array<String>) :
        ArrayAdapter<String>(context, resource, objects) { //generic안에 adpater를 구성할 item의 타입을 넣어줌
        //resource = item holder XML 파일
        //objects = xml에 들어가는 아이템

        override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
            val inflater = LayoutInflater.from(context) //혹은 parent.context
            val view = inflater.inflate(resource, null)
            val tv = view.findViewById<TextView>(R.id.name)
            tv.text = objects[position]
            return view
        }

    }

Adapter은 기본적으로 context, resource, object를 생성자로 하여 만드는데
context는 뷰가 보여질 액티비티(this),
resource는 홀더의 XML,
object에는 홀더 안에 넣어줄 데이터들이 들어간다.

val adapter = MyAdapter(this, R.layout.item_row, COUNTRIES)
val listView = findViewById<ListView>(R.id.list)
listView.adapter = adapter

위 어댑터 객체를 만들어 listView에 연결해준다.

getView

getView를 오버라이딩 하여 layout inflater에 resource를 연결해준다.
이 getView의 로그를 찍어보면 화면에서 스크롤 하면서 아이템 값들이 새로 보여질때마다 불린다.

리스트 뷰는 기본적으로 뷰를 재사용한다.

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
            val inflater = LayoutInflater.from(context) //혹은 parent.context
            var view = convertView

            if(view == null){
                view = inflater.inflate(resource, null)
                Log.d(TAG, "getView: inflate")
            }
            else{
                Log.d(TAG, "getView: 재사용")
            }
            val tv = view!!.findViewById<TextView>(R.id.name)
            tv.text = objects[position]

            Log.d(TAG, "getView: $position")
            return view
        }

getView를 다음과 같이 코드를 수정하면 처음 보이는 화면들만 view에 inflate를 하고 그 이후는 스크롤을 하면 기존의 뷰에 데이터를 새로 넣는 방식으로 작동한다. 하지만 위 코드는 tv에 text를 넣기 위해 계속 findViewById를 하고 있다.

Holder 패턴으로 view Id 접근 줄이기

        override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
            var view = convertView
            var holder: ViewHolder
            if (view == null) {  // if it's not recycled, initialize some attributes
                val inf: LayoutInflater = LayoutInflater.from(parent.context)
                view = inf.inflate(resource, null)
                Log.d(TAG, "inflate")
                
                //미리 findViewById 해줌
                holder = ViewHolder()
                holder.tv = view.findViewById(R.id.name)
                //view에 태그로 달아줌
                view.tag = holder
                
            }
            else{
                holder = view.tag as ViewHolder
            }

            holder.tv.text = objects[position]
            return view!!
        }
    }

    class ViewHolder{
        lateinit var tv:TextView

    }

ViewHolder라는 클래스를 만들어 findViewById를 미리 해주고 일일히 달아줄 뷰에는 태그에 holder를 달아준다. 이 holder에 데이터를 할당해주면 view가 할당된 tag(holder)의 값을 가져와 보여준다.

📌 리사이클러뷰

이 Holder패턴을 매번 작성해주는 것을 방지하기 위해 리사이클러뷰를 사용한다.
리사이클러뷰는

  • 사용자가 관리하는 많은 수의 데이터 집합을 개별 아이템 단위로 구성하여 화면에 출력하는 뷰 그룹
  • 한 화면에 표시되기 힘든 많은 수의 데이터를 스크롤 가능한 리스트로 표시해주는 위젯
  • 리스트뷰에 유연함과 성능을 더한 리스트뷰의 확장판

🔑 리사이클러뷰 특징

  • ViewHolder 패턴을 이용해야만 함
    • listview는 뷰홀더 패턴 이용할 필요 없음
  • 가로세로, 그리드, staggered 그리드 모두 지원
  • listview는 세로만 지원
  • 아이템 애니메이션을 처리하는 클래스
    • listview는 애니메이션 없음
      (RecyclerView.ItemAnimator) 존재
  • 커스텀 어댑터 구현 필요
    • listview는 arrayAdapter, CursorAdapter등 존재
  • RecyclerView.ItemDecoration 객체로 구분선을 설정해야함
    • listview는 android:divider 속성으로 구분선 처리 쉽게 가능
  • 개별 터치 이벤트를 관리하지만, 클릭 처리 기능이 내장되어 있지 않음
    • listview는 개별 항목에 대한 클릭 이벤트에 바인딩하기 위한 AdapterView.OnItemClickListener 인터페이스 존재

🔑 리사이클러뷰 어댑터

어댑터는 3개의 메서드를 상속받아 작성한다.

  • onCreateViewHolder : 아이템 뷰를 위한 뷰홀더 객체 생성
  • onBindViewHolder : position에 해당하는 데이터를 뷰홀더의 아이템 뷰에 표시
  • getItemCount : 전체 아이템 개수 리턴
class HelloListView : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)
        val adapter = MyAdapter(COUNTRIES)
        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.layoutManager = LinearLayoutManager(this,LinearLayoutManager.VERTICAL, false) // xml에서 app:layoutmanager로 설정 가능
        recyclerView.adapter = adapter

    }

    class MyAdapter(var list: ArrayList<String>) :
        RecyclerView.Adapter<MyAdapter.CustomViewHolder>() {
        class CustomViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
            var name = itemView.findViewById<TextView>(R.id.name)
            fun bindInfo(data : String){
                name.text = data
            }
        }

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
            // view 생성 -> holder의 parameter로 넣어줌
            // val view = LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent, false)
            val view = LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent)
            return CustomViewHolder(view)
        }

        override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
            // holder.name.text = list[position]
            holder.bindInfo(list[position])
        }

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

    }

뷰 바인딩으로 바꾸어 재작성

class MyAdapter(var list: ArrayList<String>) :
        RecyclerView.Adapter<MyAdapter.CustomViewHolder>() {
        class CustomViewHolder(binding: ItemRowBinding) : RecyclerView.ViewHolder(binding.root) {
            var name = binding.name
            fun bindInfo(data : String){
                name.text = data
            }
        }

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
            // view 생성 -> holder의 parameter로 넣어줌
            // val view = LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent, false)
//            val view = LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent, false)
            val binding : ItemRowBinding = ItemRowBinding.inflate(LayoutInflater.from(parent.context))
            return CustomViewHolder(binding)
        }

        override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
            // holder.name.text = list[position]
            holder.bindInfo(list[position])
        }

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

    }

adapter 연결

val adapter = MyAdapter(COUNTRIES)
        binding.recyclerView.layoutManager = LinearLayoutManager(this,LinearLayoutManager.VERTICAL, false) // xml에서 app:layoutmanager로 설정 가능
        binding.recyclerView.adapter = adapter

        //구분선 넣어주기
        val deco = DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
        binding.recyclerView.addItemDecoration(deco)

onCreate에서 adapter를 만들어 넣어준다.
구분선을 넣기 위해 DividerItemDecoration을 사용했다.

📌 리사이클러뷰 이벤트 핸들러

onCreateViewHolder나 onBindViewHolder 등에 넣을 수 있다.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
            // view 생성 -> holder의 parameter로 넣어줌
            // val view = LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent, false)
//            val view = LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent, false)
            val binding: ItemRowBinding =
                ItemRowBinding.inflate(LayoutInflater.from(parent.context))
            return CustomViewHolder(binding).apply {
                binding.root.setOnClickListener{
                    Toast.makeText(parent.context, "선택됨",Toast.LENGTH_SHORT).show()
                }
            }
        }

하지만 adapter의 역할은 근본적으로 아이템을 홀더에 넣어주는 것이기 때문에 외부에서 작성하는 것이 더 바람직할 것이다.

🔑 adapter 외부에서 이벤트 핸들링

외부파일로 작성한 adapter에 activity가 이벤트 핸들링 작업을 하는 것이 각각의 파일의 역할에 맞을 것이다.

이를 위해서 adapter에 인터페이스를 만들어 구현체를 activity에 전달해준다.

        interface ItemClickListener{
            fun onClick( view:View, data:String, position:Int)
        }

        lateinit var itemClickListener: ItemClickListener

그리고 onBindViewHolder에서 각 아이템에 클릭 리스너를 달아준다.

            fun bindInfo(data:String){
                name.setText(data)
                itemView.setOnClickListener(){
                    itemClickListener.onClick(it, data, layoutPosition )
                }
            }
            
        override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
            holder.apply{
                bindInfo(list[position])
            }
        }

이제 액티비티에서 anonymous nested class 형태로 어댑터에 구현한 itemClickListener을 가져와 구현을 해준다.

adapter.itemClickListener = object : MyAdapter.ItemClickListener {
            override fun onClick(view: View, data: String, position: Int) {
                Toast.makeText(this@HelloListViewDeletable, "item clicked...${data}", Toast.LENGTH_SHORT).show()
            }
        }

🔑 데이터 변경에 따른 adapter 갱신

adapter.notifyDataSetChanged()
로 삭제, 추가, 수정 이벤트마다 어댑터가 바뀐 데이터를 적용하도록 처리할 수 있다. 그밖에 itemChanged 등등 다른 구체적인 메소드들이 존재한다.

하지만 화면 표시 아이템이 많아지면 이는 비효율 적이다.

🔑 DiffUtil로 ListAdapter 구현

다수의 경우에서 이러한 불필요한 비용을 줄이기 위해 ListAdapter에서 DiffUitl을 구현해준다.
DiffUtil.itemCallback을 구현하는데,

        companion object StringComparator : DiffUtil.ItemCallback<String>(){
            override fun areItemsTheSame(oldItem: String, newItem: String): Boolean {
                return oldItem.hashCode() == newItem.hashCode()
            }

            override fun areContentsTheSame(oldItem: String, newItem: String): Boolean {
                return oldItem == newItem
            }
        }

위 두 메서드를 구현해주어야 한다.
또한 ListAdapter에서 아이템 리스트를 받아 관리해주므로

class MyAdapter : ListAdapter<String, MyAdapter.CustomViewHolder>(StringComparator)

기존의 커스텀 adapter의 getItemCount 구현이 불필요하다.

이제 adapter 사용단에서 데이터를 연결해주려면

adapter.submitList(data.toMutableList())

submitList로 전달해주고, 데이터가 바뀔 때에도 똑같이 submitList로 전달하면 전체를 다시 그리는 것이 아닌 변경된 아이템만 업데이트 해준다.

전체코드

class HelloListView : AppCompatActivity(){
    lateinit var adapter: MyAdapter
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)
        val data = COUNTRIES
        adapter = MyAdapter()
        // submitList로 데이터를 제공한다.
        // 기존의 목록과 새로운 목록을 비교하기 때문에, 두 목록의 reference는 달라야 한다. toMutableList로 새로 생성.
        adapter.submitList(data.toMutableList())
        val rview = findViewById<RecyclerView>(R.id.recyclerView)
        rview.layoutManager = LinearLayoutManager(this,  LinearLayoutManager.VERTICAL, false)
        rview.adapter = adapter

        adapter.itemClickListener = object : MyAdapter.ItemClickListener {
            override fun onClick(view: View, data: String, position: Int) {
                Toast.makeText(this@HelloListView, "item clicked...${data}", Toast.LENGTH_SHORT).show()
            }
        }

        adapter.deleteListener = object : MyAdapter.DeleteListener {
            override fun delete(position: Int) {
                Log.d(TAG, "delete: $position")
                data.removeAt(position)
//                adapter.notifyDataSetChanged() //필요없어짐.
                //data 의 reference가 바뀌어야 ListAdapter가 워킹함.
                adapter.submitList(data.toMutableList())
            }
        }

        //구분선 추가.
        val dividerItemDecoration = DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
        rview.addItemDecoration(dividerItemDecoration)
    }

    //ListAdapter에서 목록관리하므로, collection으로 받을 필요 없음.
    class MyAdapter : ListAdapter<String, MyAdapter.CustomViewHolder>(StringComparator){

        companion object StringComparator : DiffUtil.ItemCallback<String>(){
            override fun areItemsTheSame(oldItem: String, newItem: String): Boolean {
                return oldItem.hashCode() == newItem.hashCode()
            }

            override fun areContentsTheSame(oldItem: String, newItem: String): Boolean {
                return oldItem == newItem
            }
        }

        inner class CustomViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnCreateContextMenuListener {
            init{
                itemView.setOnCreateContextMenuListener(this)
            }

            var name:TextView = itemView.findViewById<TextView>(R.id.name)
            fun bindInfo(data:String){
                name.setText(data)

                itemView.setOnClickListener(){
                    itemClickListener.onClick(it, data, layoutPosition )
                }
            }

            override fun onCreateContextMenu(menu: ContextMenu, v: View?, menuInfo: ContextMenu.ContextMenuInfo?) {
                val selected = itemView.findViewById<TextView>(R.id.name).text.toString()
                Log.d(TAG, "onCreateContextMenu: ${selected}")

                val menuItem = menu.add(0, 0 , 0, "delete");
                menuItem?.setOnMenuItemClickListener {
                    deleteListener.delete(layoutPosition)
                    Toast.makeText(itemView.context, "Hello:$selected", Toast.LENGTH_LONG).show()
                    true
                }
            }
        }

        interface ItemClickListener{
            fun onClick( view:View, data:String, position:Int)
        }

        lateinit var itemClickListener: ItemClickListener

        interface DeleteListener{
            fun delete( position:Int)
        }

        lateinit var deleteListener: DeleteListener

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) : CustomViewHolder {
            val view = LayoutInflater.from(parent.context).inflate(R.layout.item_row, parent, false)
            Log.d(TAG, "inflate")

            return CustomViewHolder(view)
        }

        override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
            holder.apply{
                //getItem : 위치의 값을 가져옴.
//                bindInfo(myList[position])
                bindInfo(getItem(position))
            }
        }

//        getItemCount 필요없음.
//        override fun getItemCount(): Int {
//            return myList.size
//        }
    }

📌 이전 실습 코드

만들고 까먹는 리사이클러뷰,, 천천히 튜토리얼식으로 적어나가 보자.

1. 리사이클러뷰 xml 만들기

리사이클러뷰에서 하나의 리스트 요소에 대응될 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="wrap_content">

    <ImageView
        android:id="@+id/iv_custom"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:src="@drawable/phoneman"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_custom"
        android:layout_width="91dp"
        android:layout_height="42dp"
        android:text="이름"
        android:gravity="center"
        android:textSize="30sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/iv_custom"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

png파일을 가져와 imageview안에 넣고 constraintView안에 이미지와 텍스트를 넣어서 간단하게 구성하였다.

2. 액티비티 xml에 넣기

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:orientation="vertical"
    tools:context=".MainActivity">


    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>

메인 액티비티 xml 파일이다. 이 곳안에 리사이클러뷰를 넣어준다. 디자인 모드에서 팔레트를 켜서

Common의 recyclerview를 드래그 앤 드롭으로 가져오는 것으로 편하게 할 수 있다.
Component Tree의 LinearLayout 안으로 드래그 해준다.

3. Activity 코드 작성

먼저 리사이클러뷰를 띄울 액티비티에 layoutmanager을 정의한다.

recyclerView.layoutManager = LinearLayoutManager(this)//linearlayout매니저를 MainActivity가 통제하도록 함

이어서 어댑터를 만들어줘야 되는데, 리사이클러뷰의 해당하는 xml을 가져오고, 몇개의 뷰를 만들어야 되는지를 지정하는 클래스로, 새로 CustomAdapter.kt 코틀린클래스 파일을 만들어서 정의해준다.

recyclerView.adapter=CustomAdapter()

CustomAdapter.kt

package com.example.test30

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView

class CustomViewHolder(v : View) : RecyclerView.ViewHolder(v) {}//한 화면에 표시되는 리스트 요소들을 나타냄

class CustomAdapter : RecyclerView.Adapter<CustomViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
        val cellForRow = LayoutInflater.from(parent.context).inflate(R.layout.custom_list,parent,false)//custom_list 파일을 뷰로 만듦
        return CustomViewHolder(cellForRow)

    }

    override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {

    }

    override fun getItemCount(): Int {//뷰 홀더의 개수를 반환
        return 100

    }
}

3개의 멤버메서드 onCreateViewHolder, onBindViewHolder, getItemCount를 오버라이드 해줘야 하는데,

onCreateViewHolder, getItemCount

onCreateViewHolder는 LayoutInflater을 통하여 custom_list xml 파일을 하나의 뷰 요소로 만들어주고, getItemCount는 그 뷰가 몇개인지를 반환해준다.

4. 리사이클러뷰 데이터 값 넣기

CustomAdapter.kt 에서 Data 클래스를 만들어주고,

class Data(val profile:Int, val name:String)

mainActivity 안에 뷰의 데이터로 넣을 값들을 list로 지정해줬다.

val DataList = arrayListOf(
        Data(R.drawable.phoneman, "0번"),
        Data(R.drawable.phoneman, "1번"),
        Data(R.drawable.phoneman, "2번"),
        Data(R.drawable.phoneman, "3번"),
        Data(R.drawable.phoneman, "4번"),
        Data(R.drawable.phoneman, "5번"),
        Data(R.drawable.phoneman, "6번"),
        Data(R.drawable.phoneman, "7번"),
        Data(R.drawable.phoneman, "8번"),
        Data(R.drawable.phoneman, "9번"),
        Data(R.drawable.phoneman, "10번")
    )

이제 이 DataList를 CustomAdapter 코틀린 파일에서 뷰 안에 넣어주자.

먼저 customViewHolder에 프로필이미지와 name을 지정할 수 있도록 profile과 name 변수를 생성해주고 xml의 id 값들로 지정해준다.

class CustomViewHolder(v : View) : RecyclerView.ViewHolder(v) {//한 화면에 표시되는 리스트 요소들을 나타냄
    val profile = v.iv_custom
    val name = v.tv_custom
}

그리고 CustomAdapter 역시 main파일에 사용한 Datalist변수를 매개변수로 하여 getItemCount와 onBindViewHolder에 활용해준다.

class CustomAdapter(val DataList:ArrayList<Data>) : RecyclerView.Adapter<CustomViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
        val cellForRow = LayoutInflater.from(parent.context).inflate(R.layout.custom_list,parent,false)//custom_list 파일을 뷰로 만듦
        return CustomViewHolder(cellForRow)

    }

    override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
        holder.profile.setImageResource(DataList[position].profile)
        holder.name.text = DataList[position].name
    }

    override fun getItemCount(): Int {//뷰 홀더의 개수를 반환
        return DataList.size

    }
}

profile
컴퓨터와 교육 그사이 어딘가

0개의 댓글