RecyclerView를 Swipe, Drag, Touch하기

이번 포스팅에서는 RecyclerView에 Swipe, Drag, Touch 동작을 연결하는 법에 대해 알아보도록 하겠습니다.

이번에 만들어볼 앱에서는 ViewHolder를 스와이프하면 삭제가 되고, 잡고 끌면 위아래로 ViewHolder를 이동할 수 있으며, ViewHolder를 터치하면 그 내용이 변하는 동작을 수행하도록 하겠습니다.

여기서는 제가 RecyclerView에 View Binding 적용하기 강의에서 만들었던 Recyclerview 앱을 수정하는 방식으로 구현해보겠습니다.

# 리팩토링

우선 View Binding을 사용하도록 설정을 추가합니다.

1
2
3
android {
    buildFeatures.viewBinding true
}

메인액티비티에서 만들어주던 dataSet은 초기화시 데이터를 바로 입력하도록 하고 Recyclerview 어댑터 안쪽으로 이동시켜서 받아오는 파라미터를 삭제합니다.

1
2
3
4
5
6
7
class RecyclerViewAdapter: RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder>() {
    private val dataSet: ArrayList<List<String>> = arrayListOf<List<String>>().apply {
        for (i in 0..99) {
            add(listOf("$i th main", "$i th sub"))
        }
    }
}

메인액티비티의 Recyclerview 프로퍼티를 정하는 부분은 with로 묶어줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class MainActivity : AppCompatActivity() {
    private val rvAdapter = RecyclerViewAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        with(binding) {
            with(recyclerView) {
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
                adapter = rvAdapter
                addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
            }
        }
    }
}

# 데이터 조작함수 추가

데이터를 조작하는 함수를 Recyclerview 안에 다음과 같이 추가합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class RecyclerViewAdapter: RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder>() {

    // ViewHolder 포지션을 받아 그 위치의 데이터를 삭제하고 notifyItemRemoved로 어댑터에 갱신명령을 전달
    fun removeData(position: Int) {
        dataSet.removeAt(position)
        notifyItemRemoved(position)
    }

    // 두 개의 ViewHolder 포지션을 받아 Collections.swap으로 첫번째 위치와 두번째 위치의 데이터를 교환
    fun swapData(fromPos: Int, toPos: Int) {
        Collections.swap(dataSet, fromPos, toPos)
        notifyItemMoved(fromPos, toPos)
    }

    // 선택한 ViewHolder 포지션의 데이터 내용을 바꾸도록 함
    fun setData(position: Int) {
        dataSet[position] = listOf("main viewholder touched!", "sub viewholder touched!")
        notifyItemChanged(position)
    }
}

# 스와이프와 드래그 기능 붙이기

안드로이드에서는 Recyclerview에 입력되는 스와이프와 드래그 동작을 감지하는 ItemTouchHelper.SimpleCallback 클래스가 이미 준비되어 있으니 이걸 사용하겠습니다. 이 콜백함수는 다음과 같이 정의하면 됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
val itemTouchCallback = object : ItemTouchHelper.SimpleCallback (
    ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.LEFT
        ){
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        val fromPos: Int = viewHolder.adapterPosition
        val toPos: Int = target.adapterPosition
        rvAdapter.swapData(fromPos, toPos)
        return true
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        rvAdapter.removeData(viewHolder.layoutPosition)
    }
}

ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)

우선 ItemTouchHelper.SimpleCallback의 인스턴스를 만들어 ItemTouchHelperattachToRecyclerView로 Recyclerview와 연결시켜주면 스와이프와 드래그 동작을 인식할 수 있게 됩니다.

초기화할때 인식할 드래그 방향을 dragDirs, 스와이프 방향을 swipeDirs로 정해주는데 여기서는 드래그 방향은 위, 아래. 그리고 스와이프 방향은 왼쪽만 인식하도록 하였습니다.

onSwiped에서는 스와이프가 일어날 때 데이터를 삭제하는 동작을 정의합니다. 터치한 ViewHolder 위치를 어댑터에 전달하여 removeData를 실행합니다.

onMove에서는 드래그가 일어날 때 두 ViewHolder의 데이터를 교환하는 동작을 정의합니다. 드래그가 시작되었을 때 ViewHolder의 위치와 이동이 완료된 후의 ViewHolder의 위치를 각각 fromPos, toPos로 정의하고 swapData에 넘겨주어 데이터를 교환하게 합니다. 이때 스크롤 동작과 헷갈리지 않게 하기 위해서 ViewHolder를 드래그하기 위해서는 롱터치를 해야 합니다.

이때 onSwiped에서는 layoutPosition을 사용했고, onMove에서는 adapterPosition을 사용했습니다. 스와이프를 할 때 ViewHolder의 위치는 고정되어 있지만 드래그를 할때는 ViewHolder의 위치가 계속 변하게 됩니다. adapterPosition은 전체 Recyclerview 안에서 위치를 가져오는 속성이고 layoutPosition은 고정된 ViewHolder의 위치를 가져오는 속성입니다.

# ViewHolder에 드래그 가능 여부를 알리는 이미지 표시

현재는 ViewHolder가 데이터만 표시되고 있어서 이 ViewHolder를 드래그 할수 있는지 바로 알수 있는 방법이 없습니다. 그래서 ViewHolder 오른쪽에 햄버거 마크를 넣어서 드래그 할 수 있는 ViewHolder라는 표시를 해 주도록 하겠습니다.

아이콘은 Vector Asset 메뉴에서 추가하고 LinearLayoutFrameLayout을 섞어서 ViewHolder 모양을 수정했습니다.

 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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:paddingStart="20dp"
    android:paddingEnd="20dp"
    android:gravity="center_vertical">

    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="9"
        android:orientation="vertical">

        <TextView
            android:id="@+id/tv_main"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Main TextView"
            android:textSize="24sp"
            android:padding="5dp" />

        <TextView
            android:id="@+id/tv_sub"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Sub TextView"
            android:textSize="18sp"
            android:padding="5dp" />
    </LinearLayout>

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="1">

        <ImageView
            android:layout_width="30dp"
            android:layout_height="30dp"
            android:layout_gravity="center"
            android:src="@drawable/ic_baseline_reorder_24" />
    </FrameLayout>

</LinearLayout>

# 스와이프시 백그라운드 설정하기

다음으로 스와이프를 할때 미끄러져 나간 ViewHolder 아래에 Canvas 클래스를 이용해 작성한 빨간색 비트맵 이미지와 휴지통 아이콘을 표시되도록 하겠습니다. 이것을 위해서는 ItemTouchHelper.SimpleCallbackonChildDraw를 사용합니다.

 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
29
30
31
32
33
34
35
36
37
38
39
40
override fun onChildDraw(
    c: Canvas,
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    dX: Float,
    dY: Float,
    actionState: Int,
    isCurrentlyActive: Boolean
) {
    val icon: Bitmap
    // actionState가 SWIPE 동작일 때 배경을 빨간색으로 칠하는 작업을 수행하도록 함
    if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
        val itemView = viewHolder.itemView
        val height = (itemView.bottom - itemView.top).toFloat()
        val width = height / 4
        val paint = Paint()
        if (dX < 0) {  // 왼쪽으로 스와이프하는지 확인
            // ViewHolder의 백그라운드에 깔아줄 사각형의 크기와 색상을 지정
            paint.color = Color.parseColor("#ff0000")
            val background = RectF(itemView.right.toFloat() + dX, itemView.top.toFloat(), itemView.right.toFloat(), itemView.bottom.toFloat())
            c.drawRect(background, paint)

            // 휴지통 아이콘과 표시될 위치를 지정하고 비트맵을 그려줌
            // 비트맵 이미지는 Image Asset 기능으로 추가하고 drawable 폴더에 위치하도록 함
            icon = BitmapFactory.decodeResource(resources, R.drawable.ic_menu_delete)
            val iconDst = RectF(itemView.right.toFloat() - 3  - width, itemView.top.toFloat() + width, itemView.right.toFloat() - width, itemView.bottom.toFloat() - width)
            c.drawBitmap(icon, null, iconDst, null)
        }
    }

    super.onChildDraw(
        c,
        recyclerView,
        viewHolder,
        dX,
        dY,
        actionState,
        isCurrentlyActive
    )
}

# 터치 조작 정의하기

Recyclerview 어댑터 안에 정의한 ViewHolder 클래스에 클릭 리스너를 붙여주면 ViewHolder의 터치를 인식할 수 있습니다. 그럴려면 ViewHolder가 사용하는 list_item.xml에서 레이아웃에 id를 부여해줘야 합니다.

1
2
3
<LinearLayout
    android:id="@+id/vhLayout" >
</LinearLayout>

그리고 클릭 리스너를 붙여서 Snackbar로 터치한 위치를 보여주고 setData 함수가 실행되도록 합니다. 이때 setData는 Recyclerview 안의 함수이므로 ViewHolder 클래스에서 접근을 할 수가 없습니다. 그래서 ViewHolder를 inner classs로 변경하여줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class RecyclerViewAdapter: RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder>() {

    inner class ViewHolder(private val binding: ListItemBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(data:List<String>) {
            binding.tvMain.text = data[0]
            binding.tvSub.text = data[1]

            binding.vhLayout.setOnClickListener {
                Snackbar.make(it, "Item $layoutPosition touched!", Snackbar.LENGTH_SHORT).show()
                setData(layoutPosition)
            }
        }
    }
}

# ViewHolder 터치시 애니메이션 추가

현재는 ViewHolder를 터치했을 때 내용이 바뀌도록 하긴 했지만 정확히 어떤 ViewHolder를 터치한건지에 대한 표시가 나타나지 않습니다. 하지만 xml에 selectableItemBackground 속성을 정의하면 터치한 ViewHolder를 주목시키는 효과를 추가할 수 있습니다.

1
2
3
<LinearLayout
    android:background="?attr/selectableItemBackground">
</LinearLayout>

이렇게 해서 Recyclerview에 스와이프, 드래그, 터치 동작에 대한 응답을 설정하는 법에 대해 알아보았습니다.

Built with Hugo
Theme Stack designed by Jimmy