서론
저번 회고 글에서 말했듯 블로그 챌린지를 시작했다. 이번 회차엔 모두 같은 주제로 글을 쓴다.
그 주제는 바로 리사이클러뷰다.
컴포즈가 만연해진 시대에 (컴포즈를 사용하지 않는 역사가 긴 앱을 제외하면) 리사이클러뷰는 이제 잘 사용되지 않는다.
컴포즈로 안드로이드 개발을 처음 시작하는 사람이 늘어나면서 리사이클러뷰를 모르는 경우도 더러 있다고 들었다. 사실 컴포즈만 사용한다면 굳이 알 필요는 없다.
그러나 만약 당신이 들어간 회사가 여전히 xml을 사용하고 있다면, 리사이클러뷰 사용을 피할 수 없을 것이다. 그만큼 리사이클러뷰는 앱을 만들 때 많이 사용되는 컴포넌트이다. (컴포즈 사용자라면 생각해 보자, LazyRow, Column, Grid 없이 앱을 만들 수 있겠는가?)
리사이클러뷰는 역사가 긴 만큼 자료도 많다. 그렇기에 리사이클러뷰를 주제로 어떤 글을 써야 할지 고민이 컸다. “이미 내가 적을 글보다 좋은 글들이 많은데”라는 생각 때문이었다.
그런 의미에서 리사이클뷰가 처음이라면 아래 유튜브를, 깊게 알고 싶다면 미디엄을 참고해라.
(절대 유튜브 조회수를 늘리려는 심산이 아닙니다)
https://www.youtube.com/watch?v=Q4RDlEaxRjg
https://medium.com/hongbeomi-dev/recyclerview-deep-dive-with-google-i-o-2016-21e0895819d2
RecyclerView Deep Dive with Google I/O 2016
RecyclerView를 RecyclerView ins and Outs — Google I/O 영상을 보며 내부 동작을 살펴봅니다.
medium.com
다시 돌아와서, 그래서 나의 주제는 무엇인가.
결국 남들이 쓰지 않은 글을 쓸 수는 없다는 결론이 났고 생각을 고쳐먹기로 했다.
그냥 내가 해보지 않은 걸 하기로 했다. 뭐 어차피 내가 공부하는 것에 의미가 있으니까!
정말 놀랍게도, 그렇게 리사이클러뷰를 많이 사용해 오면서 드래그&드랍, 스와이프 기능을 구현해 본 적이 없었다.
이 사실을 깨닫고 약간은 부끄러울 지경이었다. 어떻게 그 오랜(?) 시간 동안 안드로이드 개발을 하면서 저걸 한 번도 안 해봤지? (심지어 GPT가 추천하기 전까진 떠올리지도 못했다)
드래그&드랍과 스와이프는 많은 곳에서 사용된다.
투두메이트에서 투두를 옮기거나, 음악 어플에서 플레이리스트의 곡 순서를 편집할 때 드래그&드랍이 사용되고
최근 전화 기록에서 오른쪽으로 스와이프하면 전화 걸기, 왼쪽으로 스와이프하면 메시지 보내기인 것처럼 말이다.
그럼 이제부터 어떻게 구현할지 알아보도록 하자.
ItemTouchHelper
찾아보기 전에 드래그&드랍과 스와이프를 처음부터 내가 구현해야 한다고 생각했을 땐 상당히 까다롭겠다고 생각했다.
하지만 이를 위한 클래스를 이미 안드로이드에서 만들어놨다.
안드로이드에서는 드래그&드랍과 스와이프를 구현하기 위한 클래스인 ItemTouchHelper를 제공한다.
This is a utility class to add swipe to dismiss and drag&drop support to RecyclerView
한 문장으로 설명이 끝났다.
드래그&드랍과 스와이프만을 위한 친구다. 이제 이 친구에 대해 알아보자.
먼저 공식문서 설명을 읽어보자.
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.
Depending on which functionality you support, you should override onMove and / or onSwiped.
This class is designed to work with any LayoutManager but for certain situations, it can be optimized for your custom LayoutManager by extending methods in the ItemTouchHelper.Callback class or implementing ItemTouchHelper.ViewDropHandler interface in your LayoutManager.
By default, ItemTouchHelper moves the items' translateX/Y properties to reposition them. You can customize these behaviors by overriding onChildDraw or onChildDrawOver.
Most of the time you only need to override onChildDraw.
이 유틸리티 클래스는 RecyclerView에 스와이프하여 지우기 및 드래그 앤 드롭을 지원하는 유틸리티 클래스입니다.
이 클래스는 어떤 유형의 상호 작용이 활성화되는지 구성하고 사용자가 이러한 작업을 수행할 때 이벤트를 수신하는 Callback 클래스와 함께 작동합니다.
지원하는 기능에 따라 onMove 및/또는 onSwiped를 재정의해야 합니다.
이 클래스는 모든 LayoutManager와 함께 작동하도록 설계되었지만 특정 상황에서는 ItemTouchHelper.Callback 클래스에서 메서드를 확장하거나 LayoutManager에서 ItemTouchHelper.ViewDropHandler 인터페이스를 구현하여 커스텀 LayoutManager에 맞게 최적화할 수 있습니다.
기본적으로 ItemTouchHelper는 항목의 translateX/Y 속성을 이동하여 위치를 변경합니다. onChildDraw 또는 onChildDrawOver를 오버라이드하여 이러한 동작을 사용자 정의할 수 있습니다. 대부분의 경우 onChildDraw만 오버라이드하면 됩니다.
요약하자면 스와이프와 드래그&드랍을 지원하며, 이를 위해서 onSwiped나 onMove를 구현해야 한다.
ItemTouchHelper가 뭐하는 녀석인지 알았으니 이제 구현을 해보자.
Drag&Drop
드래그&드랍을 구현하기 위해선 먼저 리사이클러뷰를 하나 만들어야 한다.
아이템으로 쓰일 레이아웃을 아래와 같이 만들어줬다. (햄버거 버튼은 아이콘 추출하기가 귀찮아서 뷰로 만들었다)
그리고 어댑터도 다음과 같이 만들어줬다. (뷰홀더 코드는 생략했다)
드래그&드랍을 구현하려면 아이템의 위치를 변경해야 하므로 swap 함수를 구현했다.
class TodoAdapter : RecyclerView.Adapter<TodoAdapter.TodoViewHolder>() {
private val items = mutableListOf<String>()
override fun getItemCount(): Int = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder =
TodoViewHolder.newInstance(parent)
override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
holder.bind(items[position])
}
fun swap(from: Int, to: Int) {
val temp = items[from]
items[from] = items[to]
items[to] = temp
notifyItemMoved(from, to)
}
fun initItems(todos: List<String>) {
items.clear()
items.addAll(todos)
notifyDataSetChanged()
}
}
그럼 이제 준비가 끝났으니 ItemTouchHelper를 사용해 보자.
ItemTouchHelper의 생성자이다. 보다시피 Callback이 필요하다.
Callback은 앞선 공식문서에서도 언급되었다. 기억이 나지 않으면 글을 올려 다시 보고 오도록 하자.
주석을 읽어보면 ItemTouchHelper를 리사이클러뷰에 연결할 때는 attachToRecyclerView 함수를 사용하면 되는 모양이다.
연결하는 방법도 알았으니 만들기만 하면 된다. 이제 우리에게 필요한 것은 Callback이다.
다음은 Callback 클래스의 선언부다.
This class is the contract between ItemTouchHelper and your application.
즉 우리는 Callback 클래스를 통해서 ItemTouchHelper를 사용한다는 뜻이다.
그럼 우선 여기까지만 알고 자세한 내용은 넘어가도록 하자.
위 사진의 왼쪽 아이콘을 보면 누군가 Callback 클래스를 상속하여 확장하고 있다는 것을 알 수 있다.
아이콘을 눌러보면 다음 클래스로 이동한다.
읽어보면 다음과 같이 정리할 수 있다.
- 기본 Callback을 간단히 래핑한 클래스이다.
- 드래그 및 스와이프 방향을 생성자에서 지정할 수 있으며, 내부적으로 플래그 콜백을 처리한다.
- onMove와 onSwipe를 오버라이드 해야 한다. (필요하다면)
- onMoved : 드래그 시 호출되며, 아이템을 어댑터에서 fromPos에서 toPos로 이동시키고 true/false를 반환한다.
- onSwiped : 스와이프 동작 완료 시 호출되며, 어댑터에서 해당 아이템을 제거하거나 적절한 액션을 수행한다.
대충 Callback을 더 간편하게 사용할 수 있도록 구현된 클래스인 것 같다. 그럼 SimpleCallback을 사용하여 간단히 드래그 앤 드롭을 구현해 보자.
SimpleCallback의 생성자는 다음과 같다.
파라미터로는 dragDirs와 swipeDirs가 있다.
- dragDirs: 뷰가 드래그될 수 있는 방향 플래그들의 이진 OR 값. LEFT, RIGHT, START, END, UP, DOWN 중에서 조합해야 한다.
- swipeDirs: 뷰가 스와이프 될 수 있는 방향 플래그들의 이진 OR 값. LEFT, RIGHT, START, END, UP, DOWN 중에서 조합해야 한다.
좋다. 이제 필요한 정보가 다 모였으니 SimpleCallback을 확장하는 우리만의 Callback을 만들어보자.
SimpleCallback을 상속하고 생성자 매개변수로 드래그 방향과 스와이프 방향을 넘긴다.
class TodoSimpleCallback() : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0,
)
우선 나는 상하로 움직이는 드래그를 구현하고 싶고 스와이프는 아직 구현할 마음이 없다.
그래서 dragDirs에는 위와 아래에 해당하는 상수를 조합하여 전달했고, swipeDirs에는 아무 숫자나 전달했다.
(어차피 onSwiped를 구현하지 않을 것이므로)
그럼 onMove를 구현해 보자.
class TodoSimpleCallback(
private val swap: (from: Int, to: Int) -> Unit
) : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
swap(
??,
??,
)
return true
}
}
swap 함수의 from과 to에 알맞는 위치 값을 넘겨야 한다.
viewHolder와 target, 즉 RecyclerView.ViewHolder 인스턴스에 점(.)을 찍고 position을 쳐보면
다음과 같은 프로퍼티들을 볼 수 있다.
이 중에 무엇을 사용해야 할까?
각각이 무엇을 뜻하는지는 몰라도 무엇을 사용해야 하는지는 알 수 있다. 아까 봤던 Callback 클래스의 선언부로 돌아가보자.
설명에 adapterPosition을 사용해야 한다고 친절하게 적혀있다.
(일반적으로 리사이클러뷰에서 데이터 처리를 위해 위치를 가져올 때는 adapterPosition을 사용한다. ConcatAdapter를 사용할 때는 상황에 따라 bindingAdapterPosition과 adapterPosition 중 상황에 맞게 판단하여 사용한다)
우선 이번 주제와 관련이 없으므로 position에 대한 자세한 내용은 넘어가겠다.
다음은 TodoSimpleCallback의 완성된 전체 코드이다.
class TodoSimpleCallback(
private val swap: (from: Int, to: Int) -> Unit
) : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
swap(
viewHolder.adapterPosition,
target.adapterPosition,
)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
TODO("Not yet implemented")
}
}
이제 TodoSimpleCallback을 리사이클러뷰에 연결해 보자.
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var adapter: TodoAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
...
initTodoAdapter()
}
private fun initTodoAdapter() {
binding.rvMain.adapter = TodoAdapter().also { adapter = it }
ItemTouchHelper(TodoSimpleCallback(adapter::swap)).attachToRecyclerView(binding.rvMain)
adapter.initItems(
listOf(
"잊혀지는 건",
"당연하단 걸",
"알면서도",
"나의 마음속",
"어딘가 저리죠",
)
)
}
}
TodoSimpleCallback으로 ItemTouchHelper를 생성하고 리사이클러뷰에 붙였다.
그리고 더미 데이터로 내가 듣고 있던 데이먼스이어의 salty의 가사를 넣어봤다.
다음은 위 코드들을 실행한 결과물이다.
잘 구현된 것을 볼 수 있다. 아이템을 길게 누르고 드래그하면 위치가 이동한다.
내부 구현
그럼 ItemTouchHelper는 어떻게 이것을 가능하게 하는 걸까? 궁금하니 내부를 살짝 들여다보도록 하자.
내부를 보려면 어디서부터 봐야 할까? 현재 우리가 사용한(작성한) ItemTouchHelper와 관련된 함수는 두 개다.
override한 onMove와 연결할 때 사용한 attachToRecyclerView 함수이다.
드래그하여 움직이는 것과는 onMove가 더 관련이 있어 보인다. 그래서 ItemTouchHelper에서 onMove가 호출되는 지점을 찾아보았다.
onMove는 moveIfNecessary함수에서 호출되고 있었다.
모든 코드를 알아볼 수는 없지만 드래그 시작 위치와 종료 위치를 파악해 onMove를 호출하는 듯하다.
이름도 직관적이기에 이해하기 쉽다. 그럼 moveIfNecessary는 어디서 호출되는지 보자.
moveIfNecessary는 두 군데에서 불리고 있었다.
하나는 Runnable 타입인 변수 mScrollRunnable의 run 구현부에서,
하나는 OnItemTouchListener 타입인 변수 mOnItemTouchListener의 onTouchEvent 구현부에서 호출됐다.
이해가 되지 않을까 봐 캡처본과 코드를 첨부한다.
(mOnItemTouchListener의 경우 코드가 너무 길어 캡처할 수 없어 생략된 코드로 대체했다)
private final OnItemTouchListener mOnItemTouchListener = new OnItemTouchListener() {
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) {
...
}
@Override
public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) {
... // 이곳에서 사용됨
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
...
}
}
우선 mScrollRunnable 변수부터 보자. 주석만 읽어봐도 뭐 하는 녀석인지 알 수 있다.
When user drags a view to the edge, we start scrolling the LayoutManager as long as View is partially out of bounds.
즉 우리가 드래그할 때, 해당 요소가 화면 끝에 걸쳐있으면 화면 스크롤을 해주기 위한 녀석이다.
코드를 봐도 얼추 이해된다. (드래그하기 위해) 선택된 요소가 있고 스크롤이 필수적이면 moveIfNecessary를 호출한다.
자 그럼 mOnItemTouchListener변수를 보자. OnItemTouchListener는 무엇일까?
OnItemTouchListener에 대고 cmd + B를 눌러보면 다음과 같은 화면을 볼 수 있다.
지금까지는 계속 ItemTouchHelper 클래스를 보고 있었는데 처음으로 다른 클래스로 이동했다.
(OnItemTouchListener 인터페이스는 RecyclerView 파일에 있습니다)
그럼 궁금하니 설명을 읽어보자.
OnItemTouchListener는 RecyclerView가 자체 스크롤 동작을 고려하기 전에, 진행 중인 터치 이벤트를 뷰 계층 수준에서 애플리케이션이 가로챌 수 있게 해준다.
이는 RecyclerView 내의 아이템 뷰에 대해 다양한 형태의 제스처 조작을 구현하고자 하는 애플리케이션에 유용할 수 있다. OnItemTouchListener는 이미 RecyclerView가 해당 제스처 스트림을 스크롤 목적으로 처리하고 있더라도, 진행 중인 터치 상호작용을 가로채어 사용할 수 있다.
정리하면 터치 이벤트가 리사이클러뷰까지 도달하여 화면을 스크롤하기 이전에 가로채서 활용할 수 있게 한다는 뜻이다.
듣고 보니 드래그 앤 드롭에 필수적인 녀석이다.
드래그 앤 드롭은 리사이클러뷰 스크롤과 같은 동작으로 터치가 일어나지만 스크롤 대신 드래그가 일어나기 때문이다.
그럼 인터페이스의 메서드에는 무엇이 있는지 확인해 보자.
정리하자면 다음과 같다.
- onInterceptTouchEvent
- 리사이클러뷰나 자식 뷰들이 해당 터치 이벤트를 처리하기 전에, 이 이벤트를 가로채야 할지 결정하는 메서드
- true 반환 → 이 Listener가 해당 터치 이벤트를 직접 처리하겠다고 선언. 즉, 앞으로 발생할 MOVE/UP 등 후속 이벤트도 이 Listener에게 우선 전달된다.
- false 반환 → 현재 동작을 유지하되, 관찰(Observe)은 계속 가능하다. RecyclerView나 다른 OnItemTouchListener, 자식 뷰 등이 계속 이벤트를 받을 수 있다.
- onTouchEvent
- 이전에 onInterceptTouchEvent에서 true를 반환하여 제스처를 가로채기로 선언한 후 발생하는 터치 이벤트를 처리한다.
- onRequestDisallowInterceptTouchEvent
- RecyclerView의 자식 뷰가, onInterceptTouchEvent를 통해 RecyclerView 및 상위 뷰들이 터치 이벤트를 가로채지 않길 원하는 경우에 호출된다.
OnItemTouchListener가 어떤 역할을 하는지 알았으니 이제 그 구현체를 살펴볼 차례다.
그러나 점점 들어가다 보니 내용도 너무 길어지고 내 머리도 복잡해져서 여기서 정리하고 끊어내보려고 한다.
정리하자면 우선 onInterceptTouchEvent에서 true를 반환하여 터치 이벤트를 가로챈다. 또한 mGestureDetector.onTouchEvent를 호출해 longPress를 인지한다.
그다음 이벤트부터 onTouchEvent로 이벤트가 들어오고 ACTION_MOVE 이벤트를 처리하여 드래그를 구현한다. 그 과정에서 아까 봤던 moveIfNecessary가 사용되면서 onMove가 호출되는 것이다. (ACTION_UP 이벤트를 처리하여 드래그를 종료(드랍)를 구현한다)
이렇게 ItemTouchHelper는 드래그&드랍을 구현해 낸다.
원래 이번 글은 지금까지 적은 드래그&드랍 기본을 포함하여 심화와 스와이프까지 작성할 예정이었지만
글을 적다 보니 글이 너무 길어져, 이번 글을 1편으로 칭하고 여기서 끝내보려고 한다.
끝!
오류지적은 언제나 환영합니다.
'학습' 카테고리의 다른 글
Jetpack Compose의 State와 MutableState 2편 (부제: mutableStateOf의 내부 구현은?) (0) | 2025.02.14 |
---|---|
Jetpack Compose의 State와 MutableState 1편 (부제: RecomposeScope) (0) | 2025.01.26 |