일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 안드로이드 유닛 테스트 예시
- 객체
- 안드로이드 유닛 테스트
- 스택 큐 차이
- rxjava cold observable
- ANR이란
- 안드로이드 라이선스 종류
- 멤버변수
- 큐 자바 코드
- 2022 플러터 안드로이드 스튜디오
- rxjava disposable
- 클래스
- 안드로이드 레트로핏 사용법
- rxjava hot observable
- 서비스 쓰레드 차이
- jvm이란
- 안드로이드 라이선스
- 자바 다형성
- 안드로이드 레트로핏 crud
- 서비스 vs 쓰레드
- android retrofit login
- 플러터 설치 2022
- Rxjava Observable
- 안드로이드 os 구조
- jvm 작동 원리
- 2022 플러터 설치
- android ar 개발
- 안드로이드 유닛테스트란
- ar vr 차이
- 스택 자바 코드
- Today
- Total
나만을 위한 블로그
[Android] ListAdapter란? ListAdapter 사용법 본문
이 포스팅에선 리사이클러뷰와 같이 사용하는 ListAdapter에 대해 정리하고 간단한 예제를 확인한다.
리사이클러뷰를 만들려면 어댑터는 반드시 필요하다. 그런데 그냥 리사이클러뷰에 데이터를 표시할 뿐 아니라 아이템 위치를 바꾸거나 낱개를 또는 여러 개를 추가, 삭제하는 경우도 많이 있다.
이 때마다 notify가 붙은 메서드를 호출해서 데이터 변경 처리를 구현하지만 과연 이 방법이 모든 경우에 권장되는 방법인가? 극단적인 예로 데이터 수 만개가 표시되는 리사이클러뷰가 있는데 데이터 변경사항이 생겨서 notifyDataSetChanged()로 모든 아이템을 업데이트해 강제로 다시 그린다고 가정한다. 이게 맞는 방법일까?
noitfyItemInserted() 또는 notifyItemRemoved()를 사용하면서 개발자가 바뀌는 범위를 직접 계산해야 하는 경우도 있기 때문에 휴먼에러의 여지도 충분히 있다.
즉 기존 어댑터를 사용하면 notify류의 메서드를 호출해서 쓸데없이 리사이클러뷰(UI)를 새로고침하기 때문에 많은 데이터를 표시하거나 아이템 하나에 여러 뷰가 표시되는 리사이클러뷰일수록 성능 저하가 우려되고, 개발자의 실수를 유발할 수도 있다.
그럼 뭐가 효율적인 방법일까? 바뀐 부분만 갱신해주는 것이다. 애써 집 다 지어놨더니 기와 조금 틀어졌다고 집을 허물고 다시 지을 수는 없는 노릇이다. 이것을 가능하게 해주는 것이 ListAdpater다.
안드로이드 디벨로퍼에선 ListAdapter를 어떻게 설명하는지 확인한다.
https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter
ListAdapter 안드로이드 개발자
androidx.appsearch.builtintypes.properties
developer.android.com
공개 추상 class ListAdapter<T, VH는 RecyclerView를 확장합니다.ViewHolder> extends RecyclerView.Adapter
백그라운드 쓰레드에서 리스트 간의 차이 계산을 포함해 리사이클러뷰에 리스트 데이터를 표시하기 위한 RecyclerView.Adapter를 기반으로 하는 클래스다. ListAdapter는 아이템 접근, 계산을 위한 어댑터 공통 기본 동작을 구현하는 AsyncListDiffer의 래퍼다
LiveData를 쓰는 게 어댑터에 데이터를 제공하는 쉬운 방법이지만 필수는 아니다. 새 리스트를 사용할 수 있을 때 submitList()를 쓰면 된다...(중략)...어댑터 동작에 대한 더 많은 제어를 원하거나 특정 base class를 제공하려는 고급 사용자는 다른 이벤트에서 어댑터 위치로의 커스텀 매핑을 제공하는 AsyncListDiffer를 참조하라
ListAdapter가 뭔지 잘 모른다면 그렇게 거창한 게 아니라 그냥 RecyclerView.Adapter를 상속하고 좀 더 많은 기능을 추가하는 클래스일 뿐이다.
AsyncListDiffer의 래퍼라고 하는데 이것은 아래와 같다.
https://developer.android.com/reference/androidx/recyclerview/widget/AsyncListDiffer
AsyncListDiffer 안드로이드 개발자
androidx.appsearch.builtintypes.properties
developer.android.com
백그라운드 쓰레드에서 DiffUtil을 통해 두 리스트 간의 차이를 계산하는 헬퍼다. RecyclerView.Adapter에 연결할 수 있으며 요약된 리스트 간의 변경사항을 어댑터에 알린다. 간소화를 위해 AsyncListDiffer를 직접 쓰는 대신 ListAdapter 래퍼 클래스를 쓸 수 있는 경우가 많다...(중략)...AsyncListDiffer는 리스트의 LiveData에서 값을 소비하고 어댑터에 대한 데이터를 간단히 표시할 수 있다
새 리스트를 받으면 백그라운드 쓰레드에서 DiffUtil을 통해 리스트의 차이를 계산한다. getCurrentList를 써서 현재 리스트에 접근하고 해당 데이터 객체를 표시한다. Diff 결과는 현재 리스트가 업데이트되기 직전에 ListUpdateCallback으로 전송된다. 리스트 업데이트를 어댑터에 직접 보내는 경우 어댑터는 getCurrentList를 통해 리스트 아이템, 총 크기에 안전하게 접근할 수 있다
기존에 표시되던 리스트와 새로 받은 리스트의 차이를 계산하는 건 AsyncListDiffer의 DiffUtil이 수행한다. DiffUtil 문서는 아래를 확인한다.
https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil
DiffUtil | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
DiffUtil은 두 리스트의 차이를 계산하고 첫 리스트를 2번째 리스트로 변환하는 업데이트 작업 리스트를 출력하는 유틸 클래스다. 리사이클러뷰 어댑터의 업데이트 계산에 사용할 수 있다. 백그라운드 쓰레드에서 DiffUtil 사용을 간소화할 수 있는 ListAdapter와 AsyncListDiffer를 참조하라. DiffUtil은 Eugene W. Myers의 차이 알고리즘을 써서 한 리스트를 다른 리스트로 바꾸기 위한 최소 업데이트 횟수를 계산한다. 이 알고리즘은 이동된 항목은 처리하지 않아서 DiffUtil은 이동된 항목을 감지하기 위해 결과에 대해 2번째 pass를 실행한다. Diffutil, ListAdapter, AsyncListDiffer는 사용 중에 리스트가 바뀌지 않아야 한다는 것에 주의하라. 이는 일반적으로 리스트 자체와 그 요소를 직접 수정하면 안 된다는 의미다. 대신 컨텐츠가 바뀔 때마다 새 리스트를 제공해야 한다
DiffUtil로 전달된 리스트는 바뀌지 않은 요소를 공유하는 게 일반적이므로 반드시 모든 데이터를 다시 로드해야 DiffUtil을 쓸 필요는 없다. 리스트가 큰 경우 이 작업에 시간이 꽤 걸릴 수 있어서 백그라운드 쓰레드에서 실행해서 DiffResult를 얻은 다음 메인 쓰레드의 리사이클러뷰에 적용하는 게 좋다...(중략)...리스트가 이미 같은 제약 조건으로 정렬된 경우 이동 감지를 비활성화해서 성능 개선이 가능하다...(중략)
ListAdapter가 지원하는 함수들은 아래와 같다.
- @NonNull List<T> getCurrentList() = 현재 리스트를 조회함. 이 리스트와 다른 모든 차이는 이미 계산되서 ListUpdateCallback을 통해 전송됐다. null list거나 submit된 리스트가 없으면 빈 리스트를 리턴한다. 리턴된 리스트는 바꿀 수 없으며 컨텐츠 변경은 submitList()로 이뤄져야 한다
- int getItemCount() = 어댑터가 보유한 데이터 set의 총 아이템 개수를 리턴함
- onCurrentListChanged() : 현재 리스트가 업데이트될 때 호출됨. submitList()로 넘어온 리스트가 null list거나 없으면 현재 리스트는 빈 리스트로 표시된다
- void submitList(@Nullable List<T> list) = 차이점을 확인할 새 리스트를 submit하고 표시한다. 이미 리스트가 표시되고 있으면 백그라운드 쓰레드에서 차이점을 계산해 메인 쓰레드에서 Adapter.notifyItem 이벤트를 디스패치한다
- void submitList(@Nullable List<T> list, @Nullable Runnable commitCallback) = 위의 함수와 같은 역할을 함. commitCallback을 써서 리스트가 언제 커밋되는지 알 수 있지만 실행되지 않을 수도 있다. A 리스트 바로 뒤에 B 리스트가 제출되서 바로 커밋되는 경우, A 리스트와 연결된 콜백은 실행되지 않는다
ListAdapter라고 뭐 엄청나게 어렵진 않다. 어댑터를 다시 만들거나 어댑터 안의 함수로 새 리스트를 제공하고 notify류 메서드를 사용하는 대신 submitList()로 표시하고 싶은 리스트를 넘기는 것에 익숙해지면, 로직을 빡세게 짜야 하지 않는 이상 큰 어려움 없이 구현할 수 있게 될 것이다.
아래는 간단한 ListAdapter 예시다. data class와 필요한 클래스들부터 적당히 만든다. 아이템의 xml은 item_my_data.xml로 만들었다.
data class MyItem(
val id: Int,
val title: String,
)
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/tv_title"
android:padding="16dp"
android:textSize="20dp"
tools:text="테스트">
</TextView>
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.regacyviewpractice.data.MyItem
import com.example.regacyviewpractice.databinding.ItemMyDataBinding
class MyListAdapter : ListAdapter<MyItem, MyListAdapter.MyViewHolder>(DIFF_CALLBACK) {
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<MyItem>() {
override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem == newItem
}
}
inner class MyViewHolder(private val binding: ItemMyDataBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: MyItem) {
binding.tvTitle.text = item.title
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyListAdapter.MyViewHolder {
val binding = ItemMyDataBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return MyViewHolder(binding)
}
override fun onBindViewHolder(holder: MyListAdapter.MyViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
<?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:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMyRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_my_data"/>
</androidx.constraintlayout.widget.ConstraintLayout>
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.regacyviewpractice.R
import com.example.regacyviewpractice.data.MyItem
import com.example.regacyviewpractice.databinding.ActivityMainBinding
import com.example.regacyviewpractice.presentation.listadapterex.MyListAdapter
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var myListAdapter: MyListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
myListAdapter = MyListAdapter()
binding.rvMyRecyclerView.apply {
adapter = myListAdapter
}
val list = mutableListOf<MyItem>()
repeat(100) {
list.add(MyItem(it + 1, "테스트 ${it + 1}"))
}
myListAdapter.submitList(list)
}
}
이걸 실행하면 큰 문제없이 1~100까지의 텍스트들이 표시되는 리사이클러뷰 화면이 표시될 것이다.
액티비티 코드는 submitList() 외에는 특별한 게 없으니 ListAdapter만 다시 확인한다.
class MyListAdapter : ListAdapter<MyItem, MyListAdapter.MyViewHolder>(DIFF_CALLBACK) {
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<MyItem>() {
override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem == newItem
}
}
inner class MyViewHolder(private val binding: ItemMyDataBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: MyItem) {
binding.tvTitle.text = item.title
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyListAdapter.MyViewHolder {
val binding = ItemMyDataBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return MyViewHolder(binding)
}
override fun onBindViewHolder(holder: MyListAdapter.MyViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
onCreateViewHolder, onBindViewHolder는 기존 어댑터에서도 쓰는 함수기 때문에 생략한다.
핵심은 companion object로 선언된 DiffUtil.ItemCallback<T>()다. ItemCallback 추상 클래스를 구현하면 areItemsTheSame(), areContentsSame()을 반드시 재정의해야 한다.
이 클래스의 구현을 확인하면 아래와 같다.
/**
* Callback for calculating the diff between two non-null items in a list.
* <p>
* {@link Callback} serves two roles - list indexing, and item diffing. ItemCallback handles
* just the second of these, which allows separation of code that indexes into an array or List
* from the presentation-layer and content specific diffing code.
*
* @param <T> Type of items to compare.
*/
public abstract static class ItemCallback<T> {
/**
* Called to check whether two objects represent the same item.
* <p>
* For example, if your items have unique ids, this method should check their id equality.
* <p>
* Note: {@code null} items in the list are assumed to be the same as another {@code null}
* item and are assumed to not be the same as a non-{@code null} item. This callback will
* not be invoked for either of those cases.
*
* @param oldItem The item in the old list.
* @param newItem The item in the new list.
* @return True if the two items represent the same object or false if they are different.
*
* @see Callback#areItemsTheSame(int, int)
*/
public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem);
/**
* Called to check whether two items have the same data.
* <p>
* This information is used to detect if the contents of an item have changed.
* <p>
* This method to check equality instead of {@link Object#equals(Object)} so that you can
* change its behavior depending on your UI.
* <p>
* For example, if you are using DiffUtil with a
* {@link RecyclerView.Adapter RecyclerView.Adapter}, you should
* return whether the items' visual representations are the same.
* <p>
* This method is called only if {@link #areItemsTheSame(T, T)} returns {@code true} for
* these items.
* <p>
* Note: Two {@code null} items are assumed to represent the same contents. This callback
* will not be invoked for this case.
*
* @param oldItem The item in the old list.
* @param newItem The item in the new list.
* @return True if the contents of the items are the same or false if they are different.
*
* @see Callback#areContentsTheSame(int, int)
*/
public abstract boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem);
/**
* When {@link #areItemsTheSame(T, T)} returns {@code true} for two items and
* {@link #areContentsTheSame(T, T)} returns false for them, this method is called to
* get a payload about the change.
* <p>
* For example, if you are using DiffUtil with {@link RecyclerView}, you can return the
* particular field that changed in the item and your
* {@link RecyclerView.ItemAnimator ItemAnimator} can use that
* information to run the correct animation.
* <p>
* Default implementation returns {@code null}.
*
* @see Callback#getChangePayload(int, int)
*/
@SuppressWarnings({"WeakerAccess", "unused"})
@Nullable
public Object getChangePayload(@NonNull T oldItem, @NonNull T newItem) {
return null;
}
}
getChangePayload()도 포함돼 있지만 이 함수엔 @Nullable이 붙었기 때문에 반드시 구현해야 하는 함수는 아니다.
3가지 함수가 뭐하는 함수인지 간단하게 정리하면 아래와 같다.
- areItemsTheSame() : 두 아이템이 같은 객체인지 비교
- areContentsTheSame() : 두 아이템의 내용(content)이 같은지 비교
- getChangePayload() : 아이템은 같지만 내용이 바뀐 경우(areItemsTheSame은 true, areContentsTheSame은 false를 리턴할 때) 호출됨. oldItem, newItem을 비교해서 달라진 부분만 리턴
areItemsTheSame()은 둘이 같은 객체인지 비교하기 때문에 보통 id 따위의 고유한 값을 통해 같은 객체인지 여부를 판단한다. areContentsTheSame()은 객체의 내용물을 비교하기 때문에 두 객체를 서로 비교한다.
getChangePayload()가 애매한데 이 함수는 간단하게 말하면 아이템 전체를 바인딩하는 대신 바뀐 부분만 바인딩하도록 해서 아이템 부분 업데이트를 최적화하기 위해 존재하는 함수다.
카톡 채팅창을 예시로 들면 채팅방 이름이 바뀌었다면 채팅방 이름만 바뀌면 되지, 가장 최근에 받은 메시지와 채팅방 프사 등은 바뀔 필요가 없다. 이 때 변경된 아이템(카톡 채팅방)의 특정 데이터(채팅방 이름)만 업데이트하고 싶다면 이 함수를 재정의해서 사용하면 된다.
그러나 이 함수를 반드시 정의하지 않더라도 ListAdapter의 기본 성능(애니메이션 등)은 보장된다. 그래서 리사이클러뷰 아이템 UI 구현 자체가 빡세고, 보다 세밀하게 아이템의 부분 UI를 갱신해야 할 필요가 있다면 그 때 getChangePayload() 재정의를 고려해볼 수 있다. 위 코드와 같이 areItemsTheSame, areContentsTheSame만 사용하는 것이 책상에서 필요없는 종이를 치우는 거라면, getChangePayload()도 구현하는 건 종이를 치우는데 포스트잇만 치우고 다른 종이들은 그대로 두는 거라고 볼 수 있다.
이제 간단한 데이터 추가, 수정, 삭제를 구현해서 ListAdapter가 어떻게 작동하는지 확인한다. 어댑터를 아래처럼 수정한다.
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.regacyviewpractice.data.MyItem
import com.example.regacyviewpractice.databinding.ItemMyDataBinding
class MyListAdapter : ListAdapter<MyItem, MyListAdapter.MyViewHolder>(DIFF_CALLBACK) {
var onItemClickListener: ((MyItem) -> Unit)? = null
var onDeleteClickListener: ((MyItem) -> Unit)? = null
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<MyItem>() {
override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem == newItem
}
}
inner class MyViewHolder(private val binding: ItemMyDataBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: MyItem) {
binding.tvTitle.text = item.title
binding.root.setOnClickListener {
onItemClickListener?.invoke(item)
}
binding.btnDelete.setOnClickListener {
onDeleteClickListener?.invoke(item)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyListAdapter.MyViewHolder {
val binding = ItemMyDataBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return MyViewHolder(binding)
}
override fun onBindViewHolder(holder: MyListAdapter.MyViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
item_my_data.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:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:padding="16dp"
android:textSize="20sp"
tools:text="테스트" />
<Button
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="삭제"
android:layout_marginEnd="16dp" />
</LinearLayout>
메인 액티비티는 아래처럼 수정한다.
import android.os.Bundle
import android.widget.EditText
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.regacyviewpractice.R
import com.example.regacyviewpractice.data.MyItem
import com.example.regacyviewpractice.databinding.ActivityMainBinding
import com.example.regacyviewpractice.presentation.listadapterex.MyListAdapter
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var myListAdapter: MyListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
myListAdapter = MyListAdapter()
myListAdapter.apply {
onItemClickListener = { item ->
showEditDialog(item)
}
onDeleteClickListener = { item ->
deleteItem(item)
}
}
binding.rvMyRecyclerView.apply {
adapter = myListAdapter
}
val list = mutableListOf<MyItem>()
repeat(100) {
list.add(MyItem(it + 1, "테스트 ${it + 1}"))
}
myListAdapter.submitList(list)
binding.fabAddItem.setOnClickListener {
showAddDialog()
}
}
private fun showAddDialog() {
val dialog = AlertDialog.Builder(this)
dialog.setTitle("새 아이템 추가")
val input = EditText(this)
input.hint = "아이템 제목 입력"
dialog.setView(input)
dialog.setPositiveButton("추가") { _, _ ->
val title = input.text.toString()
if (title.isNotEmpty()) {
addNewItem(title)
}
}
dialog.setNegativeButton("취소", null)
dialog.show()
}
private fun addNewItem(title: String) {
val newItem = MyItem(System.currentTimeMillis().toInt(), title)
val newList = myListAdapter.currentList.toMutableList()
newList.add(1, newItem)
myListAdapter.submitList(newList)
}
private fun showEditDialog(item: MyItem) {
val dialog = AlertDialog.Builder(this).apply {
setTitle("아이템 수정")
}
val input = EditText(this).apply {
setText(item.title)
}
dialog.setView(input)
dialog.setPositiveButton("수정") { _, _ ->
val updatedItem = item.copy(
title = input.text.toString()
)
updateItem(updatedItem)
}
dialog.setNegativeButton("취소", null)
dialog.show()
}
private fun updateItem(item: MyItem) {
val newList = myListAdapter.currentList.toMutableList()
val index = newList.indexOfFirst { it.id == item.id }
if (index != -1) {
newList[index] = item
myListAdapter.submitList(newList)
}
}
private fun deleteItem(item: MyItem) {
val newList = myListAdapter.currentList.toMutableList().filter { it.id != item.id }
myListAdapter.submitList(newList.toList())
}
}
메인 액티비티의 xml에는 fab를 추가한다.
<?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:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMyRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_my_data" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabAddItem"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:src="@android:drawable/ic_input_add" />
</androidx.constraintlayout.widget.ConstraintLayout>
실행하면 아래처럼 작동할 것이다.
삭제 버튼을 클릭하면 그 position의 아이템이 제거되고 그 외의 공간을 클릭 시 수정 다이얼로그가 표시된다. fab를 클릭하면 마찬가지로 다이얼로그가 표시되는데 이 때 만든 아이템은 1번 position에 저장된다.
생성하는 부분부터 확인한다.
private fun showAddDialog() {
val dialog = AlertDialog.Builder(this)
dialog.setTitle("새 아이템 추가")
val input = EditText(this)
input.hint = "아이템 제목 입력"
dialog.setView(input)
dialog.setPositiveButton("추가") { _, _ ->
val title = input.text.toString()
if (title.isNotEmpty()) {
addNewItem(title)
}
}
dialog.setNegativeButton("취소", null)
dialog.show()
}
private fun addNewItem(title: String) {
val newItem = MyItem(System.currentTimeMillis().toInt(), title)
val newList = myListAdapter.currentList.toMutableList()
newList.add(1, newItem)
myListAdapter.submitList(newList)
}
showAddDialog()에서 EditText에 입력된 텍스트가 존재한다면 addNewItem()이 호출되는데, id와 title을 부여해서 새 아이템을 만든 다음 myListAdapter.currentList로 현재 어댑터에 표시되고 있는 리스트를 가져온다.
리스트를 가져올 때 toMutableList()를 써서 가변 리스트로 바꾸는데 이건 currentList가 리턴하는 리스트가 불변 리스트기 때문이다. 이 리스트에 새 아이템을 추가하려면 불변을 가변으로 변경하는 처리가 필요하기 때문에 toMutableList()를 썼다. currentList의 구현은 아래와 같으니 참고한다.
@NonNull
public List<T> getCurrentList() {
return mDiffer.getCurrentList();
}
그 다음엔 리스트의 1번 인덱스에 새 아이템을 추가하고 myListAdapter.submitList()를 통해 ListAdapter에 변경된 리스트를 전달한다. 이 과정에서 DiffUtil이 작동해 변경된 부분을 파악해서 자동으로 바뀐 부분만 업데이트한다. 이 결과로 1번 position에 새 아이템이 애니메이션 효과와 함께 생성된다.
아이템 수정 로직도 간단하다.
private fun updateItem(item: MyItem) {
val newList = myListAdapter.currentList.toMutableList()
val index = newList.indexOfFirst { it.id == item.id }
if (index != -1) {
newList[index] = item
myListAdapter.submitList(newList)
}
}
아이템 생성 때와 마찬가지로 currentList를 가변 리스트로 만든 뒤 indexOfFirst로 수정할 아이템의 위치를 찾고, 그 인덱스에 값이 존재한다면 새 값으로 교체한 다음(리스트의 변경 발생), submitList()로 ListAdapter에 변경된 리스트를 전달한다.
수정 시에는 전체 리스트를 새로 업데이트하지 않고 바뀐 부분(아이템)만 업데이트하기 때문에 마찬가지로 바뀐 부분만 업데이트한다.
그리고 수정 시 그 position의 삭제 버튼이 반짝이는 걸 볼 수 있다. 이것은 DiffUtil이 변경된 아이템을 감지하면서 어댑터의 onBindViewHolder()를 재호출하기 때문인데, 이 때 뷰홀더가 다시 바인딩되며 내부 뷰들이 재설정되는 과정을 거친다. 그래서 btnDelete의 속성이 재적용되어 깜박이는 현상이 발생한다.
이것이 거슬린다면 2가지 방법이 있다. 먼저 고전적인 해결법으로 리사이클러뷰의 itemAnimator에 null을 설정하는 방법이 있는데 이걸 사용하면 아이템 추가, 제거 시에도 애니메이션이 제거되니 참고한다.
binding.rvMyRecyclerView.apply {
adapter = myListAdapter
itemAnimator = null // 추가 시 리사이클러뷰의 모든 애니메이션 제거됨
}
다른 방법은 getChangePayload()를 재정의해서 title의 변경만 감지 + 업데이트하게 해서 아이템 전체가 다시 바인딩되는 것을 방지하고, 바뀐 title만 업데이트하는 것이다.
어댑터를 아래처럼 수정한다.
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.regacyviewpractice.data.MyItem
import com.example.regacyviewpractice.databinding.ItemMyDataBinding
class MyListAdapter : ListAdapter<MyItem, MyListAdapter.MyViewHolder>(DIFF_CALLBACK) {
var onItemClickListener: ((MyItem) -> Unit)? = null
var onDeleteClickListener: ((MyItem) -> Unit)? = null
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<MyItem>() {
override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: MyItem, newItem: MyItem): Boolean =
oldItem == newItem
override fun getChangePayload(oldItem: MyItem, newItem: MyItem): Any? {
val diffBundle = Bundle()
if (oldItem.title != newItem.title) {
diffBundle.putString("KEY_TITLE", newItem.title)
}
return if (diffBundle.isEmpty) null else diffBundle
}
}
}
inner class MyViewHolder(val binding: ItemMyDataBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: MyItem) {
binding.tvTitle.text = item.title
binding.root.setOnClickListener {
onItemClickListener?.invoke(item)
}
binding.btnDelete.setOnClickListener {
onDeleteClickListener?.invoke(item)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyListAdapter.MyViewHolder {
val binding = ItemMyDataBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return MyViewHolder(binding)
}
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun onBindViewHolder(holder: MyViewHolder, position: Int, payloads: MutableList<Any>) {
if (payloads.isEmpty()) {
holder.bind(getItem(position))
} else {
val diffBundle = payloads[0] as Bundle
diffBundle.getString("KEY_TITLE")?.let {
holder.binding.tvTitle.text = it
}
}
}
}
이 때 onBindViewHolder()를 2개 정의해야 한다. 왜냐면 DiffUtil이 변경사항을 감지한 경우, 감지하지 못한(또는 않은) 경우로 나눠지기 때문인데
- DiffUtil이 변경사항을 감지하지 못하면 -> 매개변수가 2개인 onBindViewHolder()가 호출되서 전체 뷰를 바인딩한다
- 감지하면 -> getChangePayload()를 통해 바뀐 부분만 추출해서 다시 바인딩한다. 이 때 매개변수가 3개인 onBindViewHolder()가 호출된다
크게 신경쓰지 않아도 된다면 매개변수가 2개인 onBindViewHolder()로도 충분하지만 요구사항이 깐깐하다면 getChangePayload()를 재정의하고 2개의 onBindViewHolder()를 모두 구현해야 한다.
아이템 삭제는 아이템 추가, 수정을 확인했다면 큰 어려움 없이 이해할 수 있을 것이다.
private fun deleteItem(item: MyItem) {
val newList = myListAdapter.currentList.toMutableList().filter { it.id != item.id }
myListAdapter.submitList(newList.toList())
}
직접 리스트에서 아이템을 없애는 게 아니라 filter를 통해 새 리스트를 만들어서 업데이트한다.
즉 3번째 아이템의 버튼을 누르면 삭제할 아이템(3번째 아이템)을 제외한 새 리스트가 만들어지고, submitList()를 통해 ListAdapter에 전달된다. 이 때 마찬가지로 변경사항을 감지한 ListAdpater는 애니메이션과 함께 3번째 아이템을 리사이클러뷰에서 제거하고 UI를 업데이트한다.
'Android' 카테고리의 다른 글
[Android] Unable to delete directory build 에러 해결 (0) | 2025.02.18 |
---|---|
[Android] 텍스트에 밑줄 추가하는 법 (0) | 2025.02.11 |
[Android] Hilt + Retrofit + Flow + Coil + 멀티 모듈 구조 프로젝트 - 2 - (0) | 2025.01.02 |
[Android] 안드로이드 스튜디오와 Cursor AI 연동하는 법 (0) | 2025.01.01 |
[Android] Hilt + Retrofit + Flow + Coil + 멀티 모듈 구조 프로젝트 - 1 - (0) | 2024.12.23 |