본문 바로가기
Android

RecyclerView Swipe menu

by 들풀민들레 2022. 8. 4.

 

수업시간에 나온 질문에 도움을 주기 위해 정리한 글입니다.

 

 

 

라이브러리는 없는가?

 

RecyclerView swipe 에 의한 메뉴를 제공하는 경우, 직접 구현할 수도 있지만 코드가 못지않게 여러가지 들어가야 합니다. 많은 앱에서 필요로 하는 기능이어서 라이브러리로 만들어 놓은 것이 있지 않을까 하는 생각에 검색을 해보았더니 많은 라이브러리가 보이네요.

 

 

https://github.com/chthai64/SwipeRevealLayout

 

 

 

 

 

https://github.com/vcalvello/SwipeToAction

 

 

https://github.com/yanzhenjie/SwipeRecyclerView

 

그런데 더 좋은 라이브러리가 있을지 모르겠지만 모두 맘에 들지 않았네요. RecyclerView 를 커스터마이징해서 다른 뷰 사용을 강제한 다는 점도 맘에 들지 않았고, 화면의 UI 도 원하는 스타일이 아니었습니다.

결국 RecyclerView 를 이용하면서 원하는 스타일로 Swipe Menu 를 제공하겠다면 직접 구현하는 것이 좋겠다는 판단을 했습니다.

 

 

RecyclerView 준비

 

Swipe Menu 를 추가하기 위해 기본으로 RecyclerView 를 준비합니다.

 

 

항목 layout xml

 

항목은 간단하게 문자열 하나 출력을 위해 TextView 를 준비합니다.

 

<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    android:background="?android:attr/selectableItemBackground">

    <TextView
        android:id="@+id/swipeItemTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp"/>

</androidx.cardview.widget.CardView>

 

 

Activity layout xml

 

Activity 를 위한 레이아웃에 RecyclerView 를 등록합니다.

 

<androidx.recyclerview.widget.RecyclerView 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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".swifemenu.SwipeMenuActivity"
    android:id="@+id/swipeRecyclerView"/>

 

ViewHolder, Adapter

 

View 에 대한 핸들링은 ViewBinding 기법을 이용합니다.

 

class SwipeViewHolder(val binding: ItemSwipeRecyclerBinding): RecyclerView.ViewHolder(binding.root)

class SwipeAdapter(val datas: MutableList<String>): RecyclerView.Adapter<SwipeViewHolder>() {
    override fun getItemCount(): Int {
        return datas.size
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SwipeViewHolder {
        return SwipeViewHolder(ItemSwipeRecyclerBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    }

    override fun onBindViewHolder(holder: SwipeViewHolder, position: Int) {
        holder.binding.swipeItemTextView.text = datas[position]

    }
}

 

Activity

 

class SwipeMenuActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivitySwipeMenuBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val datas = mutableListOf<String>()
        for(i in 0..9){
            datas.add("Item $i")
        }

        binding.swipeRecyclerView.layoutManager = LinearLayoutManager(this)
        binding.swipeRecyclerView.adapter = SwipeAdapter(datas)


    
    }
}

 

 

 

ItemTouchHelper

 

위처럼 기본으로 RecyclerView 를 준비하면 Adapter 에 의해 각 항목이 잘 출력되며 세로 방향 스크롤에 의해 항목들이 보이게 됩니다.

그런데 Swipe Menu 를 만들겠다는 것은 각 항목을 유저가 터치해서 왼쪽 혹은 오른쪽으로 밀었을 때 항목이 밀리면서 안보이던 메뉴가 나타나게 하고자 하는 것입니다.

이를 구현하기 위해서는 먼저 항목이 터치 되었을 때 왼쪽으로 밀리게 하거나 오른쪽으로 밀리게 해야 하는데 이를 위해서는 ItemTouchHelper 를 준비해 주어야 합니다.

 

ItemTouchHelper 는 이름 그대로 리사이클러뷰의 항목 터치와 관련된 것을 제어하기 위해서 제공되는 ItemDecoration 을 상속받은 클래스입니다. 터치해서 특정 방향으로 밀거나(Swipe) 혹은 끌어서 어디에 떨구거나(Drag & Drop) 을 지원하기 위한 클래스입니다.

 

ItemTouchHelper 를 사용하기 위해서는 ItemTouchHelper.Callback 을 상속받은 클래스 객체를 준비해 주어야 합니다. Callback 이름 그대로 이 객체를 ItemTouchHelper 에 알려주면(ItemTouchHelper 의 생성자 매개변수로) ItemTouchHelper 에 의해 유저가 항목을 밀었을 때 Callback 의 함수가 자동으로 호출되어 개발자가 원하는 작업이 이루어 질수 있게 해줍니다. 또한 유저의 어떤 행위(왼쪽으로만 밀리게 하거나 오른쪽으로만 밀리게 하거나 등)를 허용할지도 Callback 에 정의합니다.

먼저 Callback 클래스를 아래처럼 준비합니다.

 

class SwipeDeleteCallback(
    val context: Context,
    val recyclerView: RecyclerView,
    val datas: MutableList<String>
) : ItemTouchHelper.Callback() {

    var adapter: SwipeAdapter

    init {
        adapter = (recyclerView.adapter as SwipeAdapter)
    }

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

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

    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        return false
    }
}

 

ItemTouchHelper.Callback 클래스에 3개의 함수가 오버라이드 되어야 하는데 getMovementFlags() 는 유저에게 어떤 행동을 허용할지를 결정하기 위해서 호출되는 함수입니다. Drag & Drop 을 허용할지? 아니면 Swipe 를 허용할지를 이 함수에서 결정합니다. 위의 예처럼 makeMovementFloags() 함수의 매개변수를 이용하여 유저 행동을 명시하는데 첫번째 매개변수는 Drag & Drop 에 대한 선언이며 두번째 매개변수는 Swipe 에 대한 선언입니다. 위의 예에서는 Drag & Drop 에 대한 설정을 0 으로 지정하여 허용하지 않게 한 것이며 Swipe 방향을 LEFT or RIGHT 로 지정하여 왼쪽, 오른쪽의 Swipe 가 가능하게 설정한 것입니다.

 

onSwiped() 함수는 유저가 항목을 Swipe 할때 호출이 되며 onMove() Drag & Drop 할때 호출이 됩니다.

 

이렇게 만들어진 Callback ItemTouchHelper 에게 등록해야 하며 ItemTouchHelper RecyclerView 에 적용해 주어야 합니다.

 

 

val itemTouchHelper = ItemTouchHelper(SwipeDeleteCallback(this, binding.swipeRecyclerView, datas))
itemTouchHelper.attachToRecyclerView(binding.swipeRecyclerView)

 

 

원하는 이벤트 추가

 

왼쪽 혹은 오른쪽, 아니면 둘다, swipe 가 발생했을 때 이벤트 처리 하는 것이 기본인 것 같습니다. 예를 들어 왼쪽으로 swipe 될때 삭제 이벤트를 처리해서 항목을 제거시키고, 오는쪽으로 swipe 될때 보관 이벤트를 처리해서 항목을 제거 시키는 등..

 

이번 테스트에서는 왼쪽, 오른쪽 모두 항목은 제거되게 처리했습니다. 단지 이벤트를 구분하기 위해서 왼쪽은 삭제, 오른쪽은 저장 이벤트로 칭하겠습니다.

 

먼저 삭제, 저장 이벤트를 위해 Adapter에 아래의 함수를 하나 선언합니다.

 

 

fun removeItem(index: Int){
    Log.d("kkang","removeItem : $index")
    datas.removeAt(index)
    //삭제 관련 이벤트 구현……
    notifyItemRemoved(index)
}
fun restoreItem(index: Int, data: String){
    datas.add(index, data)
    //저장 관련 이벤트 구현……….
    notifyItemInserted(index)
}

 

왼쪽 swipe 인지, 오른쪽 swipe 인지를 판단해야 하며 이는 Callback onSwiped() 함수에서 판단할 수 있습니다. 이 함수는 Swipe 이벤트가 완료된 후에 호출되는 함수입니다.

 

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
    //swipe 가 발생한 항목의 index 및 항목의 데이터 획득
    val deletedItem = datas.get(viewHolder.adapterPosition)
    val deletedIndex = viewHolder.adapterPosition

    if (direction == ItemTouchHelper.LEFT) {
        //adapter 에서 항목 제거
        adapter.removeItem(deletedIndex)
        val snackbar = Snackbar.make(
            recyclerView,
            "$deletedItem removed from list!", Snackbar.LENGTH_LONG
        )
        snackbar.setAction("UNDO", object : View.OnClickListener {
            override fun onClick(view: View?) {

                // undo is selected, restore the deleted item
                adapter.restoreItem(deletedIndex, deletedItem)
            }
        })
        snackbar.show()
    }else if (direction == ItemTouchHelper.RIGHT) {

        adapter.removeItem(deletedIndex)
        val snackbar = Snackbar.make(
            recyclerView,
            "$deletedItem save from list!..", Snackbar.LENGTH_LONG
        )
        snackbar.setAction("UNDO", object : View.OnClickListener {
            override fun onClick(view: View?) {

                // undo is selected, restore the deleted item
                adapter.restoreItem(deletedIndex, deletedItem)
            }
        })
        snackbar.show()
    }

}

 

 

 

항목 백그라운드 그림 그리기

 

위의 예로 swipe 에 의해 항목 이벤트 처리가 가능합니다. 그런데 swipe 에 의해 항목이 왼쪽 , 오른쪽으로 밀릴 때 백그라운드에 특정 그림을 그려주면 더 멋질 것 같습니다.

 

항목이 swipe 될때 백그라운드에서 나오는 그림을 그리기 위해서는 Callback onChildDraw() 함수를 이용합니다.

 

백그라운드에 출력되는 그림은 다양하게 그릴 수 있는데 이 예에서는 https://medium.com/@acerezoluna/part-3-recyclerview-from-zero-to-hero-397b7996280 drawing 코드를 약간만 커스터마이징 해서 사용했습니다.

 

먼저 드리잉을 하기 위해 호출되는 함수를 가지는 클래스를 아래처럼 선언합니다

 

class SwipeBackgroundHelper {

    companion object {

        private const val THRESHOLD = 2.5

        private const val OFFSET_PX = 20

        @JvmStatic
        fun paintDrawCommandToStart(canvas: Canvas, viewItem: View, @DrawableRes iconResId: Int, backgroundColor: Int, dX: Float) {
            val drawCommand = createDrawCommand(viewItem, dX, iconResId, backgroundColor)
            paintDrawCommand(drawCommand, canvas, dX, viewItem)
        }

        private fun createDrawCommand(viewItem: View, dX: Float, iconResId: Int, backgroundColor: Int): DrawCommand {
            val context = viewItem.context
            var icon = ContextCompat.getDrawable(context, iconResId)
            icon = DrawableCompat.wrap(icon!!).mutate()
            icon.colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.white),
                PorterDuff.Mode.SRC_IN)
            val backgroundColor = getBackgroundColor(backgroundColor, R.color.grey, dX, viewItem)
            return DrawCommand(icon, backgroundColor)
        }

        private fun getBackgroundColor(firstColor: Int, secondColor: Int, dX: Float, viewItem: View): Int {
            return when (willActionBeTriggered(dX, viewItem.width)) {
                true -> ContextCompat.getColor(viewItem.context, firstColor)
                false -> ContextCompat.getColor(viewItem.context, secondColor)
            }
        }

        private fun paintDrawCommand(drawCommand: DrawCommand, canvas: Canvas, dX: Float, viewItem: View) {
            drawBackground(canvas, viewItem, dX, drawCommand.backgroundColor)
            drawIcon(canvas, viewItem, dX, drawCommand.icon)
        }

        private fun drawIcon(canvas: Canvas, viewItem: View, dX: Float, icon: Drawable) {
            val topMargin = calculateTopMargin(icon, viewItem)
            icon.bounds = getStartContainerRectangle(viewItem, icon.intrinsicWidth, topMargin, OFFSET_PX, dX)
            icon.draw(canvas)
        }

        private fun getStartContainerRectangle(viewItem: View, iconWidth: Int, topMargin: Int, sideOffset: Int,
                                               dx: Float): Rect {

            Log.d("kkang", "dx..: $dx")
            if(dx<0){
                val leftBound = viewItem.right + dx.toInt() + sideOffset
                val rightBound = viewItem.right + dx.toInt() + iconWidth + sideOffset
                val topBound = viewItem.top + topMargin
                val bottomBound = viewItem.bottom - topMargin

                return Rect(leftBound, topBound, rightBound, bottomBound)
            }else{
                val leftBound =  dx.toInt() - iconWidth - sideOffset
                val rightBound =  dx.toInt() - sideOffset
                val topBound = viewItem.top + topMargin
                val bottomBound = viewItem.bottom - topMargin
                Log.d("kkang","leftBound:$leftBound, rightBound:$rightBound")
                return Rect(leftBound, topBound, rightBound, bottomBound)
            }

        }

        private fun calculateTopMargin(icon: Drawable, viewItem: View): Int {
            return (viewItem.height - icon.intrinsicHeight) / 2
        }

        private fun drawBackground(canvas: Canvas, viewItem: View, dX: Float, color: Int) {
            val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
            backgroundPaint.color = color
            val backgroundRectangle = getBackGroundRectangle(viewItem, dX)
            Log.d("kkang",".....${backgroundRectangle.left},${backgroundRectangle.right}")
            canvas.drawRect(backgroundRectangle, backgroundPaint)
        }

        //백그라운드를 그릴 사각형 정보 계산
        private fun getBackGroundRectangle(viewItem: View, dX: Float): RectF {
            if(dX<0) {
                return RectF(
                    viewItem.right.toFloat() + dX, viewItem.top.toFloat(), viewItem.right.toFloat(),
                    viewItem.bottom.toFloat()
                )
            }else {
                return RectF(
                    0f , viewItem.top.toFloat(), dX,
                    viewItem.bottom.toFloat()
                )
            }
        }

        private fun willActionBeTriggered(dX: Float, viewWidth: Int): Boolean {
            return Math.abs(dX) >= viewWidth / THRESHOLD
        }
    }

    private class DrawCommand internal constructor(internal val icon: Drawable, internal val backgroundColor: Int)

}

 

getStartContainerRectangle() 함수에서 백그라운드에 그려질 아이콘의 사각형 정보를 계산했으며 getBackGroundRectangle() 함수에서 백그라운드를 원하는 색으로 그리는 사각형 정보를 계산했습니다. dX 값이 음수이면 왼쪽 swipe 이고 양수이면 오른쪽 swipe 입니다.

 

위의 클래스의 paintDrawCommandToStart() 함수를 호출하면 그림이 그려지는데 이는 Callback onChildDraw() 에서 호출하면 됩니다.

 

override fun onChildDraw(canvas: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
                         dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
    if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
        val viewItem = viewHolder.itemView
        if(dX < 0) {
            SwipeBackgroundHelper.paintDrawCommandToStart(
                canvas,
                viewItem,
                R.drawable.ic_delete_black_24dp,
                R.color.red,
                dX
            )
        }else if(dX > 0) {
            SwipeBackgroundHelper.paintDrawCommandToStart(
                canvas,
                viewItem,
                R.drawable.ic_baseline_local_grocery_store_24,
                R.color.green,
                dX
            )
        }
    }
    super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}

 

 

 

 

 

 

 

 

 

'Android' 카테고리의 다른 글

API Level 33 외장 메모리 퍼미션 조정  (0) 2023.03.08
WebView 연동, Multi file upload with retrofit  (0) 2022.08.12
Progress Indicator  (0) 2022.08.01
Datastore  (0) 2022.07.29
Android JWT 인증 with Retrofit, Spring boot  (0) 2022.07.27