관리 메뉴

나만을 위한 블로그

[Android] 아이템 하나만 선택 가능한 리사이클러뷰 만드는 법 + 데이터바인딩 적용 본문

Android

[Android] 아이템 하나만 선택 가능한 리사이클러뷰 만드는 법 + 데이터바인딩 적용

참깨빵위에참깨빵_ 2022. 8. 25. 00:44
728x90
반응형

리사이클러뷰를 만들다 보면 한 아이템을 선택해서 배경색이나 글자색 등을 바꾼 뒤 다른 아이템을 누르면 이전에 선택했던 아이템을 원래대로 바꾸고 새로 선택한 아이템의 배경색 등을 바꾸고 싶을 때가 있다.

체크박스라면 예제도 많아서 갖다 쓰면 되지만 이 포스팅에선 커스텀한 텍스트뷰의 글자색을 바꾸는 예제를 기록한다. 글자색 바꾸는 법을 알면 배경색 바꾸는 건 쉬우니 패스한다. 또한 액티비티, 아이템의 XML에 기본적으로 데이터 바인딩을 적용한다.

 

먼저 기본 틀부터 만들고 시작한다.

 

<?xml version="1.0" encoding="utf-8"?>
<!-- item_single_item.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="model"
            type="String" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        android:layout_marginTop="10dp">

        <TextView
            android:id="@+id/tvTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{model}"
            android:textSize="40sp"
            android:textColor="@color/black"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="테스트"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ItemSingleItemBinding

class SelectSingleItemAdapter(
    private val context: Context,
    private val list: ArrayList<SimpleModel>,
): RecyclerView.Adapter<SelectSingleItemAdapter.SelectSingleItemViewHolder>() {

    private val TAG = this.javaClass.simpleName
    private lateinit var binding: ItemSingleItemBinding
    private var onItemClickListener: OnItemClickListener? = null

    private var selectedPosition = 0

    interface OnItemClickListener {
        fun onItemClick(item: SimpleModel, position: Int)
    }

    fun setOnItemClickListener(listener: OnItemClickListener) {
        this.onItemClickListener = listener
    }

    inner class SelectSingleItemViewHolder(
        private val binding: ItemSingleItemBinding
    ): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: SimpleModel) {
            binding.model = item

            if (onItemClickListener != null) {
                binding.tvTitle.setOnClickListener {
                    onItemClickListener?.onItemClick(item, absoluteAdapterPosition)
                    if (selectedPosition != absoluteAdapterPosition) {
                        binding.setChecked()
                        notifyItemChanged(selectedPosition)
                        selectedPosition = absoluteAdapterPosition
                    }
                }
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectSingleItemViewHolder {
        binding = ItemSingleItemBinding.inflate(LayoutInflater.from(context), parent, false)
        return SelectSingleItemViewHolder(binding)
    }

    override fun onBindViewHolder(holder: SelectSingleItemViewHolder, position: Int) {
        val item = list[position]
        holder.bind(item)
    }

    override fun getItemCount(): Int = list.size

}
<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".recyclerview.selectsingleitem.SelectSingleItemActivity">

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

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ActivitySelectSingleItemBinding

class SelectSingleItemActivity : AppCompatActivity() {

    private val TAG = this.javaClass.simpleName
    private lateinit var binding: ActivitySelectSingleItemBinding
    private var list = arrayListOf<SimpleModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_select_single_item)
        binding.run {
            lifecycleOwner = this@SelectSingleItemActivity

            for (i in 0..10) {
                list.add(SimpleModel(title = "테스트$i", isSelected = false))
            }

            recyclerview.apply {
                layoutManager = LinearLayoutManager(this@SelectSingleItemActivity)
                setHasFixedSize(true)
                adapter = SelectSingleItemAdapter(this@SelectSingleItemActivity, list).apply {
                    setOnItemClickListener(object : SelectSingleItemAdapter.OnItemClickListener {
                        override fun onItemClick(item: SimpleModel, position: Int) {
                            Toast.makeText(this@SelectSingleItemActivity, "선택한 문자열 : ${item.title}", Toast.LENGTH_SHORT).show()
                        }
                    })
                }
            }
        }
    }
}

 

이 상태로 빌드하고 아이템을 클릭하면 이런 화면이 나온다.

 

 

그러나 위 코드를 빌드해서 테스트0을 누르면 토스트만 나오고 글자색은 변경되지 않는다. 다른 아이템을 눌러야 선택한 아이템이 연보라색이 된다.

이제 이 예제를 좀 바꿔서 앱이 켜지면 테스트0이 연보라색으로 표시되고 클릭한 아이템의 글자색만 연보라색이 되게 한다. 먼저 이 예제에서의 선택된 상태, 선택해제된 상태의 정의는 아래와 같다

 

  • 선택된 상태 : 연보라색 글자
  • 선택해제된 상태 : 검은색 글자

 

이 상태에서 처음 이 화면에 들어왔을 때 첫 번째 아이템인 '테스트0'의 배경색과 글자색이 선택된 상태로 있게 하고 싶다면 먼저 아래와 같은 데이터 클래스를 만든다.

 

data class SimpleModel(
    val title: String,
    var isSelected: Boolean
)

 

왜냐면 몇 번 position의 아이템이 눌렸고 다른 position은 선택되지 않았다고 표시하기 위해선 T/F 값이 필요한데 그럴려면 데이터 클래스를 만들어 사용하는 게 속편하기 때문이다.

그 다음 item_single_item.xml에 아래와 같은 데이터 바인딩 코드를 추가한다.

 

<?xml version="1.0" encoding="utf-8"?>
<!-- item_single_item.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="model"
            type="com.example.kotlinprac.recyclerview.selectsingleitem.SimpleModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        android:layout_marginTop="10dp">

        <TextView
            android:id="@+id/tvTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{model.title}"
            android:textSize="40sp"
            android:textColor="@{model.selected ? @color/purple_200 : @color/black, default=@color/black}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="테스트"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

<variable> 안의 name에는 이 XML 안에서 객체로 사용할 데이터 클래스의 이름을 자유롭게 정하면 된다. 자바로 안드로이드를 시작할 때 리사이클러뷰를 공부하다 모델 클래스라는 이름을 알게 되어 난 익숙한 model로 지었지만 data나 checkedValue 등등 원하는 이름을 지어준다. type에는 위에서 만든 데이터 클래스가 있는 전체 경로를 적어준다.

그리고 텍스트뷰 코드를 보면 selected가 true면 어떤 색, false면 어떤 색을 설정하는 것을 삼항 연산자로 적용하는 걸 볼 수 있다. 데이터 바인딩에선 if문을 쓸 수 없기 때문에 저렇게 삼항 연산자로 표현한다. default에는 리사이클러뷰가 처음 렌더링될 때 어떤 색으로 보이게 할지 정해준다.

그리고 어댑터도 수정해준다.

 

import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ItemSingleItemBinding

class SelectSingleItemAdapter(
    private val context: Context,
    private val list: ArrayList<SimpleModel>,
): RecyclerView.Adapter<SelectSingleItemAdapter.SelectSingleItemViewHolder>() {

    private lateinit var binding: ItemSingleItemBinding
    private var onItemClickListener: OnItemClickListener? = null

    private var selectedPosition = 0

    interface OnItemClickListener {
        fun onItemClick(item: SimpleModel, position: Int)
    }

    fun setOnItemClickListener(listener: OnItemClickListener) {
        this.onItemClickListener = listener
    }

    inner class SelectSingleItemViewHolder(
        private val binding: ItemSingleItemBinding
    ): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: SimpleModel) {
            binding.model = item

            if (selectedPosition == absoluteAdapterPosition) {
                list[absoluteAdapterPosition].isSelected = true
                binding.setChecked()
            } else {
                list[absoluteAdapterPosition].isSelected = false
                binding.setUnchecked()
            }

            if (onItemClickListener != null) {
                binding.tvTitle.setOnClickListener {
                    onItemClickListener?.onItemClick(item, absoluteAdapterPosition)
                    if (selectedPosition != absoluteAdapterPosition) {
                        binding.setChecked()
                        notifyItemChanged(selectedPosition)
                        selectedPosition = absoluteAdapterPosition
                    }
                }
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectSingleItemViewHolder {
        binding = ItemSingleItemBinding.inflate(LayoutInflater.from(context), parent, false)
        return SelectSingleItemViewHolder(binding)
    }

    override fun onBindViewHolder(holder: SelectSingleItemViewHolder, position: Int) {
        val item = list[position]
        holder.bind(item)
    }

    override fun getItemCount(): Int = list.size

    private fun ItemSingleItemBinding.setChecked() = tvTitle.setTextColor(ContextCompat.getColor(context, R.color.purple_200))

    private fun ItemSingleItemBinding.setUnchecked() = tvTitle.setTextColor(ContextCompat.getColor(context, R.color.black))

}

 

아이템 클릭 리스너로 사용할 인터페이스를 만들어 적용하는 것까진 그렇다 쳐도 다른 로직은 생소할 수 있다. 빈약한 설명이나마 첨부한다.

먼저 선언한 변수부터 확인한다.

 

private var selectedPosition = 0

 

selectedPosition은 이름 그대로 유저가 선택한 아이템의 position을 담을 프로퍼티다. position은 0부터 시작하고 화면이 켜지면 첫 번째 아이템을 선택상태로 만들고 싶었기 때문에 여기선 0으로 초기화했지만 그게 아니라면 -1로 초기화해도 상관없다.

다음은 뷰홀더 안에 있는 bind() 코드다.

 

fun bind(item: SimpleModel) {
    binding.model = item

    if (selectedPosition == absoluteAdapterPosition) {
        list[absoluteAdapterPosition].isSelected = true
        binding.setChecked()
    } else {
        list[absoluteAdapterPosition].isSelected = false
        binding.setUnchecked()
    }

    if (onItemClickListener != null) {
        binding.tvTitle.setOnClickListener {
            onItemClickListener?.onItemClick(item, absoluteAdapterPosition)
            if (selectedPosition != absoluteAdapterPosition) {
                binding.setChecked()
                notifyItemChanged(selectedPosition)
                selectedPosition = absoluteAdapterPosition
            }
        }
    }
}

 

위에서 아이템 XML에 내가 만든 데이터 클래스 경로를 넣고 XML 안에서 사용할 이름을 정했었다. 이렇게만 해두면 아무리 데이터를 넣어도 표시되지 않는다. 말 그대로 데이터가 들어오면 표시할 준비만 해둔 상태고 실질적인 데이터는 아직 없는 상태다.

이 데이터를 넣어주기 위해 bind()의 파라미터로 데이터 클래스(SimpleModel)의 객체를 넘긴다. 그리고 bind()의 파라미터로 넘어온 객체(item)는 데이터 바인딩이 적용된 아이템 XML의 model이란 객체에 적용된다.

 

그 다음 유저가 선택한 위치와 absoluteAdapterPosition이라는 걸 비교해서 같으면 선택 처리, 다르면 선택해제 처리를 하는데 absoluteAdapterPosition은 뭘까?

안드로이드 디벨로퍼에선 아래와 같이 설명한다.

 

https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.ViewHolder#getAbsoluteAdapterPosition() 

 

RecyclerView.ViewHolder  |  Android Developers

androidx.car.app.managers

developer.android.com

리사이클러뷰의 어댑터와 관련해 뷰홀더가 나타내는 아이템의 어댑터 위치를 반환한다. 이 RecyclerView.ViewHolder를 바인딩한 어댑터가 다른 어댑터 안에 있는 경우 이 위치는 다를 수 있으며 ConcatAdapter의 다른 어댑터로 인한 오프셋을 포함한다...(중략)...리사이클러뷰가 보는 위치를 쿼리하는 경우 getAbsoluteAdaperPosition()을 써야 한다. (스크롤 상태를 저장하는 데 사용하려는 경우 등) 리사이클러뷰 어댑터 컨텐츠에 액세스하기 위해 위치를 쿼리하는 경우 getBindingAdapterPosition()을 써야 한다.

 

예전에는 getAdapterPosition()이란 함수로 아이템의 position을 구하곤 했지만 지금은 deprecated됐고 난 보통 getAbsoluteAdaperPosition을 사용한다.

처음 selectedPosition은 0으로 초기화했으니 아직까진 0이고 absoluteAdapterPosition도 처음에는 0을 리턴한다. 그렇기 때문에 저 if문은 리사이클러뷰가 렌더링될 때 무조건 첫 번째 아이템을 가리킨다. 이 if문 안에 첫 번째 아이템에 적용할 처리를 추가한다.

 

list[absoluteAdapterPosition].isSelected = true
binding.setChecked()

 

첫 번째 아이템만 색깔을 바꿔야 하니까 리스트의 absoluteAdapterPosition 위치에 있는 isSelected 값을 true로 설정하고 선택 상태로 만든다. 다른 position의 아이템들은 isSelected를 false로 두고 선택되지 않은 상태로 있게 한다.

그래서 이 상태로 앱을 처음 빌드하면 아래와 같이 나올 것이다.

 

 

이제 클릭하면 테스트0은 선택해제되고 다른 글자가 선택상태가 되도록 한다. 그럴려면 당연히 클릭 리스너를 만들어야 한다. bind() 안의 마지막 if문을 본다.

 

if (onItemClickListener != null) {
    binding.tvTitle.setOnClickListener {
        onItemClickListener?.onItemClick(item, absoluteAdapterPosition)
        if (selectedPosition != absoluteAdapterPosition) {
            binding.setChecked()
            notifyItemChanged(selectedPosition)
            selectedPosition = absoluteAdapterPosition
        }
    }
}

 

만들었던 인터페이스 함수에 맞게 매개변수를 넣어준다. position에는 absoluteAdapterPosition을 넣어서 클릭한 아이템의 현재 position이 들어가도록 한다. 그러면 아직까지도 selectedPosition은 0인데 absoluteAdapterPosition은 0이 아닌 다른 숫자를 갖게 된다.

이 때 setChecked()로 선택한 아이템을 선택상태로 돌리고 나면 기존에 선택했던 아이템은 선택해제 상태로 바꿔야 한다. 그래서 notifyItemChanged(selectedPosition)으로 0번 아이템의 상태를 선택해제 상태로 바꾼다. 그리고 0을 갖고 있던 selectedPosition에 선택한 아이템의 position을 넣어서 다른 아이템을 선택하면 그 아이템이 선택상태로 변하고 기존 아이템은 선택해제 상태가 될 수 있게 한다.

 

onBindViewHolder()에선 첫 번째 아이템이 선택상태가 되게 하기 위해 position이 0인지 확인해서 0번 아이템(테스트0)을 연보라색으로 설정한다. 액티비티 코드는 고친 게 없으니 패스한다.

아무튼 최종 코드는 아래와 같다.

 

data class SimpleModel(
    val title: String,
    var isSelected: Boolean
)
<?xml version="1.0" encoding="utf-8"?>
<!-- item_single_item.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="model"
            type="com.example.kotlinprac.recyclerview.selectsingleitem.SimpleModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        android:layout_marginTop="10dp">

        <TextView
            android:id="@+id/tvTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{model.title}"
            android:textSize="40sp"
            android:textColor="@{model.selected ? @color/purple_200 : @color/black, default=@color/black}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="테스트"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ItemSingleItemBinding

class SelectSingleItemAdapter(
    private val context: Context,
    private val list: ArrayList<SimpleModel>,
): RecyclerView.Adapter<SelectSingleItemAdapter.SelectSingleItemViewHolder>() {

    private lateinit var binding: ItemSingleItemBinding
    private var onItemClickListener: OnItemClickListener? = null

    private var selectedPosition = 0

    interface OnItemClickListener {
        fun onItemClick(item: SimpleModel, position: Int)
    }

    fun setOnItemClickListener(listener: OnItemClickListener) {
        this.onItemClickListener = listener
    }

    inner class SelectSingleItemViewHolder(
        private val binding: ItemSingleItemBinding
    ): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: SimpleModel) {
            binding.model = item

            if (selectedPosition == absoluteAdapterPosition) {
                list[absoluteAdapterPosition].isSelected = true
                binding.setChecked()
            } else {
                list[absoluteAdapterPosition].isSelected = false
                binding.setUnchecked()
            }

            if (onItemClickListener != null) {
                binding.tvTitle.setOnClickListener {
                    onItemClickListener?.onItemClick(item, absoluteAdapterPosition)
                    if (selectedPosition != absoluteAdapterPosition) {
                        binding.setChecked()
                        notifyItemChanged(selectedPosition)
                        selectedPosition = absoluteAdapterPosition
                    }
                }
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectSingleItemViewHolder {
        binding = ItemSingleItemBinding.inflate(LayoutInflater.from(context), parent, false)
        return SelectSingleItemViewHolder(binding)
    }

    override fun onBindViewHolder(holder: SelectSingleItemViewHolder, position: Int) {
        val item = list[position]
        holder.bind(item)
    }

    override fun getItemCount(): Int = list.size

    private fun ItemSingleItemBinding.setChecked() = tvTitle.setTextColor(ContextCompat.getColor(context, R.color.purple_200))

    private fun ItemSingleItemBinding.setUnchecked() = tvTitle.setTextColor(ContextCompat.getColor(context, R.color.black))

}
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ActivitySelectSingleItemBinding

class SelectSingleItemActivity : AppCompatActivity() {

    private val TAG = this.javaClass.simpleName
    private lateinit var binding: ActivitySelectSingleItemBinding
    private var list = arrayListOf<SimpleModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_select_single_item)
        binding.run {
            lifecycleOwner = this@SelectSingleItemActivity

            for (i in 0..10) {
                list.add(SimpleModel(title = "테스트$i", isSelected = false))
            }

            recyclerview.apply {
                layoutManager = LinearLayoutManager(this@SelectSingleItemActivity)
                setHasFixedSize(true)
                adapter = SelectSingleItemAdapter(this@SelectSingleItemActivity, list).apply {
                    setOnItemClickListener(object : SelectSingleItemAdapter.OnItemClickListener {
                        override fun onItemClick(item: SimpleModel, position: Int) {
                            Toast.makeText(this@SelectSingleItemActivity, "선택한 문자열 : ${item.title}", Toast.LENGTH_SHORT).show()
                        }
                    })
                }
            }
        }
    }
}

 

이제 빌드해서 확인해보자.

 

 

처음 앱이 켜지면 테스트0이 연보라색으로 표시된다. 이 상태에서 다른 아이템을 클릭하면 이렇게 된다.

 

 

이제 각자 앱에 맞게 세로 스크롤을 가로로 바꾸거나 DTO를 바꿔서 다른 데이터바인딩 처리를 하는 등 자유롭게 커스텀하면 된다.

반응형
Comments