본문 바로가기

Android/Utility

[Android] RecyclerView Drag and Drop

728x90

회사에서 개발 업무를 진행하다, 요구사항에 맞춰서 아이템의 순서를 Drag & Drop으로 변경할 수 있도록 개발해야 했다.

iOS의 경우에는 이런 이벤트를 쉽게 moveRow라는 것을 통해서 구현할 수 있다고 하는데, 안드로이드에서는 이벤트를 직접 구현하여 적용시켜야 했다.

구글링을 통해 방법을 찾아서 기능 구현을 진행했고, 그 방법에 대하여 작성해보고자 한다.


구현의 순서는 다음과 같다.

 

1. ItemMoveEvent Class를 만든다.
2. Activity에서 적용할 RecyclerView에 Event를 적용시킨다.
3. Adapter에서 아이템이 이동될 때의 처리를 해준다.

 

아주 간단하게 Drag & Drop에 대한 이벤트를 구현할 수 있어 보이지만, 관련된 정보를 찾는 것에 시간이 생각보다 오래 걸렸었다.

 

우선,

ItemMoveEvnet라고 칭했던 class를 만들어 보도록 하자.

필자는 이를 ItemMoveCallback이라고 명명하여 만들었다.

 

class ItemMoveCallback : ItemTouchHelper.Callback() {
    
    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        TODO("Not yet implemented")
    }

    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        TODO("Not yet implemented")
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        TODO("Not yet implemented")
    }
}

 

ItemTouchHelper.Callback을 상속받아서 필요한 이벤트들을 구현해주어야 한다.

기본적으로 반드시 override 해야 하는 3가지의 이벤트이며, 각 함수들의 사용처는 다음과 같다.

 

getMovementFlags : 아이템의 이동에 관련된 플래그 값을 설정한다.
onMove : 아이템을 이동했을 때의 이벤트 처리를 수행한다.
onSwiped : 아이템을 스와이프 할 때의 처리를 수행한다.

 

이름만 들어도 알 수 있겠지만, onSwiped는 사용하지 않도록 한다.

이것들 외에도 추가적으로 필요한 함수들을 override 해주도록 한다.

 

override fun isLongPressDragEnabled() = true
override fun isItemViewSwipeEnabled() = false

override fun onSelectedChanged(
    viewHolder: RecyclerView.ViewHolder?,
    actionState: Int
) {
    super.onSelectedChanged(viewHolder, actionState)
}

override fun clearView(
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder
) {
    super.clearView(recyclerView, viewHolder)
}

 

override 된 함수들의 사용처는 다음과 같다.

 

isLongPressDragEnabled : 롱 프레스로 드래그를 수행할 것인지 여부
isItemViewSwipeEnabled : 스와이프 이벤트를 수행할 것인지 여부
onSelectedChanged : 아이템이 선택됐을 때의 이벤트 처리
clearViwe : 아이템에 대한 이벤트가 끝났을 때의 처리

 

override 된 함수들 중, swipe에 관련된 함수들을 제외하고 모두 사용하여 이벤트를 구현할 수 있다.

세부적인 내용을 작성하기 앞서, 해당 callback Class는 필요한 부분에서 공통적으로 사용할 수 있도록 하기 위하여 inner interface를 만들어 놓고, 이를 상속받은 adapter에서 사용할 수 있도록 구현하려고 한다.

 

interface ItemTouchInterface {
    fun onRowMoved(fromPosition: Int, toPosition: Int)
    fun onRowSelected(itemViewHolder: MovieAdapter.ViewHolder?)
    fun onRowClear(itemViewHolder: MovieAdapter.ViewHolder?)
}

 

해당 인터페이스를 상속받아서 필요한 로직들을 adpater에서 구현하도록 한다.

onRowMoved는 아이템 이동에 관련된 이벤트를,

onRowSelected는 아이템이 선택됐을 때의 이벤트를,

onRowClear는 아이템에 대한 이동이 끝났을 때의 이벤트를 각각 구현할 예정이다.

 

class ItemMoveCallback(private val mAdapter: ItemTouchInterface) :
    ItemTouchHelper.Callback() {

    override fun isLongPressDragEnabled() = true
    override fun isItemViewSwipeEnabled() = false
    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, i: Int) {}

    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
    }

    override fun onMove(
        recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        if (viewHolder is MovieAdapter.ViewHolder) {
            val itemViewHolder: MovieAdapter.ViewHolder = viewHolder
            mAdapter.onRowMoved(itemViewHolder.adapterPosition, target.adapterPosition)
        }
        return true
    }

    override fun onSelectedChanged(
        viewHolder: RecyclerView.ViewHolder?,
        actionState: Int
    ) {
        if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) {
            if (viewHolder is MovieAdapter.ViewHolder) {
                val itemViewHolder: MovieAdapter.ViewHolder = viewHolder
                mAdapter.onRowSelected(itemViewHolder)
            }
        }
        super.onSelectedChanged(viewHolder, actionState)
    }

    override fun clearView(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ) {
        super.clearView(recyclerView, viewHolder)
        if (viewHolder is MovieAdapter.ViewHolder) {
            val itemViewHolder: MovieAdapter.ViewHolder = viewHolder
            mAdapter.onRowClear(itemViewHolder)
        }
    }

    interface ItemTouchInterface {
        fun onRowMoved(fromPosition: Int, toPosition: Int)
        fun onRowSelected(itemViewHolder: MovieAdapter.ViewHolder?)
        fun onRowClear(itemViewHolder: MovieAdapter.ViewHolder?)
    }
}

 

최종적으로 완성된 ItemMoveCallback class이다.

 

이번 예제에서는 swipe는 사용하지 않으므로 설정을 false으로 하고, onSwiped 함수를 비워두도록 한다.

나머지 구현된 3개의 함수에서는, 전달받은 adapter에 따라서 다른 로직 처리가 가능하도록 구현을 해두었다.

필자가 해당 예제를 구현한 CleanArchitecture Sample 프로젝트에서는 RecyclerView를 사용한 부분이 한 곳 밖에 존재하지 않지만, 여러 부분에서 구현하여 별도로 처리할 가능성이 높다고 생각하여 이와 같이 viweHolder의 종류에 따라 다르게 처리할 수 있도록 구현해 두었다. 이렇게 하기 위하여 interface를 내부에 선언하고, 인자로 adapter가 아닌 interface를 받도록 구현한 것이다.

물론, 종류가 더 많아지게 되면 if가 아닌 when 절로 변경하여 처리하는 것이 더욱 깔끔해질 것이다.

 

각 함수가 호출되면, 각 adpater에 override 된 함수를 호출하여 이벤트를 처리하도록 한다.

 

다음을, Adapter 부분을 확인해보자.

 

class MovieAdapter(private val itemClick: (Movie) -> Unit) :
    ListAdapter<Movie, MovieAdapter.ViewHolder>(
        diffUtil
    ), ItemMoveCallback.ItemTouchInterface { ... }

 

interface를 상속받아서 실 구현부를 구현해야 하므로, ItemMoveCallback에 선언한 interface를 상속받도록 한다.

 

override fun onRowSelected(itemViewHolder: ViewHolder?) {
    itemViewHolder!!.rowView!!.setBackgroundColor(
        ContextCompat.getColor(
            itemViewHolder.context!!,
            R.color.bright_grey
        )
    )
}

override fun onRowClear(itemViewHolder: ViewHolder?) {
    itemViewHolder!!.rowView!!.setBackgroundColor(
        ContextCompat.getColor(
            itemViewHolder.context!!,
            R.color.white
        )
    )
}

 

우선, 아이템을 클릭했을 때와 이동을 다 한 후 아이템의 터치가 끝났을 경우에 호출되는 부분이다.

이 부분에서는 단순하게 드래그할 때 색상을 바꾸어주고, 드래그가 끝나면 색을 원래대로 돌려주도록 구현해 두었다.

색상을 바꾸는 부분은 반드시 필요하다고 생각이 되는 부분인데, 롱 클릭을 통해서 드래그를 시도할 때 색상이 바뀌지 않으면 실제로 클릭이 되었음에도 불구하고 UI상으로의 변동사항이 없기 때문에 실 사용자는 드래그가 되고 있는지 직관적으로 이해할 수 없다.

그에 따라서 이 샘플에서는 별다른 로직은 추가하지 않고 색상만 변하도록 구현해 두었다.

 

override fun onRowMoved(fromPosition: Int, toPosition: Int) {
    if (fromPosition < toPosition) {
        for (i in fromPosition until toPosition) {
            Collections.swap(movieList, i, i + 1)
        }
    } else {
        for (i in fromPosition downTo toPosition + 1) {
            Collections.swap(movieList, i, i - 1)
        }
    }
    notifyItemMoved(fromPosition, toPosition)
}

 

다음은 아이템이 이동되는 부분이다.

이 부분에서는 자연스럽게 아이템이 이동되는 UI를 보여주기 위하여, 하나의 포지션을 이동할 때마다 두 개의 아이템 위치를 교환하고 notify를 통해 화면을 갱신시켜주도록 하였다.

 

이 부분을 구현할 때 주의해야 할 점은, 각 아이템의 position이다.

현재 필자가 사용한 예제에는 RecyclerView를 단일로 사용하고 있기 때문에 위와 같이 단순한 로직으로 처리가 가능하다.

하지만, RecyclerView 위아래로 다른 View Item이 존재할 경우, 해당 Position으로 이동을 하려고 시도하기 때문에 Index 관련된 에러가 발생하게 된다.

따라서, 포지션을 비교하는 조건문보다 먼저 toPosition의 값. 이동하고자 하는 위치의 값이 recyclerView의 범위를 벗어나는지 확인 후 벗어나지 않을 경우에만 이하의 로직을 수행하도록 해야 한다.

 

마지막으로,

Activity단에서 추가해야 할 부분을 확인하도록 한다.

 

private fun initAdapter() {
    ...
    
    val callback: ItemTouchHelper.Callback = ItemMoveCallback(movieAdapter)
    val touchHelper = ItemTouchHelper(callback)
    touchHelper.attachToRecyclerView(binding.rvMovies)

    ...
}

 

Adapter를 설정하는 부분에 다음과 같은 3줄을 추가한다.

맨 처음에 생성했던 Callback 객체를 생성하고, RecyclerView에 ItemTouchHelper를 설정해준다.

이처럼 RecyclerView에 설정만 해두면, 설정이 된 RecyclerView에서 Callback 객체에서 구현한 부분을 적용시킬 수 있다.


이처럼 간단하게 ItemTouchHelper.Callback을 상속받는 클래스를 구현하여 적용하기만 하면 구현할 수 있었다.

하지만, 검색 능력의 부족인지 해당 방식을 찾는 것에 생각보다 시간이 걸렸다는 것이 아쉽다.

 

이번 예제에서는 단순히 Drag & Drop을 통하여 아이템의 순서만 바꾸도록 해보았는데, 추가적으로 필요에 따라 로직을 추가하여 다양한 기능이나 편의성, 확장성을 제공해줄 수 있을 것 같다는 생각이 들었다.

또한, 추후에 생각이 난다면 swipe에 대한 설정도 추가하여 여러 가지 이벤트를 추가해 테스트를 해봐야겠다.

 

해당 게시글에 사용된 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/CleanArchitectureSample

 

GitHub - HeeGyeong/CleanArchitectureSample

Contribute to HeeGyeong/CleanArchitectureSample development by creating an account on GitHub.

github.com

 

728x90