가장 위에 첨부해놓은 사진처럼, RecyclerView의 아이템을 오른쪽으로 밀면 삭제 버튼이 나타나도록, 왼쪽으로 밀면 수정 버튼이 나타나도록 했다. 위 사진처럼 구현하기 위해 필요한 레이아웃 파일과 클래스들을 차례로 나열해보도록 할테니, 참고해서 원하는 화면을 구현해보도록 하자 😀.
fragment_memo.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"
android:background="@color/classicBlue"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/todoListView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:ignore="MissingConstraints" />
</androidx.constraintlayout.widget.ConstraintLayout>
memo_item.xml
RecyclerView의 아이템에 대한 레이아웃 파일이다. 참고로 나는 Databinding을 사용해 TextView에 바인딩되어야 하는 문자열들을 어댑터의 ViewHolder에서 한번에 바인딩 할 것이지만, Databinding을 사용하지 않고자 하는 사람들은 그냥 일반적인 방법으로 구현하면 된다.
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="memo"
type="com.example.Memo" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/view1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/classicBlue"
android:layout_marginTop="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_margin="10dp"
tools:ignore="MissingConstraints">
<TextView
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:text="@{memo.content}"
android:textSize="15sp"
android:textColor="@color/white"
android:textStyle="bold" />
<CheckBox
android:id="@+id/completionBox"
android:layout_width="30dp"
android:layout_height="30dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/content"
app:layout_constraintBottom_toBottomOf="@id/content"
android:buttonTint="@color/white"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Memo.kt
RecyclerView에 표시될 데이터들에 대한 데이터 클래스다.
data class MemoDataModel(
val content: String,
val completion: Boolean
)
MemoAdapter.kt
RecyclerView와 데이터들을 연결해줄 어댑터 클래스다. onBindViewHolder() 내에서 memo_item.xml의 TextView에 데이터 클래스 Memo 객체의 아이템(val content: String)이 바인딩된다.
class MemoAdapter (
private val context: Context,
private val viewModel: ViewModel
) : RecyclerView.Adapter<MemoAdapter.Holder>() {
var list = ArrayList<Memo>()
private lateinit var binding: MemoItemBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val inflater = LayoutInflater.from(context)
binding = MemoItemBinding.inflate(inflater, parent, false)
return Holder(binding.root)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.onBind(list[position])
}
override fun getItemCount(): Int = list.size
inner class Holder(val view: View) : RecyclerView.ViewHolder(view) {
fun onBind(item: Memo) {
binding.memo = item
}
}
ItemTouchHelperListener.kt
후에 어댑터 클래스가 implements 해야 할 인터페이스다. 어댑터 클래스에서 이 인터페이스를 implements 한 후, 원하는 기능들을 각 함수 내부에서 자유롭게 구현할 수 있다. 나는 좌우 스와이프에 대한 기능을 구현할 예정이기에 onLeftClick과 onRightClick을 활용해야 한다.
interface ItemTouchHelperListener {
fun onItemMove(from_position: Int, to_position: Int): Boolean
fun onItemSwipe(position: Int)
fun onLeftClick(position: Int, viewHolder: RecyclerView.ViewHolder?)
fun onRightClick(position: Int, viewHolder: RecyclerView.ViewHolder?)
}
SwipeController.kt
RecyclerView의 각각의 아이템을 사용자의 터치에 따라 특정 방향으로 스와이프시키고, 스와이프 후에 나타날 각각의 버튼들을 동적으로 구현하는 클래스다. 코드 양이 매우 많고 나 역시 모든 코드를 제대로 이해한 건 아니라서 🤣 이해한 만큼 최대한 주석에 설명을 달아놓았다.
class SwipeController() : ItemTouchHelper.Callback() {
private var swipeBack = false
private val buttonWidth = 200f //버튼 너비 지정
private var buttonsShowedState = ButtonState.GONE
private var buttonInstance: RectF? = null //버튼 객체 초기 지정
private lateinit var listener: ItemTouchHelperListener
private var currentItemViewHolder: RecyclerView.ViewHolder? = null
constructor(listener: ItemTouchHelperListener) : this() {
this.listener = listener
}
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val draw_flags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
val swipe_flags = ItemTouchHelper.START or ItemTouchHelper.END
return makeMovementFlags(draw_flags, swipe_flags)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return listener.onItemMove(viewHolder.adapterPosition,target.adapterPosition)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
listener.onItemSwipe(viewHolder.adapterPosition);
}
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
var dX = dX
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
if (buttonsShowedState !== ButtonState.GONE) {
if (buttonsShowedState === ButtonState.LEFT_VISIBLE) //오른쪽으로 스와이프 했을 때
dX = dX.coerceAtLeast(buttonWidth)
if (buttonsShowedState === ButtonState.RIGHT_VISIBLE) //왼쪽으로 스와이프 했을 때
dX = dX.coerceAtMost(-buttonWidth)
super.onChildDraw(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
} else {
setTouchListener(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
}
if (buttonsShowedState === ButtonState.GONE) {
super.onChildDraw(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
}
}
currentItemViewHolder = viewHolder
drawButtons(c, currentItemViewHolder!!)
}
private fun drawButtons(
c: Canvas,
viewHolder: RecyclerView.ViewHolder
) { //레이아웃이 아닌 클래스에서 직접 버튼 구현
val buttonWidthWithOutPadding = buttonWidth - 10
val corners = 5f
val itemView: View = viewHolder.itemView
val p = Paint() //Paint 객체 p 생성
buttonInstance = null
//rectF 클래스로 버튼 형태 구현
if (buttonsShowedState === ButtonState.LEFT_VISIBLE) {
val leftButton = RectF(
(itemView.left + 10).toFloat(),
(itemView.top + 10).toFloat(),
itemView.left + buttonWidthWithOutPadding,
(itemView.bottom - 10).toFloat()
) //rectF : top, bottom, left, right 에 대한4가지 정보를 가지고 있는 직사각형 클래스 (rect : int, rectF : float)
p.color = Color.parseColor("원하는 버튼 색상의 hex code 입력") //Paint 객체 컬러 지정, setColor 메서드는 int형 인자를 매개변수로 받기 때문에 string 형태의 색상을 Color 클래스의 parseColor 메서드를 이용해 int형으로 바꿔줌
c.drawRoundRect(leftButton, corners, corners, p) //drawRoundRect : 원 그리기
drawText("버튼에 표시될 텍스트", c, leftButton, p)
buttonInstance = leftButton
} else if (buttonsShowedState === ButtonState.RIGHT_VISIBLE) { //왼쪽으로 스와이프 했을 때, 오른쪽 버튼(삭제)이 나와야 함
val rightButton = RectF(
itemView.right - buttonWidthWithOutPadding, (itemView.top + 10).toFloat(),
(itemView.right - 10).toFloat(), (itemView.bottom - 10).toFloat()
)
p.color = Color.parseColor("원하는 버튼 색상의 hex code 입력")
c.drawRoundRect(rightButton, corners, corners, p)
drawText("버튼에 표시될 텍스트", c, rightButton, p)
buttonInstance = rightButton
}
}
private fun drawText(text: String, c: Canvas, button: RectF, p: Paint) { //버튼 내에 글씨 삽입
val textSize = 45f
p.color = Color.WHITE
p.isAntiAlias = true
p.textSize = textSize
val textWidth = p.measureText(text) //measureText : 글자의 너비 리턴
c.drawText(
text,
button.centerX() - textWidth / 2,
button.centerY() + textSize / 2,
p
) //Canvas 객체의 drawText : 글자의 구체적인 속성 정의
}
override fun convertToAbsoluteDirection(flags: Int, layoutDirection: Int): Int {
if (swipeBack) {
swipeBack = false
return 0
}
return super.convertToAbsoluteDirection(flags, layoutDirection)
}
@SuppressLint("ClickableViewAccessibility")
private fun setTouchListener(
c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
) {
recyclerView.setOnTouchListener { view, event ->
swipeBack =
event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP
if (swipeBack) {
if (dX < -buttonWidth) buttonsShowedState =
ButtonState.RIGHT_VISIBLE else if (dX > buttonWidth) buttonsShowedState =
ButtonState.LEFT_VISIBLE
if (buttonsShowedState !== ButtonState.GONE) {
setTouchDownListener(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
setItemClickable(recyclerView, false)
}
}
false
}
}
@SuppressLint("ClickableViewAccessibility")
private fun setTouchDownListener(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
actionState: Int, isCurrentlyActive: Boolean
) {
recyclerView.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
setTouchUpListener(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
}
false
}
}
@SuppressLint("ClickableViewAccessibility")
private fun setTouchUpListener(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
actionState: Int, isCurrentlyActive: Boolean
) {
recyclerView.setOnTouchListener { v, event ->
super@SwipeController.onChildDraw(
c,
recyclerView,
viewHolder,
0f,
dY,
actionState,
isCurrentlyActive
)
recyclerView.setOnTouchListener { v, event -> false }
setItemClickable(recyclerView, true)
swipeBack = false
if (buttonInstance != null && buttonInstance!!.contains(
event.x,
event.y
)
) {
if (buttonsShowedState === ButtonState.LEFT_VISIBLE) {
listener.onLeftClick(viewHolder.adapterPosition, viewHolder)
} else if (buttonsShowedState === ButtonState.RIGHT_VISIBLE) {
listener.onRightClick(viewHolder.adapterPosition, viewHolder)
}
}
buttonsShowedState = ButtonState.GONE
currentItemViewHolder = null
false
}
}
private fun setItemClickable(recyclerView: RecyclerView, isClickable: Boolean) {
for (i in 0 until recyclerView.childCount) {
recyclerView.getChildAt(i).isClickable = isClickable
}
}
}
MemoAdapter.kt
이전에 만들었던 어댑터 클래스에서 ItemTouchHelperListener를 implements 한 후, 오버라이딩 된 onLeftClick과 onRightClick 내에서 버튼 클릭 시 원하는 동작을 코드로 작성한다.
class MemoAdapter (
private val context: Context,
private val viewModel: ViewModel
) : RecyclerView.Adapter<MemoAdapter.Holder>(), ItemTouchHelperListener {
var list = ArrayList<MemoDataModel>()
private lateinit var binding: MemoItemBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val inflater = LayoutInflater.from(context)
binding = MemoItemBinding.inflate(inflater, parent, false)
return Holder(binding.root)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.onBind(list[position])
}
override fun getItemCount(): Int = list.size
inner class Holder(val view: View) : RecyclerView.ViewHolder(view) {
fun onBind(item: MemoDataModel) {
binding.memo = item
}
}
override fun onLeftClick(position: Int, viewHolder: RecyclerView.ViewHolder?) {
Toast.makeText(context, "LeftClick", Toast.LENGTH_SHORT).show()
}
override fun onRightClick(position: Int, viewHolder: RecyclerView.ViewHolder?) {
Toast.makeText(context, "RightClick", Toast.LENGTH_SHORT).show()
}
override fun onItemMove(from_position: Int, to_position: Int): Boolean = false
override fun onItemSwipe(position: Int) { // }
}
MemoFragment.kt
RecyclerView가 나타나야 할 액티비티 또는 프래그먼트에서 생성한 ItemTouchHelper 객체에 RecyclerView를 부착하고, 데이터들이 저장된 어댑터 객체를 RecyclerView에 연결하면 완성이다.
class MemoFragment : Fragment(R.layout.fragment_memo) {
private val binding by viewBinding(FragmentMemoBinding::bind,
onViewDestroyed = { fragmentMemoBinding ->
fragmentMemoBinding.todoListView.adapter = null
})
private val viewModel : ViewModel by inject()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = MemoAdapter(requireContext(), viewModel)
val itemTouchHelper = ItemTouchHelper(SwipeController(adapter))
// itemTouchHelper에 RecyclerView 부착
itemTouchHelper.attachToRecyclerView(binding.todoListView)
// RecyclerView item에 출력할 모든 데이터 가져오기
viewModel.getAllMemo().observe(viewLifecycleOwner, Observer { list ->
list?.let {
adapter.list = it as ArrayList<MemoDataModel>
}
// RecyclerView에 어댑터 연결
binding.todoListView.adapter = adapter
binding.todoListView.layoutManager = LinearLayoutManager(requireContext())
})
}
companion object{
const val TAG = "MemoFragment"
}
}
https://everyshare.tistory.com/30
https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28