Android - RecyclerView Swipe Menu

Trycatch·2020년 10월 12일
6
post-thumbnail

RecyclerViewItemTouchHelper를 이용하여 Swipe Menu를 만들어보자

Code

RecyclerView

우선 item을 화면에 표시해줄 RecyclerView를 구현해준다.

// activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:background="#ffffff">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
// item_swipe.xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <variable
            name="label"
            type="String" />
    </data>

    <FrameLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="100dp">

        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginStart="13dp"
            android:layout_marginEnd="13dp"
            android:background="@drawable/background_task_item">

            <TextView
                android:id="@+id/task"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="테스크"
                android:textColor="#ffffff"
                android:layout_marginEnd="25dp"
                android:layout_gravity="end|center_vertical" />

        </FrameLayout>

        <LinearLayout
            android:id="@+id/swipe_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="horizontal"
            android:layout_marginStart="13dp"
            android:layout_marginEnd="13dp"
            android:background="@drawable/background_item">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@{label}"
                android:textColor="#000000"
                android:layout_marginStart="25dp"
                android:layout_gravity="start|center_vertical"/>
        </LinearLayout>

    </FrameLayout>
</layout>

RecyclerViewAdapter 구현

class SwipeListAdapter : RecyclerView.Adapter<SwipeListAdapter.SwipeViewHolder>() {
    private val items: MutableList<String> = mutableListOf<String>().apply {
        for (i in 0..10)
            add("$i")
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SwipeViewHolder = SwipeViewHolder(
        ItemSwipeBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
    )

    override fun onBindViewHolder(holder: SwipeViewHolder, position: Int) {
        holder.bind(items[position])
    }

    override fun getItemCount(): Int = items.size

    class SwipeViewHolder(private val binding: ItemSwipeBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(label: String) {
            binding.label = label
            // 테스크 버튼 클릭시 SnackBar 표시
            binding.task.setOnClickListener {
                Snackbar.make(it, "$label click", Snackbar.LENGTH_SHORT).show()
            }
        }
    }
}

해당 AdapterRecyclerView의 연결시켜준다.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        recyclerView.apply {
            layoutManager = LinearLayoutManager(applicationContext)
            adapter = SwipeListAdapter()
            addItemDecoration(ItemDecoration())
        }
    }
}

ItemTouchHelper

ItemTouchHelper Document

ItemTouchHelper는 공식 Document에 따르면 아래와 같이 설명되어지고 있다.

This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.

It works with a RecyclerView and a Callback class, which configures what type of interactions are enabled and also receives events when user performs these actions.

ItemTouchHelper.Callback

ItemTouchHelper.Callback Document

ItemTouchHelper를 사용하기 위해서는 ItemTouchHelper.Callback 클래스를 구현해줘야하며, ItemTouchHelper.Callback은 아래 abstract method들을 구현해줘야한다.

  • public abstract int getMovementFlags (RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder)
    
  • public abstract boolean onMove (RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target)
    
  • public abstract void onSwiped (RecyclerView.ViewHolder viewHolder, int direction)

getMovementFlags는 이동 방향을 결정하고
onMoveonSwiped는 각각 Drag, Swipe 될 때 호출된다.

class SwipeHelperCallback : ItemTouchHelper.Callback() {

    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        // Drag와 Swipe 방향을 결정 Drag는 사용하지 않아 0, Swipe의 경우는 LEFT, RIGHT 모두 사용가능하도록 설정
        return makeMovementFlags(0, LEFT or RIGHT)
    }

    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ) = false

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

ItemTouchHelper.Callback을 구현해주었으면 RecyclerView와 연결해준다.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
			
        .
        . 중략
        .
    
        val swipeHelperCallback = SwipeHelperCallback()
        val itemTouchHelper = ItemTouchHelper(swipeHelperCallback)
        itemTouchHelper.attachToRecyclerView(recyclerView)
    }
}

ItemTouchUIUtil

이제 View 전체가 swipe 되는게 아닌 item_swipe.xmlswipe_view만 swipe 되도록 해보자

ItemTouchHelper.Callback에는 public static ItemTouchUIUtil getDefaultUIUtil ()가 구현되어 있으며, 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 Documnet

ItemTouchUIUtil을 적용시킨 코드는 아래와 같다.

class SwipeHelperCallback : ItemTouchHelper.Callback() {

    .
    . 중략
    .

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

    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        viewHolder?.let {
            getDefaultUIUtil().onSelected(getView(it))
        }
    }

    override fun onChildDraw(
        c: Canvas,
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        dX: Float,
        dY: Float,
        actionState: Int,
        isCurrentlyActive: Boolean
    ) {
        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 SwipeListAdapter.SwipeViewHolder).itemView.swipe_view
    }
}

Block Escape

ItemTouchHelper는 일정 범위 밖이나 일정 속도 이상으로 swipe가 될 시 View 밖으로 escape 되도록 구현되어있는데 getSwipeEscapeVelocitygetSwipeThreshold를 override하여 escape를 막는다.

해당 method의 경우 아래 링크 참조

class SwipeHelperCallback : ItemTouchHelper.Callback() {
	
    .
    . 중략
    .
    
    override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
        return defaultValue * 10
    }

    override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
        return 2f
    }
}

Clamp

이제 View를 swipe시 테스크 버튼이 보이도록 고정시켜보자

class SwipeHelperCallback : ItemTouchHelper.Callback() {

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

    .
    . 중략
    .

    override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
        currentDx = 0f
        previousPosition = viewHolder.adapterPosition
        getDefaultUIUtil().clearView(getView(viewHolder))
    }
    
    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        viewHolder?.let {
            currentPosition = viewHolder.adapterPosition
            getDefaultUIUtil().onSelected(getView(it))
        }
    }

    override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
        val isClamped = getTag(viewHolder)
        // 현재 View가 고정되어있지 않고 사용자가 -clamp 이상 swipe시 isClamped true로 변경 아닐시 false로 변경
        setTag(viewHolder, !isClamped && currentDx <= -clamp)
        return 2f
    }

    override fun onChildDraw(
        c: Canvas,
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        dX: Float,
        dY: Float,
        actionState: Int,
        isCurrentlyActive: Boolean
    ) {

        if (actionState == ACTION_STATE_SWIPE) {
            val view = getView(viewHolder)
            val isClamped = getTag(viewHolder)
            val x =  clampViewPositionHorizontal(view, dX, isClamped, isCurrentlyActive)

            currentDx = x
            getDefaultUIUtil().onDraw(
                c,
                recyclerView,
                view,
                x,
                dY,
                actionState,
                isCurrentlyActive
            )
        }
    }

    private fun clampViewPositionHorizontal(
        view: View,
        dX: Float,
        isClamped: Boolean,
        isCurrentlyActive: Boolean
    ) : Float {
        // View의 가로 길이의 절반까지만 swipe 되도록
        val min: Float = -view.width.toFloat()/2
        // RIGHT 방향으로 swipe 막기
        val max: Float = 0f

        val x = if (isClamped) {
       	    // View가 고정되었을 때 swipe되는 영역 제한
            if (isCurrentlyActive) dX - clamp else -clamp
        } else {
            dX
        }

        return min(max(min, x), max)
    }

    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 setClamp(clamp: Float) {
        this.clamp = clamp
    }
    
    // 다른 View가 swipe 되거나 터치되면 고정 해제
    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
        }
    }
}

View가 swipe 되었을 때 고정될 크기 설정과 RecyclerView의 다른 View를 터치했을시 이전에 고정한 View를 해제시킨다.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val swipeAdapter = SwipeListAdapter()

        val swipeHelperCallback = SwipeHelperCallback().apply {
            setClamp(200f)
        }
        val itemTouchHelper = ItemTouchHelper(swipeHelperCallback)
        itemTouchHelper.attachToRecyclerView(recyclerView)

        recyclerView.apply {
            layoutManager = LinearLayoutManager(applicationContext)
            adapter = swipeAdapter
            addItemDecoration(ItemDecoration())

            setOnTouchListener { _, _ ->
                swipeHelperCallback.removePreviousClamp(this)
                false
            }
        }

    }
}

Result

이렇게 ItemTouchHelper를 이용하여 Swipe Menu를 만들어보았습니다.
전체 소스 코드는 Github에서 확인 가능합니다.

Reference

https://developer.android.com/reference/androidx/recyclerview/widget/ItemTouchHelper

https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28

profile
Android Developer

0개의 댓글