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)
}
}
이제 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
}
}
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
}
}
이제 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
}
}
}
}
이렇게 ItemTouchHelper
를 이용하여 Swipe Menu를 만들어보았습니다.
전체 소스 코드는 Github에서 확인 가능합니다.
https://developer.android.com/reference/androidx/recyclerview/widget/ItemTouchHelper
https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28