.gif)
RecyclerView의 ItemTouchHelper를 이용하여 Swipe Menu를 만들어보자
우선 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>
RecyclerView의 Adapter 구현
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()
}
}
}
}
해당 Adapter를 RecyclerView의 연결시켜준다.
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는 공식 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 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는 이동 방향을 결정하고
onMove와 onSwiped는 각각 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)
}
}
.gif)
이제 View 전체가 swipe 되는게 아닌 item_swipe.xml의 swipe_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
}
}
.gif)
ItemTouchHelper는 일정 범위 밖이나 일정 속도 이상으로 swipe가 될 시 View 밖으로 escape 되도록 구현되어있는데 getSwipeEscapeVelocity와 getSwipeThreshold를 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
}
}
.gif)
이제 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
}
}
}
}
.gif)
이렇게 ItemTouchHelper를 이용하여 Swipe Menu를 만들어보았습니다.
전체 소스 코드는 Github에서 확인 가능합니다.
https://developer.android.com/reference/androidx/recyclerview/widget/ItemTouchHelper
https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28