[Android] RecyclerView Item Swipe 구현하기 (with Kotlin)

Minji Jeong·2022년 6월 30일
3

Android

목록 보기
25/39
post-thumbnail
개발을 하다보면 RecyclerView를 이용해서 리스트 형식의 뷰를 만들 때가 굉장히 많다. 단순하게 보여주기 용도로만 리스트를 만들 때도 있지만, 리스트 내의 각각의 아이템을 클릭해서 어떠한 이벤트를 발생시키거나, 아이템을 옆으로 밀어 삭제하는 등의 기능을 구현하고 싶을 때가 있다. 나는 이러한 기능을 직접 프로젝트에서 구현하고 싶었고, 아래 블로그들 참고하여 RecyclerView 내의 아이템들을 밀면 삭제 버튼과 수정 버튼이 나타나서 각 기능을 수행할 수 있도록 구현하였다. 참고한 블로그들의 주소를 아래 링크로 걸어놓을테니 더 자세한 설명을 원한다면 해당 블로그들을 참고하자. 참고로 해당 블로그들의 예제는 Java로 작성되어 있다.
https://everyshare.tistory.com/30
https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28

가장 위에 첨부해놓은 사진처럼, RecyclerView의 아이템을 오른쪽으로 밀면 삭제 버튼이 나타나도록, 왼쪽으로 밀면 수정 버튼이 나타나도록 했다. 위 사진처럼 구현하기 위해 필요한 레이아웃 파일과 클래스들을 차례로 나열해보도록 할테니, 참고해서 원하는 화면을 구현해보도록 하자 😀.

fragment_memo.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"
    android:background="@color/classicBlue"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/todoListView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        tools:ignore="MissingConstraints" />

</androidx.constraintlayout.widget.ConstraintLayout>

memo_item.xml
RecyclerView의 아이템에 대한 레이아웃 파일이다. 참고로 나는 Databinding을 사용해 TextView에 바인딩되어야 하는 문자열들을 어댑터의 ViewHolder에서 한번에 바인딩 할 것이지만, Databinding을 사용하지 않고자 하는 사람들은 그냥 일반적인 방법으로 구현하면 된다.

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="memo"
            type="com.example.Memo" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/view1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/classicBlue"
        android:layout_marginTop="5dp">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:layout_margin="10dp"
            tools:ignore="MissingConstraints">

            <TextView
                android:id="@+id/content"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                android:text="@{memo.content}"
                android:textSize="15sp"
                android:textColor="@color/white"
                android:textStyle="bold" />

            <CheckBox
                android:id="@+id/completionBox"
                android:layout_width="30dp"
                android:layout_height="30dp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="@id/content"
                app:layout_constraintBottom_toBottomOf="@id/content"
                android:buttonTint="@color/white"/>

        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

Memo.kt
RecyclerView에 표시될 데이터들에 대한 데이터 클래스다.

data class MemoDataModel(
    val content: String, 
    val completion: Boolean 
)

MemoAdapter.kt
RecyclerView와 데이터들을 연결해줄 어댑터 클래스다. onBindViewHolder() 내에서 memo_item.xml의 TextView에 데이터 클래스 Memo 객체의 아이템(val content: String)이 바인딩된다.

class MemoAdapter (
    private val context: Context,
    private val viewModel: ViewModel
    ) : RecyclerView.Adapter<MemoAdapter.Holder>() {

    var list = ArrayList<Memo>()
    private lateinit var binding: MemoItemBinding

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val inflater = LayoutInflater.from(context)
        binding = MemoItemBinding.inflate(inflater, parent, false)
        return Holder(binding.root)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        holder.onBind(list[position])
    }

    override fun getItemCount(): Int = list.size

    inner class Holder(val view: View) : RecyclerView.ViewHolder(view) {

        fun onBind(item: Memo) {
            binding.memo = item
        }
}

ItemTouchHelperListener.kt
후에 어댑터 클래스가 implements 해야 할 인터페이스다. 어댑터 클래스에서 이 인터페이스를 implements 한 후, 원하는 기능들을 각 함수 내부에서 자유롭게 구현할 수 있다. 나는 좌우 스와이프에 대한 기능을 구현할 예정이기에 onLeftClick과 onRightClick을 활용해야 한다.

interface ItemTouchHelperListener {
    fun onItemMove(from_position: Int, to_position: Int): Boolean
    fun onItemSwipe(position: Int)
    fun onLeftClick(position: Int, viewHolder: RecyclerView.ViewHolder?)
    fun onRightClick(position: Int, viewHolder: RecyclerView.ViewHolder?)
}

SwipeController.kt
RecyclerView의 각각의 아이템을 사용자의 터치에 따라 특정 방향으로 스와이프시키고, 스와이프 후에 나타날 각각의 버튼들을 동적으로 구현하는 클래스다. 코드 양이 매우 많고 나 역시 모든 코드를 제대로 이해한 건 아니라서 🤣 이해한 만큼 최대한 주석에 설명을 달아놓았다.

class SwipeController() : ItemTouchHelper.Callback() {

    private var swipeBack = false
    private val buttonWidth = 200f //버튼 너비 지정
    private var buttonsShowedState = ButtonState.GONE
    private var buttonInstance: RectF? = null //버튼 객체 초기 지정
    private lateinit var listener: ItemTouchHelperListener
    private var currentItemViewHolder: RecyclerView.ViewHolder? = null

    constructor(listener: ItemTouchHelperListener) : this() {
        this.listener = listener
    }

    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        val draw_flags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
        val swipe_flags = ItemTouchHelper.START or ItemTouchHelper.END
        return makeMovementFlags(draw_flags, swipe_flags)
    }

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

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        listener.onItemSwipe(viewHolder.adapterPosition);
    }

    override fun onChildDraw(
        c: Canvas,
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        dX: Float,
        dY: Float,
        actionState: Int,
        isCurrentlyActive: Boolean
    ) {
        var dX = dX
        if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
            if (buttonsShowedState !== ButtonState.GONE) {
                if (buttonsShowedState === ButtonState.LEFT_VISIBLE) //오른쪽으로 스와이프 했을 때
                    dX = dX.coerceAtLeast(buttonWidth)
                if (buttonsShowedState === ButtonState.RIGHT_VISIBLE) //왼쪽으로 스와이프 했을 때
                    dX = dX.coerceAtMost(-buttonWidth)
                super.onChildDraw(
                    c,
                    recyclerView,
                    viewHolder,
                    dX,
                    dY,
                    actionState,
                    isCurrentlyActive
                )
            } else {
                setTouchListener(
                    c,
                    recyclerView,
                    viewHolder,
                    dX,
                    dY,
                    actionState,
                    isCurrentlyActive
                )
            }
            if (buttonsShowedState === ButtonState.GONE) {
                super.onChildDraw(
                    c,
                    recyclerView,
                    viewHolder,
                    dX,
                    dY,
                    actionState,
                    isCurrentlyActive
                )
            }
        }
        currentItemViewHolder = viewHolder
        drawButtons(c, currentItemViewHolder!!)
    }

    private fun drawButtons(
        c: Canvas,
        viewHolder: RecyclerView.ViewHolder
    ) { //레이아웃이 아닌 클래스에서 직접 버튼 구현
        val buttonWidthWithOutPadding = buttonWidth - 10
        val corners = 5f
        val itemView: View = viewHolder.itemView
        val p = Paint() //Paint 객체 p 생성
        buttonInstance = null

        //rectF 클래스로 버튼 형태 구현
        if (buttonsShowedState === ButtonState.LEFT_VISIBLE) {
            val leftButton = RectF(
                (itemView.left + 10).toFloat(),
                (itemView.top + 10).toFloat(),
                itemView.left + buttonWidthWithOutPadding,
                (itemView.bottom - 10).toFloat()
            ) //rectF : top, bottom, left, right 에 대한4가지 정보를 가지고 있는 직사각형 클래스 (rect : int, rectF : float)
            p.color = Color.parseColor("원하는 버튼 색상의 hex code 입력") //Paint 객체 컬러 지정, setColor 메서드는 int형 인자를 매개변수로 받기 때문에 string 형태의 색상을 Color 클래스의 parseColor 메서드를 이용해 int형으로 바꿔줌
            c.drawRoundRect(leftButton, corners, corners, p) //drawRoundRect : 원 그리기
            drawText("버튼에 표시될 텍스트", c, leftButton, p)
            buttonInstance = leftButton
        } else if (buttonsShowedState === ButtonState.RIGHT_VISIBLE) { //왼쪽으로 스와이프 했을 때, 오른쪽 버튼(삭제)이 나와야 함
            val rightButton = RectF(
                itemView.right - buttonWidthWithOutPadding, (itemView.top + 10).toFloat(),
                (itemView.right - 10).toFloat(), (itemView.bottom - 10).toFloat()
            )
            p.color = Color.parseColor("원하는 버튼 색상의 hex code 입력")
            c.drawRoundRect(rightButton, corners, corners, p)
            drawText("버튼에 표시될 텍스트", c, rightButton, p)
            buttonInstance = rightButton
        }
    }

    private fun drawText(text: String, c: Canvas, button: RectF, p: Paint) { //버튼 내에 글씨 삽입
        val textSize = 45f
        p.color = Color.WHITE
        p.isAntiAlias = true
        p.textSize = textSize
        val textWidth = p.measureText(text) //measureText : 글자의 너비 리턴
        c.drawText(
            text,
            button.centerX() - textWidth / 2,
            button.centerY() + textSize / 2,
            p
        ) //Canvas 객체의 drawText : 글자의 구체적인 속성 정의
    }

    override fun convertToAbsoluteDirection(flags: Int, layoutDirection: Int): Int {
        if (swipeBack) {
            swipeBack = false
            return 0
        }
        return super.convertToAbsoluteDirection(flags, layoutDirection)
    }

    @SuppressLint("ClickableViewAccessibility")
    private fun setTouchListener(
        c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
        dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
    ) {
        recyclerView.setOnTouchListener { view, event ->
            swipeBack =
                event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP
            if (swipeBack) {
                if (dX < -buttonWidth) buttonsShowedState =
                    ButtonState.RIGHT_VISIBLE else if (dX > buttonWidth) buttonsShowedState =
                    ButtonState.LEFT_VISIBLE
                if (buttonsShowedState !== ButtonState.GONE) {
                    setTouchDownListener(
                        c,
                        recyclerView,
                        viewHolder,
                        dX,
                        dY,
                        actionState,
                        isCurrentlyActive
                    )
                    setItemClickable(recyclerView, false)
                }
            }
            false
        }
    }

    @SuppressLint("ClickableViewAccessibility")
    private fun setTouchDownListener(
        c: Canvas, recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
        actionState: Int, isCurrentlyActive: Boolean
    ) {
        recyclerView.setOnTouchListener { v, event ->
            if (event.action == MotionEvent.ACTION_DOWN) {
                setTouchUpListener(
                    c,
                    recyclerView,
                    viewHolder,
                    dX,
                    dY,
                    actionState,
                    isCurrentlyActive
                )
            }
            false
        }
    }

    @SuppressLint("ClickableViewAccessibility")
    private fun setTouchUpListener(
        c: Canvas, recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
        actionState: Int, isCurrentlyActive: Boolean
    ) {
        recyclerView.setOnTouchListener { v, event ->
            super@SwipeController.onChildDraw(
                c,
                recyclerView,
                viewHolder,
                0f,
                dY,
                actionState,
                isCurrentlyActive
            )
            recyclerView.setOnTouchListener { v, event -> false }
            setItemClickable(recyclerView, true)
            swipeBack = false
            if (buttonInstance != null && buttonInstance!!.contains(
                event.x,
                event.y
            )
        ) {
            if (buttonsShowedState === ButtonState.LEFT_VISIBLE) {
                listener.onLeftClick(viewHolder.adapterPosition, viewHolder)
            } else if (buttonsShowedState === ButtonState.RIGHT_VISIBLE) {
                listener.onRightClick(viewHolder.adapterPosition, viewHolder)
            }
        }
            buttonsShowedState = ButtonState.GONE
            currentItemViewHolder = null
            false
        }
    }

    private fun setItemClickable(recyclerView: RecyclerView, isClickable: Boolean) {
        for (i in 0 until recyclerView.childCount) {
            recyclerView.getChildAt(i).isClickable = isClickable
        }
    }
}

MemoAdapter.kt
이전에 만들었던 어댑터 클래스에서 ItemTouchHelperListener를 implements 한 후, 오버라이딩 된 onLeftClick과 onRightClick 내에서 버튼 클릭 시 원하는 동작을 코드로 작성한다.

class MemoAdapter (
    private val context: Context,
    private val viewModel: ViewModel
    ) : RecyclerView.Adapter<MemoAdapter.Holder>(), ItemTouchHelperListener {

    var list = ArrayList<MemoDataModel>()
    private lateinit var binding: MemoItemBinding

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val inflater = LayoutInflater.from(context)
        binding = MemoItemBinding.inflate(inflater, parent, false)
        return Holder(binding.root)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        holder.onBind(list[position])
    }

    override fun getItemCount(): Int = list.size

    inner class Holder(val view: View) : RecyclerView.ViewHolder(view) {

        fun onBind(item: MemoDataModel) {
            binding.memo = item
        }
    }

    override fun onLeftClick(position: Int, viewHolder: RecyclerView.ViewHolder?) { 
        Toast.makeText(context, "LeftClick", Toast.LENGTH_SHORT).show()
    }

    override fun onRightClick(position: Int, viewHolder: RecyclerView.ViewHolder?) { 
    	Toast.makeText(context, "RightClick", Toast.LENGTH_SHORT).show()
    }

    override fun onItemMove(from_position: Int, to_position: Int): Boolean = false

    override fun onItemSwipe(position: Int) { // }
}

MemoFragment.kt
RecyclerView가 나타나야 할 액티비티 또는 프래그먼트에서 생성한 ItemTouchHelper 객체에 RecyclerView를 부착하고, 데이터들이 저장된 어댑터 객체를 RecyclerView에 연결하면 완성이다.

class MemoFragment : Fragment(R.layout.fragment_memo) {

    private val binding by viewBinding(FragmentMemoBinding::bind,
        onViewDestroyed = { fragmentMemoBinding ->
            fragmentMemoBinding.todoListView.adapter = null
        })

    private val viewModel : ViewModel by inject()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val adapter = MemoAdapter(requireContext(), viewModel)

        val itemTouchHelper = ItemTouchHelper(SwipeController(adapter))
   		// itemTouchHelper에 RecyclerView 부착    
   		itemTouchHelper.attachToRecyclerView(binding.todoListView)

        // RecyclerView item에 출력할 모든 데이터 가져오기
        viewModel.getAllMemo().observe(viewLifecycleOwner, Observer { list ->
            list?.let {
                adapter.list = it as ArrayList<MemoDataModel>
            }
            // RecyclerView에 어댑터 연결
            binding.todoListView.adapter = adapter
            binding.todoListView.layoutManager = LinearLayoutManager(requireContext())
        })
    }

    companion object{
        const val TAG = "MemoFragment"
    }
}

References

https://everyshare.tistory.com/30
https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28

profile
Mobile Software Engineer

0개의 댓글