RecyclerView 아이템 하나만 선택되게 하기

<aside> 🔥 회사에서 작업 할 때 리사이클러뷰 안에 아이템을 딱 하나만 선택할 수 있는 뷰를 만들 일이 꽤 있었는데 그때마다 어려움을 겪어서 이번기회에 공부해봤다. 사실 알고보면 완전 쉬운 코드지만, 나의 삽질 🪓  일기 정도로 봐주면 좋을 것 같다.

</aside>

하고 싶은 것

하고 싶은 것

맨 처음 작성 했던 코드

//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=".recyclerview.MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:itemCount="30"
        tools:listitem="@layout/item_book" />

</androidx.constraintlayout.widget.ConstraintLayout>
//item_book.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"
    android:background="@color/black">

    <androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/image_view_item"
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:background="@color/teal_200"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@id/text_view_item"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:src="@drawable/ic_launcher_foreground" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/text_view_item"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginStart="10dp"
        android:gravity="center"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/image_view_item"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Test" />

</androidx.constraintlayout.widget.ConstraintLayout>
data class Book(
    val id: Int,
    val name: String,
    val image: Drawable,
    var isSelected: Boolean = false,
)
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val dummyList by lazy { getDummyBookList() }
    private lateinit var adapter: RecyclerAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)

        adapter = RecyclerAdapter(clickListener = { prevId, book ->
            //  adapter.notifyItemChanged(book.id)
            //  if(prevId>=0) {  adapter.notifyItemChanged(prevId) }
            adapter.notifyDataSetChanged()

        }, list = dummyList)

        binding.recyclerView.adapter = adapter
        //adapter.submitList(dummyList)
    }

    private fun getDummyBookList(): MutableList<Book> {
        val list = mutableListOf<Book>()
        for (i in 0..50) {
            val book = Book(
                i,
                "android${i + 1}",
                image = ContextCompat.getDrawable(this, R.drawable.ic_launcher_foreground)!!
            )
            list.add(book)
        }
        return list
    }
}
class RecyclerAdapter(
    private val clickListener: (Int, Book) -> Unit, private val list: List<Book>
) : RecyclerView.Adapter<RecyclerAdapter.BookViewHolder>() {

    private var selectedItemId = -1

    class BookViewHolder(private val binding: ItemBookBinding) :
        RecyclerView.ViewHolder(binding.root) {
        val textView: TextView
        val imageView: ImageView

        init {
            textView = binding.textViewItem
            imageView = binding.imageViewItem
        }
    }

    // var position = 0;

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder {
        //  Log.d("LOGGING","onCreateViewHolder ${position}")
        //   position+=1
        val inflater = LayoutInflater.from(parent.context)
        val binding = ItemBookBinding.inflate(inflater, parent, false)
        return BookViewHolder(binding)
    }

    override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
       // Log.d("LOGGING", "onBindViewHolder ${position}")
       // Log.d("LOGGING", "onBindViewHolder ${holder.hashCode()}")
        val item = list[position]
        holder.imageView.setImageDrawable(item.image)
        holder.textView.text = item.name

        item.isSelected = selectedItemId == item.id
        if (item.isSelected) holder.itemView.setBackgroundColor(Color.BLUE)
        //  else holder.itemView.setBackgroundColor(Color.BLACK)
        holder.itemView.setOnClickListener {
            val prevItemId = selectedItemId
            selectedItemId = item.id
            clickListener(prevItemId, item)
        }
    }

    override fun getItemCount(): Int = list.size

//    override fun getItemViewType(position: Int): Int {
//        return position
//    }
}

MainActivity에 리사이클러뷰가 있고, 그 리사이클러뷰에 RecyclerAdapter을 연결한다. 각 아이템을 클릭하면 아이템 백그라운드가 파란색으로 변하는 아주 단순한 리사이클러뷰이다. (주석 처리된 코드는 나중에 사용할 코드이니 지금은 생각하지 말자)

그런데 이 코드를 실행해보면 아래와 같이 내가 누르지 않은 뷰가 눌려있는 등 점점 아이템이 뒤죽박죽 되는 현상이 발생한다.

뒤죽박죽 리사이클러뷰

뒤죽박죽 리사이클러뷰

비슷한 현상을 검색해보니 getItemViewType 을 수정하라고 하길래 이것이 원인인줄 알았다. 결론적으로 이것이 원인은 아니지만 getItemViewType이 뭔지 알아보자.

getItemViewType 은 ‘뷰 재활용을 목적으로 위치에 있는 아이템의 뷰 타입을 반환한다. 기본적으로는 어댑터가 단일 뷰타입을 사용하게 하기위하여 0을 반환한다.’

리사이클러뷰는 뷰타입에 따라 다른 뷰홀더를 사용할 수 있는데, 따로 지정하지 않으면 0이라는 디폴트값을 리턴해줌으로써 같은 뷰타입을 사용하도록 하는 함수이다. 그리고 그 뷰타입 별로 화면에 보이는 아이템 갯수 + a 개수 만큼 뷰 홀더를 미리 만들어놓고 그 뷰홀더를 재활용 한다.

짧게 얘기하자면, 지금 상태는 화면에 보이는 아이템 갯수 + a 개수 만큼 같은 뷰타입의 뷰홀더를 만들어놓고 그 뷰홀더를 재활용 하고있다. 뷰홀더에 이전의 바인딩 기록(백그라운드가 파란색)이 남아있는 상태에서 뷰를 재활용을 하다보니 뒤죽박죽 된것 처럼 보인다.

스크린샷 2023-01-15 오후 6.36.34.png

그럼 getItemViewType에서 모두 다른 뷰타입을 리턴해주면 다 다른 뷰홀더를 생성하기 때문에 이런 뒤죽박죽 현상이 사라지지 않을까? - NOPE

override fun getItemViewType(position: Int): Int {
        return position
    }

getItemViewType을 각자의 포지션 값으로 리턴했다. 이러면 모든 아이템이 다 다른 뷰타입을 가지게되고 그러면 다 다른 뷰홀더를 생성하기 때문에 이런 뒤죽박죽 현상은 사라질 것이라 생각했지만 아니였다.

아이템마다 모두 다른 뷰홀더를 사용하지만, 아이템을 누를때마다 화면에 보이는 아이템은 onCreateViewHolder가 아니라 onBindViewHolder만 탄다. 이게 무슨말이냐면, 처음에 0번째 아이템을 누른후, 1번째 아이템을 누르면 0번째 아이템과 1번째 아이템의 뷰홀더는 다르지만, 0번째 아이템이 다시 onCreateViewHolder을 타서 새로운 뷰홀더를 만드는게 아니라 onBindViewHolder만 탄다. onBindViewHolder내에서 *if* (item.isSelected)holder.itemView.setBackgroundColor(Color.BLUE)를 타는 것도 아니지만 그렇다고 따로 !item.isSelected 일때 다시 검정 백그라운드로 돌려주는 코드도 없기때문에 0번째 아이템 뷰홀더의 백그라운드는 여전히 파란색이다. 따라서 0번째와 1번째 아이템 모두 파란 백그라운드로 바뀐다는 소리이다.

Screen_Recording_20230115_194348_My Application.mp4

또다른 문제점 - 뷰를 재활용 하는 리사이클러뷰의 목적이 없어진다.

만약 이 방법으로 문제가 해결됐다고 해도 또 다른 문제점이 있다. getItemViewType을 모두 다른 값으로 한다면, 아이템이 처음 만들어질 때 모든 아이템에 대하여 onCreateViewHolder을 탄다. 그러면 화면에 보여지는 몇개 +a 의 아이템에 대해서만 onCreateViewHolder을 타고 나머지 아이템은 이 뷰홀더를 재활용 한다는 리사이클러뷰의 목적이 사라지게된다.

스크린샷 2023-01-15 오후 7.17.55.png

onCreateViewHolder와 onBindViewHolder에 로그를 찍어 확인해보았다. 왼쪽은 getItemViewType을 따로 건들지 않았을 때(모두 0의 값이 리턴됨) 로그값이다. 화면에 보이는 아이템 갯수(15개) +a(3개)만 onCreateViewHolder를 타고 나머지는 그 뷰홀더를 재활용해 onBindViewHolder만 탄다는 것을 알 수 있다.

오른쪽은 getItemViewType을 모두 다른 값(position)으로 리턴했을때 로그값이다. 모두 다른 뷰타입을 가지므로 모두 다른 뷰홀더를 생성해야한다. 따라서 리스트의 사이즈(50)만큼 모든 아이템에 대하여 onCreateViewHolder과 onBindViewHolder을 탄다는 것을 알 수 있다. 따라서 getItemViewType에 모두 다른 값을 주는 방법은 리사이클러뷰가 위에서 말한 리사이클러뷰의 목적을 사라지게 하는 방법이다.

스크린샷 2023-01-15 오후 6.53.09.png

스크린샷 2023-01-15 오후 6.55.32.png

스크린샷 2023-01-15 오후 6.55.36.png

그러면, onBindViewHolder에서 !item.isSelected 일때 백그라운드를 검정으로 되돌려주는 코드를 넣으면 되겠네? - YES