[Android/Kotlin] recyclerview drag and drop/swipe 기능 만들기
유튜브하다가 이건 또 어케하는 거야 싶어져서 만들어 봤는데
생각보다 너무 오래 걸렸다~~~~
시작하기 전에 다른 분들이 알려주신 이슈 해결 사항도 여기 적어보고자 합니다!!
1, 왼쪽으로 스와이프 해서 삭제 버튼을 보이게할 때, 다른 리스트도 함께 스와이프 되는 현상
(RecyclerView swipe to show button 구현 중 UI가 초기화 되지 않는 현상)
2, 아이템 삭제한 뒤 삭제 누르지 않은 index의 아이템들이 삭제되는 현상
해결 방법 => removeData()에서 notifyItemRemoved(position) 이후에 notifyItemRangeChanged(position, itemCount - position) 추가
댓글 감사합니다!!
구현한 기능
1, drag and drop
2, swipe(반만 swipe 되게 하기, swipe 고정시키기)
3, 삭제 버튼 눌러서 삭제
4, sub menu 달기
5, view 터치 시 클릭 효과 주기 + 다른 view 선택 시 이전에 선택했던 view 닫기
전체 코드 보기 전에 굵직한 내용들은 설명을 조금 덧붙여보려고 한다.
recyclerview에 drag and drop, swipe 기능을 넣고 싶다면 ItemTouchHelper.Callback()을 상속받은 클래스를 recyclerview와 연결시켜 주어야 한다.
나는 SwipeHelperCallback라는 클래스를 만들어 ItemTouchHelper.Callback()를 상속받아 기능들을 구현했고,
MainActivity에서 아래 코드와 같이 연결시켜 주었다.
// 리사이클러뷰에 스와이프, 드래그 기능 달기
val swipeHelperCallback = SwipeHelperCallback(recyclerViewAdapter).apply {
// 스와이프한 뒤 고정시킬 위치 지정
setClamp(resources.displayMetrics.widthPixels.toFloat() / 4) // 1080 / 4 = 270
}
ItemTouchHelper(swipeHelperCallback).attachToRecyclerView(binding.recyclerView)
SwipeHelperCallback의 인수로는 recyclerViewAdapter를 넣어주었는데,
이는 recyclerViewAdapter에서 정의한 removeData, swipeData 메서드를 사용하기 위해서이다.
removeData는 데이터를 삭제하는 메서드이고, swipeData는 데이터를 교환하는 함수이다.
SwipeHelperCallback에서 오버라이드 한 함수들을 살펴보자.
getMovementFlags : 사용자가 view를 이동할 때, 인식할 방향을 설정
나는 드래그할 땐 위, 아래만 방향을 인식하게 했고,
스와이프할 때는 왼쪽, 오른쪽만 인식하게 했다.
만약 드래그 기능을 빼고 싶다면 UP or DOWN 부분에 0이라고 넣어주면 된다.
// 이동 방향 결정하기
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
// 드래그 방향 : 위, 아래 인식
// 스와이프 방향 : 왼쪽, 오른쪽 인식
// 설정 안 하고 싶으면 0
return makeMovementFlags(UP or DOWN, LEFT or RIGHT)
}
1, drag and drop
onMove : drag and drop 동작 정의
인수로 받은 recyclerviewAdapter의 swipeData 메서드를 이용해 데이터를 교환하는 것을 구현했다.
// 드래그 일어날 때 동작 (롱터치 후 드래그)
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
// 리사이클러뷰에서 현재 선택된 데이터와 드래그한 위치에 있는 데이터를 교환
val fromPos: Int = viewHolder.adapterPosition
val toPos: Int = target.adapterPosition
recyclerViewAdapter.swapData(fromPos, toPos)
return true
}
2, swipe(반만 swipe 되게 하기, swipe 고정시키기)
나는 반만 swipe되고 나서 삭제 버튼을 눌러야 삭제가 되게 했는데,
이런 방식이 아니라 전체를 swipe를 해서 삭제하고 싶으면 onSwiped 함수에서 다음과 같이 적어주면 된다.
removeData도 recyclerViewAdapter에서 정의한 함수이다.
// 스와이프 일어날 때 동작
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// 스와와이프 끝까지 하면 해당 데이터 삭제하기
recyclerViewAdapter.removeData(viewHolder.layoutPosition)
}
반만 swipe 되고 고정하는 코드는 아래와 같다.
// 아이템을 터치하거나 스와이프하는 등 뷰에 변화가 생길 경우 호출
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) // 고정할지 말지 결정, true : 고정함 false : 고정 안 함
val newX = clampViewPositionHorizontal(dX, isClamped, isCurrentlyActive) // newX 만큼 이동(고정 시 이동 위치/고정 해제 시 이동 위치 결정)
// 고정시킬 시 애니메이션 추가
if (newX == -clamp) {
getView(viewHolder).animate().translationX(-clamp).setDuration(100L).start()
return
}
currentDx = newX
getDefaultUIUtil().onDraw(
c,
recyclerView,
view,
newX,
dY,
actionState,
isCurrentlyActive
)
}
}
onChildDraw에서 getTag를 통해 현재 view를 고정시킬지 말지를 결정한다.
// isClamped를 view의 tag로 관리
// isClamped = true : 고정, false : 고정 해제
private fun setTag(viewHolder: RecyclerView.ViewHolder, isClamped: Boolean) { viewHolder.itemView.tag = isClamped }
private fun getTag(viewHolder: RecyclerView.ViewHolder) : Boolean = viewHolder.itemView.tag as? Boolean ?: false
getTag에서 가져온 isClamped값으로 실제로 이동할 newX를 구한다.
// swipe_view 를 swipe 했을 때 <삭제> 화면이 보이도록 고정
private fun clampViewPositionHorizontal(
dX: Float,
isClamped: Boolean,
isCurrentlyActive: Boolean
) : Float {
// RIGHT 방향으로 swipe 막기
val max = 0f
// 고정할 수 있으면
val newX = if (isClamped) {
// 현재 swipe 중이면 swipe되는 영역 제한
if (isCurrentlyActive)
// 오른쪽 swipe일 때
if (dX < 0) dX/3 - clamp
// 왼쪽 swipe일 때
else dX - clamp
// swipe 중이 아니면 고정시키기
else -clamp
}
// 고정할 수 없으면 newX는 스와이프한 만큼
else dX / 2
// newX가 0보다 작은지 확인
return min(newX, max)
}
이를 그림으로 표현해보면 아래와 같다.
고정되지 않을 때 왼쪽 이동 : dX / 2 (반만 이동)
고정되지 않을 때 오른쪽 이동 : 불가(min(newX, max)으로 0이상 이동을 막음)
고정되어있을 때 왼쪽 이동 : dX/3 - clamp (3분의 1만큼 이동하지만 clamp가 4분의 1을 이미 차지하고 있어서 (4분의 1은 이미 고정되어 있어서) 결국 반만 이동하는 것처럼 보임)
고정되어있을 때 오른쪽 이동 : dX - clamp
삭제 버튼을 view의 4분의 1이 되도록 weightSum과 layout_weight를 이용했고,
clamp도 view의 4분의 1로 설정해주기 위해 setClamp(resources.displayMetrics.widthPixels.toFloat() / 4)로 해줬다.
3, 삭제 버튼 눌러서 삭제
4, sub menu 달기
recyclerViewAdapter에서 정의
// 뷰 홀더 설정
inner class MyViewHolder(private val binding : ItemListBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(title : String) {
// 제목 달기
binding.title = title
// 서브 메뉴 달기(...모양)
binding.textViewOptions.setOnClickListener {
val popup = PopupMenu(binding.textViewOptions.context, binding.textViewOptions)
popup.inflate(R.menu.recyclerview_item_menu)
popup.setOnMenuItemClickListener { item ->
val str = when (item.itemId) {
R.id.itemSaveLater -> "나중에 볼 동영상에 저장"
R.id.itemSavePalyList -> "재생목록에 저장"
R.id.itemSvaeOffline -> "동영상 오프라인 저장"
R.id.itemShare -> "공유"
R.id.itemRemove -> "좋아요 표시한 동영상에서 삭제"
else -> "오류"
}
Toast.makeText(binding.textViewOptions.context, str, Toast.LENGTH_SHORT).show()
true
}
popup.show()
}
// 삭제 텍스트뷰 클릭시 토스트 표시
binding.tvRemove.setOnClickListener {
removeData(this.layoutPosition)
Toast.makeText(binding.root.context, "삭제했습니다.", Toast.LENGTH_SHORT).show()
}
}
}
5, view 터치 시 클릭 효과 주기 + 다른 view 선택 시 이전에 선택했던 view 닫기
클릭 효과 추가 : android:background="?attr/selectableItemBackground"
이전에 선택한 view 닫기 :
SwipeHelperCallback
// view가 swipe 되었을 때 고정될 크기 설정
fun setClamp(clamp: Float) { this.clamp = clamp }
// 다른 View가 swipe 되거나 터치되면 고정 해제
fun removePreviousClamp(recyclerView: RecyclerView) {
// 현재 선택한 view가 이전에 선택한 view와 같으면 패스
if (currentPosition == previousPosition) return
// 이전에 선택한 위치의 view 고정 해제
previousPosition?.let {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(it) ?: return
getView(viewHolder).animate().x(0f).setDuration(100L).start()
setTag(viewHolder, false)
previousPosition = null
}
}
MainActivity
// 다른 곳 터치 시 기존 선택했던 뷰 닫기
binding.recyclerView.setOnTouchListener { _, _ ->
swipeHelperCallback.removePreviousClamp(binding.recyclerView)
false
}
activity_main.xml
item_list.xml : 리사이클러뷰의 아이템뷰(dataBinding 사용)
참고
https://developer.android.com/topic/libraries/data-binding/expressions?hl=ko
MyRecyclerViewAdapter.kt : 리사이클러뷰 어댑터(삭제버튼 눌러서 삭제, sub menu 달기)
참고
https://stackoverflow.com/questions/37601346/create-options-menu-for-recyclerview-item
SwipeHelperCallback.kt : drag and drop, swipe 동작 제어
참고
https://velog.io/@trycatch98/Android-RecyclerView-Swipe-Menu
http://www.devexchanges.info/2017/02/android-recyclerview-dynamically-load.html?m=1
https://cliearl.github.io/posts/android/recyclerview-touch/
https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/ItemTouchHelper.Callback
MainActivity.kt
추가로 handleIcon 클릭했을 때만 drag and drop 하고 싶으면 아래 문서를 참고하면 될 것 같다.
https://niqrid2020.pe.kr/recyclerview-drag-and-drop-%EC%98%88%EC%A0%9C-%EC%BD%94%EB%93%9Ckotlin/