RecyclerView의 기본 요소(Adapter, ViewHolder, item.xml 등)가 모두 구현되어 있음을 전제로, 드래그 동작 추가하는 법을 설명하는 글입니다.
급하신 분은 여기부터 읽으시면 됩니다.
제스처를 지원하는 리스트뷰는 모바일에서 굉장히 흔하게 볼 수 있는 UI다.
예시로 음악 앱에서 드래그로 곡 순서 바꾸기, 메일함에서 스와이프로 메일 삭제하기 등을 떠올릴 수 있다.
최근 참여한 프로젝트 스타카토(Github, PlayStore)에서, 사용자가 기록에 첨부한 사진들의 순서를 바꿀 수 있어야 한다는 요구사항이 추가됐다.
이번 포스트에서는 그 과정에서 시행착오를 통해 알게된 점을 기록해보려 한다.
+) 참고로 필자는 ListAdapter를 사용했다.
💁🏻♀️ 디자이너 빙티
개발자 빙티님! 드래그로 순서를 바꿀 수 있는 GridLayout의 RecyclerView를 구현해 주세요~ (디자인도 내가 해서 자아가 두 개인 것을 양해 바란다.)
기능 요구 사항
- 드래그로 사진 순서를 바꿀 수 있다.
사진 첨부 버튼
은 첫번째에 위치하고, 드래그할 수 없다.- 스크롤 제스처와 구분하기 위해 아이템을 길게 눌렀을 때만 드래그 가능하다.
- 드래그 중인 아이템은 흐리게 표시한다.
이제 위 기능을 구현하기 위해 어떤 클래스들이 필요한지 알아보자.
This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.
이름에서부터 느껴지듯, ItemTouchHelper는 RecyclerView에 스와이프, 드래그 등의 터치 동작을 지원하기 위한 핵심 유틸 클래스다.
이 친구를 사용하기 위해선 두 가지 준비물이 필요하다.
RecyclerView
: 동작을 적용할 대상 클래스Callback
: 터치 동작을 정의하는 추상 클래스로, ItemTouchHelper와 앱의 연결점 역할을 한다.Callback
를 상속하는 새로운 MyCallback
클래스를 만들고 원하는 동작을 정의한 다음, ItemTouchHelper
로 감싸 attachToRecyclerView()
메서드로 RecyclerView
와 연결해 줄 수 있다.
// Activity
private fun initItemTouchHelper() {
itemTouchHelper = ItemTouchHelper(MyCallback(photoAttachAdapter))
itemTouchHelper.attachToRecyclerView(binding.rvPhotoAttach)
}
기본적인 RecyclerView
는 이미 구현되어 있다는 전제 하에, Callback
클래스에 대해 더 자세히 알아보자.
This class is the contract between
ItemTouchHelper
and your application. It lets you control which touch behaviors are enabled per eachViewHolder
and also receive callbacks when user performs these actions. - 공식문서
공식 문서에 따르면, ItemTouchHelper.Callback
는 사용자의 제스처에 반응하는 콜백 메소드가 선언되어 있는 추상 클래스다.
ItemTouchHelper.Callback
를 상속할 때는 아래 세 메서드를 반드시 override 해야 한다.
getMovementFlags()
onMove()
onSwiped()
사용자가 RecyclerView의 아이템에 터치 동작을 시작할 때마다 호출된다.
상태(idle, swiping, dragging)별로 이동 가능한 방향을 정의해 터치 동작을 활성화 할 수 있다.
Int 타입인 상태와 방향을 makeFlag()
나 makeMovementFlags()
에서 조합한 뒤 return 해주면 된다.
상태의 종류는 3가지가 있다.
ACTION_STATE_IDLE
(0) : 드래그나 스와이프 동작이 끝났을 때ACTION_STATE_SWIPE
(1) : 아이템의 스와이프가 시작될 때ACTION_STATE_DRAG
(2) : 아이템의 드래그가 시작될 때방향의 종류는 6가지인데, START와 END는 다국어(RTL)를 지원하는 앱의 좌우 레이아웃 방향에 따라 자동으로 LEFT 또는 RIGHT로 해석된다.
UP
(1) : 위로 드래그/스와이프DOWN
(2) : 아래로 드래그/스와이프LEFT
(4) : 왼쪽으로 스와이프RIGHT
(8) : 오른쪽으로 스와이프START
(16) : 레이아웃 방향 기준 시작 (LTR: 왼쪽, RTL: 오른쪽)END
(32) : 레이아웃 방향 기준 끝 (LTR: 오른쪽, RTL: 왼쪽)getMovementFlags()
의 파라미터로 ViewHolder가 전달되므로, 뷰타입이 여러 개일 때 ViewHolder마다 터치 동작을 다르게 설정할 수 있다.
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int {
return if (viewHolder is PhotoAttachViewHolder.AttachedPhotoViewHolder) { // 사진 뷰홀더인 경우
makeFlag(
ItemTouchHelper.ACTION_STATE_DRAG,
ItemTouchHelper.DOWN or ItemTouchHelper.UP or ItemTouchHelper.START or ItemTouchHelper.END,
)
} else { // 버튼 뷰홀더인 경우
ItemTouchHelper.ACTION_STATE_IDLE // 0
}
}
요구사항을 만족하기 위해 사진 뷰홀더인 경우 makeFlag()
로 상하좌우 드래그가 가능하도록 설정했다.
버튼 뷰홀더인 경우 ACTION_STATE_IDLE
를 리턴해 드래그를 비활성화 했다.
드래그한 아이템이 다른 아이템과 겹칠 때마다 호출된다.
따라서 onMove는 Adapter에게 아이템의 위치 변경을 알리기에 적합하다.
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean { // ... }
반환값은 위치 변경 여부를 의미하며, true를 반환할 경우 onMoved()
라는 또 다른 콜백 메서드가 호출된다.
파라미터를 살펴보면 ViewHolder 타입이 두 개인 것을 볼 수 있다.
위 함수에서는 두 아이템의 순서를 바꾸는 로직을 구현하기에 적합하다.
나는 아이템이 Adapter의 몇 번째에 있는지 index를 반환하는 ViewHolder.getAbsoluteAdapterPosition
를 사용해 구현했다.
viewHolder.getAbsoluteAdapterPosition
: 드래그 중인 아이템의 원래 위치target.getAbsoluteAdapterPosition
: 드래그 된 새로운 위치(만약 ConcatAdapter로 구현했다면 getAbsoluteAdapterPosition를 사용할 때 주의가 필요하다. 자세한 내용은 공식문서를 참고하자.)
아이템의 원래 위치와 바뀐 위치를 무사히 알아냈다면 Adapter에게 알려야 한다.
interface ItemMoveListener {
fun onItemMove(
from: Int,
to: Int,
)
fun onStopMove()
}
위와 같은 인터페이스를 정의하고 Adapter에서 인덱스를 받아 리스트를 수정하도록 구현했다.
// PhotoAttachAdapter.kt
override fun onItemMove(
from: Int,
to: Int,
) {
val movedItem = currentList[from]
submitList(
currentList.toMutableList().apply {
removeAt(from)
add(to, movedItem)
},
)
}
만약 ListAdapter가 아닌 RecyclerView.Adapter라면 submitList()
대신 notifyItemMoved()
등의 메서드를 이용하면 될 것이다.
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean {
return if (target is PhotoAttachViewHolder.AttachedPhotoViewHolder) {
moveListener.onItemMove(
viewHolder.absoluteAdapterPosition,
target.absoluteAdapterPosition,
)
true
} else {
false
}
}
완성된 onMove 메서드는 이런 모습이다.
만약 이동하려는 위치에 있는 아이템 target
이 사진 뷰홀더라면 위에서 구현한 moveListener.onItemMove()를 호출해 원래 위치와 새로운 위치를 Adapter에게 전달하고 true를 반환한다.
반면 '버튼'이라면 아무 것도 하지 않고 false만 반환했다.
ViewHolder의 동작 상태가 변경되는 시점에 호출된다.
따라서 각 상태에 따라 시각적 피드백이나 데이터 처리를 수행할 때 유용하다.
구체적인 호출 시점은 다음과 같다.
ACTION_STATE_DRAG
: 아이템의 드래그가 시작될 때ACTION_STATE_SWIPE
: 아이템의 스와이프가 시작될 때ACTION_STATE_IDLE
: 드래그나 스와이프 동작이 끝났을 때 override fun onSelectedChanged(
viewHolder: RecyclerView.ViewHolder?,
actionState: Int,
) {
when (actionState) {
ItemTouchHelper.ACTION_STATE_DRAG -> {
if (viewHolder is PhotoAttachViewHolder.AttachedPhotoViewHolder) {
viewHolder.startMoving()
}
}
ItemTouchHelper.ACTION_STATE_IDLE -> {
moveListener.onStopMove()
}
}
super.onSelectedChanged(viewHolder, actionState)
}
드래그가 시작되는 ACTION_STATE_DRAG
상태에서는 사진을 흐리게 보여주기 위해 아래처럼 정의한 viewHolder.startMoving()를 호출해주었다.
// Viewholder
fun startMoving() {
binding.ivAttachedPhoto.alpha = 0.6F
}
드래그가 완료된 ACTION_STATE_IDLE
상태에서는 순서가 변경된 새 리스트를 뷰모델에게 전달해주기 위해 ItemDragListener 인터페이스를 만들고,
fun interface ItemDragListener {
fun onStopDrag(list: List<PhotoUiModel>)
}
Adapter의 생성자 프로퍼티에 추가했다.
class PhotoAttachAdapter(
private val attachedPhotoHandler: AttachedPhotoHandler,
private val dragListener: ItemDragListener,
) : ItemMoveListener
이제 Activity에서 드래그 완료 후 순서가 변경된 새 리스트를 뷰모델에 전달
하는 부분을 구현하고 Adapter의 생성자를 통해 주입할 수 있게 되었다.
(Functional (SAM) interfaces 참고)
// 어댑터 초기화 시
PhotoAttachAdapter(viewModel) { newList ->
viewModel.updatePhotosWithNewOrder(newList) // 새로운 리스트 전달
}
마지막으로 Adapter에서 onStopMove()를 적절히 구현해주면 완성!
class PhotoAttachAdapter(
private val attachedPhotoHandler: AttachedPhotoHandler,
private val dragListener: ItemDragListener,
) : ItemMoveListener {
// Adapter의 기타 메서드 생략
override fun onStopDrag() {
dragListener.onStopDrag(currentList.filterNot { it == photoAdditionButton })
}
}
clearView는 사용자의 터치 동작이 완전히 끝났을 때 호출되는 콜백 메서드이다.
onSelectedChanged()
나 onChildDraw()
에서 적용한 시각 효과를 초기화하기에 적합하다.
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
) {
if (viewHolder is PhotoAttachViewHolder.AttachedPhotoViewHolder) {
viewHolder.stopMoving()
}
super.clearView(recyclerView, viewHolder)
}
viewHolder.stopMoving()를 호출해, 드래그하는 동안 투명하게 했던 효과를 초기화 해주었다.
onChildDraw()
는 ItemTouchHelper가 RecyclerView의 onDraw()
콜백에서 호출하는 메서드로, 사용자 이벤트에 따라 View가 반응하는 방식을 커스터마이징 할 때 사용한다.
(언제 호출되나 궁금해서 로그를 찍어보니, 아이템을 드래그하는 내내 연속적으로 로그가 찍혔다.)
기본 구현은 주어진 dX, dY만큼 자식 뷰를 이동시키며 드래그 중인 항목이 다른 자식 뷰들보다 나중에 그려지도록 처리한다.
이번 기능에서 사용하지는 않았지만, 나중에 더 복잡한 애니메이션을 커스텀한다면 유용할 것 같아 미리 써놓는다.
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean,
)
드래드 중인 사진이 정지한 사진의 아래로 깔려보이는 버그가 존재했다.
clearView()에서 누락된 super.clearView(recyclerView, viewHolder) 메서드 추가
우선, 드래그 되는 아이템과 정지한 아이템들의 elevation 문제라고 생각해 onMove에서 viewHolder와 target의 elevation을 로그로 확인했다.
드래그 중인 아이템의 높이가 밀려나는 아이템의 높이보다 낮게 설정이 되어있었다!
ㅌㅅㅌ 드래그 됨 D viewHolder elevation / 3.0
ㅌㅅㅌ 밀려남 D target elevation / 8.0
ㅌㅅㅌ 드래그 됨 D viewHolder elevation / 4.0
ㅌㅅㅌ 밀려남 D target elevation / 8.0
ItemTouchUIUtilImpl
의 내부 구현을 살펴보면, 드래그 시 onDraw()
에서 ViewCompat.setElevation()
으로 elevation 값을 증가시킨다. 이후 드래그 종료 시 onClear()
에서 elevation을 원래 값으로 되돌린다.
위 로직은 Android API 21(Lollipop) 이상에서 동작하며 자세한 코드는 아래와 같다.
@Override
public void onDraw(
@NonNull Canvas c,
@NonNull RecyclerView recyclerView,
@NonNull View view,
float dX,
float dY,
int actionState,
boolean isCurrentlyActive
) {
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
// 높이를 조절하기 전, 원래 elevation을 저장
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
// 정지한 아이템들의 elevetion 최댓값보다 1 크게 설정
float newElevation = 1f + findMaxElevation(recyclerView, view);
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}
view.setTranslationX(dX);
view.setTranslationY(dY);
}
@Override
public void clearView(@NonNull View view) {
if (Build.VERSION.SDK_INT >= 21) {
// 저장해 둔 드래그 이전의 elevation을 다시 불러옴
final Object tag = view.getTag(R.id.item_touch_helper_previous_elevation);
if (tag instanceof Float) {
// 원래 elevation으로 복구
ViewCompat.setElevation(view, (Float) tag);
}
view.setTag(R.id.item_touch_helper_previous_elevation, null);
}
view.setTranslationX(0f);
view.setTranslationY(0f);
}
로그에 따르면 elevation 증가는 적용 중이지만, 이후 원래 값으로 되돌리지 않고 elevation가 증가된 채로 남아있는 것을 볼 수 있었다.
코드를 살펴보니, clearView()
를 오버라이딩해놓고 super.clearView(recyclerView, viewHolder)
를 호출하지 않아 발생한 문제였다.
들인 시간에 비해 해결책은 매우 간단했지만, 덕분에 ItemTouchHelper의 내부 구현을 뜯어보고 여러 애니메이션을 지원하고 있다는 사실을 알게 되었다! 😈
이렇게 드래그로 아이템 순서를 바꿀 수 있는 RecyclerView를 구현해보았다.
저기에 코루틴을 통한 비동기 네트워크 요청 및 재시도 로직, 아이템 삭제 및 추가 로직까지 추가하는 바람에 골머리를 앓았지만 ...
ItemTouchHelper를 적용하는 과정만 떼어놓고 보니 생각보다 간?단?한 것 같다.
이 기능을 좀 더 개선하려면, 위에서 소개한 onChildDraw()
를 이용해 드래그 중인 아이템이 RecyclerView 영역 밖으로 벗어날 수 없게 제한해보는 것도 좋을 것 같다!
일단 지금은 여기서 끝.