본문 바로가기

안드로이드

[안드로이드] 예제:MVVM+AAC를 이용한 RecyclerView 6 - ListAdapter

ListAdapter

기존의 RecyclerView.Adapter는 RecyclerView에 데이터 목록을 표현하기 위한 기본 클래스이다. 추가로 백그라운드 쓰레드에서 기존 리스트와의 비교하는 연산까지 포함한다. ListAdapter는 AsyncListDiffer를 좀더 편리하게 사용할 수 있는 wrapper라고 보면 된다. 더 적은 코드로 효율적인 구현이 가능해진다.

ListAdapter는 내부적으로 읽기만 가능한 불변 객체만 다룬다. 예를들어 ListAdpater로 전달받은 리스트 중 특정 항목을 수정, 추가, 삭제하는 것이 불가능하다는 말이다. 대신 추가, 수정, 삭제와 같은 변경이 반영된 새로운 리스트를 다시 ListAdpater로 제출해야 한다. 이 기능을 하는 함수는 submitList(val list: List<T>)가 유일하다.


구현 과정

앞선 1~5의 문서의 코드를 수정하는 방식으로 ListAdpater를 학습할 예정이다. 추가로 RecyclerView에 대한 BindingAdapter를 구현하여 사용하는 방법도 소개한다. 

 

1. RecyclerViewBindingAdapter 구현

object RecyclerViewBindingAdapter {
    @BindingAdapter("listData")
    @JvmStatic
    fun BindData(recyclerView: RecyclerView, todos: List<Todo>?){
        val adapter = recyclerView.adapter as TodoAdapter
        adapter.submitList(todos) //For ListAdapter
    }
}

코드는 간단하다. 어답터를 생성하고 ListAdapter의 submitList를 통해 리스트를 전달한다. 여기서 자주 버그가 발생하는 부분이 두번째 매개변수인 todos: List<Todo>?이다. 초기에 리스트가 없을 경우 리스트가 null일텐데 만약 ? 키워드를 넣지 않는다면 에러를 뿜으며 종료된다. 이 점 조심하자.

 

2. activity_main.xml 수정

...
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="16dp"
            app:layout_constraintBottom_toTopOf="@+id/main_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/textview"
            app:listData="@{viewModel.getAll()}" //새로 추가된 라인 : 데이터 바인딩
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            tools:listitem="@layout/item_todo" />
...

app:listData 속성이 새로 추가되었다. 데이터바인딩을 하기 위함으로 viewModel의 getAll을 통해 불변 리스트를 전달해주는 모습이다.

 

3. TodoAdapter.kt 수정

class TodoAdapter(val todoItemClick: (Todo) -> Unit, val todoItemLongClick: (Todo) -> Unit)
:ListAdapter<Todo, TodoAdapter.ViewHolder>(TodoDiffUtil) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val binding = DataBindingUtil.inflate<ItemTodoBinding>(layoutInflater,viewType,parent,false)
        return ViewHolder(binding)
    }

    override fun getItemViewType(position: Int): Int {
        return R.layout.item_todo
    }

    override fun getItemCount(): Int {
        return super.getItemCount()
    }


    override fun onBindViewHolder(holder: TodoAdapter.ViewHolder, position: Int) {
        val todo = getItem(position)
        holder.bind(todo)
    }

    inner class ViewHolder(private val binding: ItemTodoBinding):RecyclerView.ViewHolder(binding.root){
        fun bind(todo: Todo) {
            binding.todo = todo
            binding.executePendingBindings() //데이터가 수정되면 즉각 바인딩

            binding.root.setOnClickListener {
                todoItemClick(todo)
            }
            binding.root.setOnLongClickListener {
                todoItemLongClick(todo)
                true
            }
        }
    }

    companion object TodoDiffUtil: DiffUtil.ItemCallback<Todo>(){
        override fun areItemsTheSame(oldItem: Todo, newItem: Todo): Boolean {
            //각 아이템들의 고유한 값을 비교해야 한다.
            return oldItem==newItem
        }

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


}

코드를 보면 어떤지 정도만 감이 올 것이다. onCreateView, onBindViewHolder, ViewHolder는 거의 유사하다. 하나 새로 추가된 것은 DiffUtill.ItemCallback으로 내부적으로 아이템을 비교하기위해 구현되어야 한다. 이때 리턴 값으로 이전 값과 새로운 값을 비교하여야 하는데, 각 아이템의 ID와 같은 고유한 값이 존재한다면 그 둘을 비교하는게 베스트이고, 아니면 위처럼 객체 자체를 비교해도 된다. submitList를 통해 수정된 리스트를 전달받으면 oldItem과 newItem의 비교의 결과가 false로 나오는 아이템만 수정되고 notify 작업이 발생하여 UI가 갱신된다.

 

4. MainActivity.kt 수정

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    val viewModel: MainViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //setContentView(R.layout.activity_main)

        binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
        binding.lifecycleOwner = this
        binding.viewModel = viewModel
        setRecyclerView()

        binding.mainButton.setOnClickListener {
            val intent = Intent(this, AddActivity::class.java)
            startActivity(intent)
        }
    }

    private fun deleteDialog(todo: Todo) {
        val builder = AlertDialog.Builder(this)
        builder.setMessage("Delete selected contact?")
            .setNegativeButton("취소") { _, _ -> }
            .setPositiveButton("편집") { _, _ ->
                val intent = Intent(this, AddActivity::class.java)
                intent.putExtra(AddActivity.EXTRA_TODO_TITLE, todo.title)
                intent.putExtra(AddActivity.EXTRA_TODO_DESC, todo.description)
                intent.putExtra(AddActivity.EXTRA_TODO_ID, todo.id)
                startActivity(intent)
            }.setNeutralButton("삭제"){_, _ ->
                lifecycleScope.launch(Dispatchers.IO){viewModel.delete(todo)}
            }
        builder.show()
    }

    private fun setRecyclerView(){
        // Set contactItemClick & contactItemLongClick lambda
        val adapter = TodoAdapter ({ todo -> deleteDialog(todo)},{ todo -> deleteDialog(todo)})

        binding.recyclerView.adapter=adapter
        binding.recyclerView.setHasFixedSize(true)

    }
}

변경된 사항은 우선 lifecycleOwner를 this로 세팅해주었다. 또한 ViewModel.getAll().observe(this, Observer { todos -> adapter.setTodos(todos!!) }) 코드가 사라졌다. 


다음 문서

사실 다음에는 갤러리에서 이미지를 선택하여 리사이클러뷰 아이템의 imageView에 바인딩하려 했으나 이보단 다른 예제를 하는게 좋을 것 같다는 생각이 든다. 특정 API를 이용하여 값을 얻어온 다음 리사이클러뷰에 표현하는 예제를 해보려고 한다. 지금까지 사용한 ViewModel, LiveData, Databinding(+bindindAdpater), ListAdapter 등을 이용할 예정이고 추가로 Retrofit2을 경험하고 싶기 때문에 진행 상황이 바뀌게 되었다.

구현 예시-영화진흥위원회 오픈 API

이미지 출처: https://apkpure.com/es/%EB%AC%B4%EB%B9%84%ED%81%B4%EB%A6%AD/com.khs.www.movietimechecker