RecyclerView 에서 item Swipe 하기 (feat. ItemTouchHelper, ItemTouchUIUtil)

한창희·2022년 6월 14일
2

< 구현하고 싶은 부분 >

  • 리사이클러뷰에서 아이템뷰 스와이프시 일정 길이만큼 스와이프가 된다
  • 스와이프 된 영역에 해당 아이템을 삭제할 수 있는 영역이 보이게 된다

위와 같은 느낌으로 생각하면 된다



< RecyclerView 구현 >

<layout 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">

    <data>

    </data>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <!-- 하단 삭제 view 영역 -->
        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/erase_item_view"                                             
            android:layout_width="match_parent"
            android:layout_height="match_parent">
            
            <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="0dp"
                android:layout_height="match_parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintWidth_percent="0.3"
                android:background="#CCD4FF">

            </androidx.constraintlayout.widget.ConstraintLayout>

        </androidx.constraintlayout.widget.ConstraintLayout>


        <!-- 상단 아이템 view 영역 -->
        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/swipe_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/white">

            <androidx.constraintlayout.widget.Guideline
                android:id="@+id/gl_v_4.44"
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:orientation="vertical"
                app:layout_constraintGuide_percent="0.0444" />

            <androidx.constraintlayout.widget.Guideline
                android:id="@+id/gl_v_95.56"
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:orientation="vertical"
                app:layout_constraintGuide_percent="0.9556" />

            <View
                android:id="@+id/empty_view_1"
                android:layout_width="0dp"
                android:layout_height="0dp"
                app:layout_constraintDimensionRatio="360:15"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />


            <ImageView
                android:id="@+id/iv_image"
                android:layout_width="0dp"
                android:layout_height="0dp"
                app:layout_constraintBottom_toTopOf="@id/empty_view_2"
                app:layout_constraintDimensionRatio="1:1"
                app:layout_constraintStart_toStartOf="@id/gl_v_4.44"
                app:layout_constraintTop_toBottomOf="@id/empty_view_1"
                app:layout_constraintWidth_percent="0.25"
                tools:src="@drawable/ic_launcher_background" />

            <TextView
                android:id="@+id/tv_textView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="15dp"
                android:textColor="@color/black"
                android:textSize="18sp"
                app:layout_constraintBottom_toTopOf="@id/empty_view_2"
                app:layout_constraintStart_toEndOf="@id/iv_image"
                app:layout_constraintTop_toBottomOf="@id/empty_view_1"
                tools:text="RecyclerView Item 1" />

            <View
                android:id="@+id/empty_view_2"
                android:layout_width="0dp"
                android:layout_height="0dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintDimensionRatio="360:15"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent" />

            <View
                android:id="@+id/view_line"
                android:layout_width="match_parent"
                android:layout_height="1.5dp"
                android:background="@color/black"
                app:layout_constraintBottom_toBottomOf="parent" />

        </androidx.constraintlayout.widget.ConstraintLayout>

    </FrameLayout>
</layout>
  • 아이템 뷰는 FrameLayout 에서 하단에 위치하며 가려져있는 삭제 뷰(스와이프 시 보임)와
  • 상단에 위치하고 있는 뷰를 배치


< ItemTouchHelper, ItemTouchUIUtil >


ItemTouchHelper

  • ItemTouchHelper는 RecyclerView에 삭제를 위한 스와이프 및 드래그 앤 드롭 지원을 지원하는 유틸리티 클래스입니다. ItemTouchHelper는 사용자가 액션을 수행할 때 이벤트를 수신하는 RecyclerView 및 이벤트에 반응하는 콜백 메소드가 선언되어 있는 Callback 클래스와 함께 사용합니다.

  • 즉, 구조적으로 RecyclerView와 ItemTouchHelper.Callback을 ItemTouchHelper가 연결시켜주는 것입니다.

(참고링크 : https://velog.io/@changhee09/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-ItemTouchHelper)

class SwipeHelper: ItemTouchHelper.Callback() {
	 override fun getMovementFlags(  // 이동 방향을 결정!!
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        // Drag와 Swipe 방향을 결정 Drag는 사용하지 않아 0, Swipe의 경우는 오른쪽에서 왼쪽으로만 가능하게 설정,   양방향 모두 하고 싶다면 'ItemTouchHelper.LEFT or ItemTouchHelper.Right'
        return makeMovementFlags(0, ItemTouchHelper.LEFT)
    }

    override fun onMove(  // Drag 시 호출
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {

        return false
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {  // Swipe 시 호출

    }
    
    ...
    
}
  • ItemTouchHelper.Callback()을 구현하는 클래스를 생성하고, 필수 구현 메서드를 작성

 // ItemTouchHelper.Callback 을 리사이클러뷰와 연결
        val swipeHelper = SwipeHelper()  // ItemTouchHelper.Callback 구현 클래스
        val itemTouchHelper = ItemTouchHelper(swipeHelper)
        itemTouchHelper.attachToRecyclerView(binding.rvData) // rvData = 리사이클러뷰 id
  • 리사이클러뷰가 배치된 activity or fragment 에서 연결해준다


ItemTouchUIUtil

Utility class for ItemTouchHelper which handles item transformations for different API versions.
This class has methods that map to ItemTouchHelper.Callback's drawing methods. Default implementations in ItemTouchHelper.Callback call these methods with RecyclerView.ViewHolder.itemView and ItemTouchUIUtil makes necessary changes on the View depending on the API level. You can access the instance of ItemTouchUIUtil via ItemTouchHelper.Callback.getDefaultUIUtil() and call its methods with the children of ViewHolder that you want to apply default effects.

  • ItemTouchUIUtil 에서도 기존 ItemTouchHelper.Callback에서 대응되는 메서드를 제공한다
  • 스와이프 시키려는 뷰를 인자로 넘겨주면 될 것 같다!
  • ItemTouchHelper.Callback 공식문서를 살펴보니 onChildDraw, onChildDrawOver는 메서드 이름만 다르지 나와있는 설명은 동일했다
class SwipeHelper: ItemTouchHelper.Callback() {

		...
        
         // Called by the ItemTouchHelper when the user interaction with an element is over and it also completed its animation.
    override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
        //super.clearView(recyclerView, viewHolder)
        getDefaultUIUtil().clearView(getView(viewHolder))
    }


    // Called when the ViewHolder swiped or dragged by the ItemTouchHelper is changed =
    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        //super.onSelectedChanged(viewHolder, actionState)
        viewHolder?.let {
            getDefaultUIUtil().onSelected(getView(it))
        }
    }

    override fun onChildDraw(
        c: Canvas,
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        dX: Float,
        dY: Float,
        actionState: Int,
        isCurrentlyActive: Boolean
    ) {
        //super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
        if(actionState == ItemTouchHelper.ACTION_STATE_SWIPE){
            val view = getView(viewHolder)

            getDefaultUIUtil().onDraw(
                c,
                recyclerView,
                view,
                dX,
                dY,
                actionState,
                isCurrentlyActive
            )
        }
    }


    private fun getView(viewHolder: RecyclerView.ViewHolder): View {
        return (viewHolder as RvAdapter.MyDataViewHolder).itemView.findViewById(R.id.swipe_view) // 아이템뷰에서 스와이프 영역에 해당하는 뷰 가져오기
    }
}	

  • 현재까지 구현한 결과
  • 스와이프 뷰가 고정되지 않고 계속 스와이프 되다 사라진다


< 스와이프 시 아이템 뷰 사라짐 방지 >

getSwipeEscapeVelocity

  • 일정속도 이상으로 스와이프 시 얼마나 스와이프 되었는지 상관없이 아이템 뷰 사라진다
  • 따라서 기본속도에 20배를 하는 경우에만 뷰가 사라지도록 설정 (아무리 빨리해도 20배를 넘을 수는 없다고 판단했다..)

getSwipeThreshold

  • Default value is .5f , which means, to swipe a View, user must move the View at least half of RecyclerView's width or height, depending on the swipe direction.
  • 반 이상 스와이프 시 자동으로 해당 아이템 뷰가 사라진다
  • 따라서 2f를 return 하도록 변경!
class SwipeHelper: ItemTouchHelper.Callback(){

	...

	override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
        //return super.getSwipeEscapeVelocity(defaultValue)
        return defaultValue * 20
    }

    override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
        //return super.getSwipeThreshold(viewHolder)
        return 2f
    }
}
  • 여기 까지 왔다면 아이템뷰 스와이프 시 밖으로 사라지지 않는 결과를 얻을 수 있다
  • 이제 스와이프 시 삭제 영역이 모두 보이게 되면 스와이프가 고정되도록 구현해보자


< 일정 부분 스와이프 후 고정되게 하기 >

  • getSwipeThreshold 가 호출 되는 시점(스와이프 하다 손을 떼는 경우)에 삭제 영역이 모두 보일 수 있게 스와이프가 되었는지를 판단
  • 위 판단 결과로 스와이프 된 상태로 고정할 지, 원상태로 돌아올지가 정해진다
  • (추가구현 할 사항) 특정 시점에 한 아이템만 스와이프 상태로 고정이 가능하게 하기, 현재는 모든 아이템이 스와이프 된 상태로 있을 수 있다
class SwipeHelper: ItemTouchHelper.Callback() {  // ItemTouchHelper.Callback 을 구현해야 한다

    private var currentPosition: Int? = null
    private var previousPosition: Int? = null
    private var currentDx = 0f
    private var clamp = 0f


    override fun getMovementFlags(  // 이동 방향을 결정!!,  스와이프 시 항상 onChildDraw 보다 먼저 호출!
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        Log.d("AppTest", "getMovementFlags")

        val view = getView(viewHolder)
        clamp = view.width.toFloat() / 10 * 3   // 아이템뷰 가로 길이의 비율로 clamp 설정도 가능!!!!

        // Drag와 Swipe 방향을 결정 Drag는 사용하지 않아 0, Swipe의 경우는 오른쪽에서 왼쪽으로만 가능하게 설정,   양방향 모두 하고 싶다면 'ItemTouchHelper.LEFT or ItemTouchHelper.Right'
        return makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT)
    }

    override fun onMove(  // Drag 시 호출
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {

        return false
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {  // Swipe 시 호출

    }

    // Called by the ItemTouchHelper when the user interaction with an element is over and it also completed its animation.
    override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
        //super.clearView(recyclerView, viewHolder)

        currentDx = 0f
        getDefaultUIUtil().clearView(getView(viewHolder))
        previousPosition = viewHolder.adapterPosition
    }


    // Called when the ViewHolder swiped or dragged by the ItemTouchHelper is changed =
    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        //super.onSelectedChanged(viewHolder, actionState)
        viewHolder?.let {
            currentPosition = viewHolder.adapterPosition  // 현재 스와이프 한 아이템 위치
            getDefaultUIUtil().onSelected(getView(it))
        }
    }

    override fun onChildDraw(  // 스와이프 동작 시 호출
        c: Canvas,
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        dX: Float,  // 스와이프를 시작한 터치 지점에서 얼만큼 좌우로 움직였는지! (왼쪽 = 음수, 오른쪽 = 양수)
        dY: Float,
        actionState: Int,
        isCurrentlyActive: Boolean
    ) {
        //super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
        if(actionState == ItemTouchHelper.ACTION_STATE_SWIPE){
            Log.d("AppTest", "onChildDraw")

            val view = getView(viewHolder)

            val isClamped = getClamped(viewHolder as RvAdapter.MyDataViewHolder)
            //val isClamped =getTag(viewHolder)

            val x = clampViewPositionHorizontal(view, dX, isClamped, isCurrentlyActive)
            //Log.d("AppTest", "dX : ${dX}, dY : ${dY}")

            currentDx = x
            Log.d("AppTest", "x : ${x}")

            getDefaultUIUtil().onDraw(
                c,
                recyclerView,
                view,
                x,     // dX 가 아닌 x 로 설정해야함!!!, x만큼 왼쪽으로 스와이프 뷰를 밀어서 그린다!
                dY,
                actionState,
                isCurrentlyActive
            )
        }
    }

    private fun clampViewPositionHorizontal(
        view: View,
        dX: Float,  //
        isClamped: Boolean,
        isCurrentlyActive: Boolean // 스와이프 중인지 , 손 떼면 false 된다
    ) : Float {
        // View의 가로 길이의 30% 만 스와이프 되도록
        val maxSwipe: Float = -view.width.toFloat() / 10 * 3 // 음수 값!!,  xml 상에서 삭제 영역이 아이템뷰 width의 0.3 만큼 차지하도록 설정한 것과 맞추기 위함

        // RIGHT 방향으로 swipe 막기
        val right: Float = 0f

        val x = if (isClamped) {
            // View가 고정되었을 때 swipe되는 영역 제한
            if (isCurrentlyActive) dX - clamp else -clamp
        // 스와이프 된 고정 상태에서 dX - clamp 가 maxSwipe 이하인 상태에서 터치 유지 해제(왼쪽 스와이프 시도 중) -> maxSwipe 리턴
        // 스와이프 된 고정 상태에서 dx - clamp 가 maxSwipe 이상인 상태에서 터치 유지 해제(오른쪽 스와이프 시도 중) -> isClamped 에 false 값이 들어올 것임 -> currentDx가 -clamp 보다 커진다!
        } else {
            dX   // maxSwipe보다 더 많이 왼쪽으로 스와이프 해도 maxSwipe 까지만 스와이프 된다(이때 isClamped는 true 된다) / 밑에서 maxSwipe & x 중에 큰 값을 사용하기 때문
        }

        return min(max(maxSwipe, x), right) // right = 항상 0 이므로  min 함수에서 최대는 항상 0 값이 나온다 -> 스와이프를 통해 오른쪽으로 밀리는 일은 없다!!
    }


    override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
        //return super.getSwipeEscapeVelocity(defaultValue)
        return defaultValue * 10
    }

    override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {   // 터치 유지하다가 떼면 호출됨
        //return super.getSwipeThreshold(viewHolder)

        Log.d("AppTest", "getSwipeThreshold")
        //val isClamped = getClamped(viewHolder as RvAdapter.MyDataViewHolder)
        //val isClamped =getTag(viewHolder)

        // 현재 View가 고정되어있지 않고 사용자가 -clamp 이상 swipe시 isClamped true로 변경 아닐시 false로 변경 처리 할 것!!!

        Log.d("AppTest", "isClamped = ${currentDx <= -clamp}")
        //setTag(viewHolder, currentDx <= -clamp)  // 스와이프 되고 오른쪽 스와이프 시에만 닫히도록 하게하기 위해  '!isClamped && ' 조건 제거
        setClamped(viewHolder as RvAdapter.MyDataViewHolder, currentDx <= -clamp)

        return 2f
    }

    private fun getView(viewHolder: RecyclerView.ViewHolder): View {
        return (viewHolder as RvAdapter.MyDataViewHolder).itemView.findViewById(R.id.swipe_view) // 아이템뷰에서 스와이프 영역에 해당하는 뷰 가져오기
    }

    private fun setClamped(viewHolder: RvAdapter.MyDataViewHolder, isClamped: Boolean){
        viewHolder.setClamped(isClamped)
    }

    private fun getClamped(viewHolder: RvAdapter.MyDataViewHolder): Boolean{
        return viewHolder.getClamped()
    }

    fun setClamp(clamp: Float) {  // activity or fragment에서 clamp 값을 설정할 수도 있다
        this.clamp = clamp
    }

    // 스와이프가 되었는지를 tag 값으로 판단했으나, 뷰홀더 재활용 과정에서 혼란이 발생할 수 있음 -> 리사이클러뷰 데이터 클래스에 swipe가 되었는지를 판단하는 data 추가
    private fun setTag(viewHolder: RecyclerView.ViewHolder, isClamped: Boolean) {
        // isClamped를 view의 tag로 관리
        viewHolder.itemView.tag = isClamped
    }

    private fun getTag(viewHolder: RecyclerView.ViewHolder) : Boolean {
        // isClamped를 view의 tag로 관리
        return viewHolder.itemView.tag as? Boolean ?: false
    }
    /////////////////////////////////////////////////////////////////////////////////

    fun removePreviousClamp(recyclerView: RecyclerView) {
        if (currentPosition == previousPosition)
            return
        previousPosition?.let {
            val viewHolder = recyclerView.findViewHolderForAdapterPosition(it) ?: return
            getView(viewHolder).translationX = 0f
            setTag(viewHolder, false)
            previousPosition = null
        }
    }


}

//  스와이프를 하던 손 or 마우스를 떼는 순간 getSwipeThreshold 호출되며, 여기서 isClamped 가 true 가 될지, false가 될 지를 정한다!!!





  • SwipeHelper 클래스의 clamp 변수 값이 위 그림의 역할을 하고 있다고 보면 된다




profile
매 순간 최선을 다하자

2개의 댓글

comment-user-thumbnail
2022년 6월 17일

setTag 함수와 getTag함수의 tag 관리 대신 recyclerView에 들어가는 dataClass 값에 isSwiped라는 boolean값을 넣어준 뒤에 adpater를 helperCallBack클래스에 생성자로 받아서 tag를 adapter.currentList[viewHodler.layoutPosition].isSwiped로 바꾸어 사용해도 괜찮을까요? 혹시 좋은 방법이 있다면 추가 부탁드립니다!

1개의 답글