본문 바로가기

안드로이드

[안드로이드] 예제: MVVM+AAC를 이용한 RecyclerView 9 - Swipe, Drag&Drop으로 아이템 삭제 및 순서 변경

개요

이번 문서에서는 ItemTouchHelper를 이용하여 프래그먼트에 추가된 아이템을 스와이프하여 삭제하거나 드래그&드랍으로 순서를 변경하는 예제를 다룬다. 

 

과정

  1. model 수정
  2. DAO 수정
  3. Repository 및 ViewModel 수정
  4. ItemTouchHelper 구현
  5. ListAdapter 수정
  6. Fragment 수정

1. model 수정

먼저 기존의 Todo.kt에서 순서를 나타내기 위해 itemOrder 필드를 추가한다. 또한, 후에 리스트를 정렬하기 위해 Comparable 인터페이스를 상속받아 구현한다.

 

Todo.kt

data class Todo (
    //autoGenerate는 null을 받으면 ID 값을 자동으로 할당해줌
    @PrimaryKey(autoGenerate = true)
    var id: Int?,

    @ColumnInfo(name ="title")
    var title: String,

    @ColumnInfo(name="overview")
    var overview: String,

    @ColumnInfo(name="poster_path")
    var posterPath: String,

    @ColumnInfo(name = "word_order")
    var itemOrder: Int
    ) : Comparable<Todo>
{
    constructor(): this(null,"","","",0)

    override fun compareTo(other: Todo): Int {
        return this.itemOrder.compareTo(other.itemOrder)
    }
}

 

2. DAO 수정

이제 데이터를 가져올 때 itemOrder 필드를 기준으로 오름차순으로 아이템을 가져온다. 그리고 드래그&드랍으로 아이템들의 순서가 바뀌었을 때 DB를 업데이트 하기 위해 updateAll 메서드도 추가했다. 추가로 itemOrder 값을 설정하기 위해 최대값을 가져온 뒤 나중에 insert 하는 구문에서 1을 추가로 더하는 방식으로 구현한다.

 

TodoDAO.kt

@Dao
interface TodoDao {
    @Query("SELECT * FROM Todo ORDER BY word_order ASC")
    fun getAll(): LiveData<List<Todo>>

    @Query("SELECT MAX(word_order) FROM Todo")
    fun getMaxOrder(): Int
    
    @Update(onConflict = OnConflictStrategy.REPLACE)
    fun updateAll(todo: List<Todo>)
	
    ...
}
참고로 필드 구성이 바뀌었으므로 데이터베이스 버젼을 올려야 한다.

 

3. Repository 및 ViewModel 수정

Repository와 ViewModel에서는 리스트를 업데이트하기 위한 메서드의 추가가 필요하다.

 

Repository.kt

    fun updateAll(todoList: List<Todo>){
        todoDao.updateAll(todoList)
    }

MainViewModel.kt

    fun updateAll(todoList: List<Todo>) {
        repository.updateAll(todoList)
    }

 

4. ItemTouchHelper 구현

먼저 어답터에 아이템의 변화를 알리기 위해 listener를 생성한다.

 

ItemTouchHelperListener.kt

interface ItemTouchHelperListener {
    fun onItemMove(fromPos:Int, targetPos:Int)
    fun onItemDismiss(pos:Int)
}

 

다음으로 Callback을 구현한다. 아이템이 움직일 경우 onMove, onSwiped 등의 함수가 호출된다. 그리고 리스너를 생성자의 매개변수로 받아 각 함수 밑에 onItemMove, onItemDismiss 메서드를 수행한다. 이 콜백의 메서드에서는 유저가 움직이는 뷰홀더 아이템의 포지션을 넘겨준다.

 

ItemMoveHelper.kt

class ItemMoveCallback(private val listener: ItemTouchHelperListener) : ItemTouchHelper.Callback() {

    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        val mFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN
        val sFlag = ItemTouchHelper.END or ItemTouchHelper.START
        return makeMovementFlags(mFlag,sFlag)
    }

    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        listener.onItemMove(viewHolder.adapterPosition, target.adapterPosition)
        return true
    }


    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        listener.onItemDismiss(viewHolder.adapterPosition)
    }

    override fun isLongPressDragEnabled(): Boolean {
        return true
    }

    override fun isItemViewSwipeEnabled(): Boolean {
        return true
    }
}

 

5. ListAdapter 수정

ListAdapter를 수정하기 전에 먼저 종속성에서 RecyclerView의 버젼을 확인하여 올리기 바란다. ListAdapter의 getCurrentList 메서드를 실행하기 위해서는 androidx.recyclerview의 최신 버전이 필요하다.

    implementation "androidx.recyclerview:recyclerview:1.1.0"

 

이제 adapter를 수정한다. 먼저 스와이프하여 삭제의 경우 간단한게 해당 포지션의 아이템을 가져와서 코루틴 스코프에서 삭제를 수행한다. 그리고 드래그&드랍의 경우 아이템이 이동될 경우 현재 adapter에 제출된 리스트를 getCurrentList를 통해 가져온다. 이때 적절한 조건문을 통해 onItemMove가 호출될 때 마다 리스트를 가져오는 것이 아니라 수행하는 동안 단 한번만 가져오게 구현해야한다. 필자의 경우 set이라는 Boolean 변수를 두어 해결하였다. 이제 실제로 아이템의 움직임을 반영하기 위해 각 아이템의 itemOrder 필드를 교환하고 리스트를 itemOrder 순으로 정렬한다. 그리고 notifyItemMoved를 통해 UI에 반영한다. 가장 중요한 점은 아이템의 순서 수정은 adapter에서 DB에 업데이트하지 않는다. Adapter에서는 순서가 바뀐 리스트만 저장해놓고, 실제 유저가 adapter가 붙어있는 recyclerview가 존재하는 fragment에서 나갈 때 어답터의 getList를 통해 수정이 반영된 리스트를 가져와 DB를 업데이트한다.

 

TodoAdapter.kt

class TodoAdapter(application: Application): ListAdapter<Todo, TodoAdapter.ViewHolder>(TodoDiffUtil), ItemTouchHelperListener {
    private var viewModel :MainViewModel = MainViewModel(application)
    private var list = ArrayList<Todo>()
    var set = false

    fun getList() : ArrayList<Todo> {
        return list
    }

    override fun onItemMove(fromPosition:Int, toPosition:Int) {
        if(!set) {
            list.addAll(currentList)
            set=true
        }
        //이거는 order만 바꾸어줌
        val temp = list[fromPosition].itemOrder
        list[fromPosition].itemOrder=list[toPosition].itemOrder
        list[toPosition].itemOrder = temp

        list.sort()
        notifyItemMoved(fromPosition,toPosition)
    }
    
    override fun onItemDismiss(position: Int) {
        viewModel.viewModelScope.launch ( Dispatchers.IO ){
            viewModel.delete(getItem(position))
        }
    }
    ...
}


 

6. Fragment 수정

위에서 설명한대로 변환된 리스트의 수정은  SecondFragment에서 유저가 다른 프래그먼트로 이동하거나 앱을 종료할 때 DB에 반영한다. 이를 위해 onPause 메서드를 오버라이드하여 여기에서 DB 업데이트 문을 수행한다. 추가로 ItemTouchHelper도 구현하여 recyclerView에 붙인다.

 

SecondFragment.kt

class SecondFragment : Fragment() {
    private lateinit var adapter:TodoAdapter
    ...
    
    private fun setRecyclerView(){
        adapter = TodoAdapter(activity!!.application)
        val touchHelper = ItemTouchHelper(ItemMoveCallback(adapter))
        touchHelper.attachToRecyclerView(binding.recyclerView)
        binding.recyclerView.adapter=adapter
        binding.recyclerView.setHasFixedSize(true)
    }

    override fun onPause() {
        super.onPause()
        viewModel.viewModelScope.launch(Dispatchers.IO){
            if(adapter.getList().size>0)
                viewModel.updateAll(adapter.getList())
        }
    }
}

 

그리고 아이템을 추가할 때 order 값을 반영하기 위해 FirstFragment의 아래 메서드를 수정했다. 최대 order값을 가져와 1을 더한 값을 준다.

 

FirstFragment.kt

private fun addDialog(movie: Movie) {
        val builder = AlertDialog.Builder(this.context!!)
        builder.setMessage("Delete selected contact?")
            .setNegativeButton("취소") { _, _ -> }
            .setPositiveButton("추가") { _, _ ->
                lifecycleScope.launch(Dispatchers.IO){
                    val todo = Todo(null, movie.title, movie.overview, movie.poster_path, viewModel.getMaxOrder()+1)
                    viewModel.insert(todo)
                }
                val direction: NavDirections = FirstFragmentDirections.actionFirstFragmentToSecondFragment()
                findNavController().navigate(direction)
            }.setNeutralButton("상세"){_,_ ->
                val direction: NavDirections = NavMainDirections.actionGlobalDetailFragment(movie.title,movie.overview,movie.adult,movie.poster_path,movie.release_date)
                findNavController().navigate(direction)
            }
        builder.show()
    }

마무리

 

이번 문서의 코딩은 최근 중 가장 많은 시간이 소요됐다. 스와이프하여 삭제의 경우 금방이었다. 하지만 드래그&드랍의 경우 아래와 같은 문제점을 마주했다

  1. Drag & Drop 아이템의 UI 반영
  2. 수정된 리스트의 DB 업데이트

  먼저 기존에는 onMove가 호출될 때마다 변경된 아이템의 순서를 DB에 업데이트했다. 그러다보니 아이템이 단 한칸만 움직일 수 있었다. 추측으로는 recyclerView에 LiveData를 이용하여 Databinding을 하고 있기 때문에 값이 변경될 경우 바로 UI에 알리게 되어 아이템을 '주르륵' 연속적으로 움직이지 못한 것 같다.(아이템은 바뀌었는데 잘못된 아이템을 잡고 있으니 그걸 놓게 되는 것 같다. 그리고 그 와중에 에러도 계속 발생한다.) 그리고 notifyMoveItemChanged는 아이템 값을 바꾸는게 아니라 UI 변화만 주는거라는 것을 모르고 있었다. 

  여튼 이러한 문제는 제출된 list의 변경을 요구하는 것으로 생각했다. 그래서 list.getItem(postiion).itemOrder의 값을 바꾸어 줬는데 이 또한 문제가 있었다. 제출된 list는 값을 직접적으로 수정할 수 없다. 따라서 초기 list와 같은 list를 복사하여 수정이 가능한 ArrayList 타입으로 만들었다. 그리고 실제 아이템의 변화는 모두 이 복사한 리스트에 반영하고 업데이트는 유저가 아이템을 수정하는 작업을 마쳤을 때 전체 리스트를 업데이트하는 방식으로 구현했다. 유저가 아이템을 수정하는 작업을 마친 경우는 해당 프래그먼트에서 나올때이다. 그 경우는 FirstFragment로 이동하거나 앱을 종료할 경우이다. 이는 즉 onPause()에서 DB 업데이트 작업을 하면 된다는 것을 의미했다. 

  위와 같은 과정을 통해 해당 문제를 해결할 수 있었다. 이러한 구현이 어쩌면 성능이 더 좋을 수도 있다는 생각이 들었다. 왜냐하면 아이템이 움직일때마다 DB에 접근하여 아이템을 개별로 업데이트하는 방법보다 단 한번의 DB 접근으로 변화된 사항을 업데이트하는게 결국 메모리에 접근하는 시간이 줄어 결국 성능이 향상되지 않았을까? 하는 생각이다.