RecyclerView Android: hiểu về các thao tác vuốt và kéo

RecyclerView Android là một thành phần quan trọng trong phát triển ứng dụng, cho phép hiển thị danh sách dữ liệu dài một cách tối ưu. Nhưng ngoài việc chỉ hiển thị danh sách, bạn còn có thể mở rộng chức năng của nó bằng cách thêm thao tác vuốt và kéo thả trực quan, giúp người dùng tương tác với dữ liệu một cách dễ dàng hơn.

Trong bài viết này, chúng ta sẽ khám phá cách tích hợp các thao tác này vào RecyclerView Android mà không cần sử dụng các thư viện phức tạp.

RecyclerView Android: hiểu về các thao tác vuốt và kéo

Thao tác vuốt

Hãy bắt đầu với những điều cần thiết. Chúng ta sẽ cần chiều rộng và chiều cao của thiết bị, các biểu tượng, màu sắc và một danh sách cơ bản. Ngoài ra, chúng tôi đã tạo một hàm mở rộng mà chúng tôi sẽ sử dụng thường xuyên để chuyển đổi kích thước pixel thành pixel động (dp) để có khả năng thích ứng tốt hơn.

class MainActivity : AppCompatActivity() {

    // Khai báo hai biến: dragHelper và swipeHelper, cả hai đều thuộc kiểu ItemTouchHelper
    private lateinit var dragHelper: ItemTouchHelper
    private lateinit var swipeHelper: ItemTouchHelper

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Lấy chiều rộng và chiều cao của thiết bị dưới dạng pixel động (dp)
        val displayMetrics: DisplayMetrics = resources.displayMetrics
        val height = (displayMetrics.heightPixels / displayMetrics.density).toInt().dp
        val width = (displayMetrics.widthPixels / displayMetrics.density).toInt().dp

        // Lấy các biểu tượng xóa và lưu trữ từ tài nguyên
        val deleteIcon = resources.getDrawable(R.drawable.ic_outline_delete_24, null)
        val archiveIcon = resources.getDrawable(R.drawable.ic_outline_archive_24, null)

        // Tìm RecyclerView với ID rv_list
        val rvList = findViewById<RecyclerView>(R.id.rv_list)

        // Đặt màu cho các hành động xóa và lưu trữ
        val deleteColor = resources.getColor(android.R.color.holo_red_light)
        val archiveColor = resources.getColor(android.R.color.holo_green_light)

        // Tạo một danh sách chuỗi và khởi tạo Adapter tùy chỉnh của chúng ta với danh sách này
        val list = arrayListOf<String>().apply {
            for (i in 0..100) {
                add("Item $i")
            }
        }
        val adapter = ItemAdapter(this, list)

        // Đặt adapter cho RecyclerView của chúng ta
        rvList.adapter = adapter

        // Xác định swipe helper ở đây
    }

    // Thuộc tính mở rộng cho các số nguyên để chuyển đổi chúng thành dp bằng cách sử dụng lớp TypedValue
    private val Int.dp
        get() = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            toFloat(), resources.displayMetrics
        ).roundToInt()
}

Với những điều cơ bản đã được thiết lập, chúng ta hãy chuyển sang phần thú vị. Chúng tôi đã khai báo swipe helper trước đó; bây giờ là lúc thêm một số mã để làm cho nó hoạt động.

swipeHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
            0,
            ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
        ) {
            // thêm mã tại đây
        })

Trong mã này, chúng tôi khởi tạo ItemTouchHelper với một đối tượng của cùng lớp. Nó yêu cầu hai tham số: dragDirsswipeDirs. Chúng tôi đặt dragDirs là 0 để giữ logic vuốt và kéo tách biệt. Đối với swipeDirs, chúng tôi muốn mục có thể vuốt từ cả hai bên, cho phép cung cấp các chức năng khác nhau tùy thuộc vào hướng vuốt.

Tại thời điểm này, IDE sẽ hiển thị lỗi và yêu cầu bạn ghi đè hai phương thức: onMove()onSwiped().

Hãy lắng nghe IDE và ghi đè hai phương thức này.

override fun onMove(
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    target: RecyclerView.ViewHolder
) = true

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
    val pos = viewHolder.adapterPosition
    list.removeAt(pos)
    adapter.notifyItemRemoved(pos)

    Snackbar.make(
      findViewById(R.id.ll_main),
    if (direction == ItemTouchHelper.RIGHT) "Deleted" else "Archived",
    Snackbar.LENGTH_SHORT
    ).show()
}

Phương thức onMove() không quan trọng đối với logic vuốt, trong khi onSwiped() được gọi sau khi người dùng vuốt hoàn toàn mục. Trong onSwiped(), bạn có thể thực hiện cuộc gọi API, xóa một mục dữ liệu hoặc hiển thị hộp thoại cảnh báo. Trong ví dụ này, chúng tôi xóa mục khỏi danh sách và thông báo cho adapter. Bạn cũng có thể kiểm tra hướng mà người dùng đã vuốt bằng tham số direction, như được minh họa trong chức năng Snackbar.

Đừng quên gắn helper vào RecyclerView như sau:

swipeHelper.attachToRecyclerView(rvList)

Lúc này, thao tác vuốt của bạn sẽ hoạt động như mong đợi, nhưng còn thiếu gì đó đúng không? À, đúng rồi! Bạn có thể hỏi: "Nền đâu rồi?" Chúng tôi đang xử lý điều đó.

RecyclerView Android: hiểu về các thao tác vuốt và kéo

Hãy ghi đè phương thức onChildDraw(), phương thức này cung cấp nhiều tham số như canvas, lượng vuốt theo hướng ngang và dọc, đối tượng viewHolder, v.v. Chúng tôi sẽ sử dụng các tham số này để vẽ các biểu tượng và màu sắc khi vuốt theo cả hai hướng trái và phải.

// Ghi đè phương thức onChildDraw()
override fun onChildDraw(
    canvas: Canvas,
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    dX: Float,
    dY: Float,
    actionState: Int,
    isCurrentlyActive: Boolean
) {
    // 1. Đặt màu nền dựa trên hướng vuốt
    when {
        abs(dX) < width / 3 -> canvas.drawColor(Color.GRAY)
        dX > width / 3 -> canvas.drawColor(deleteColor)
        else -> canvas.drawColor(archiveColor)
    }

    // 2. Đặt các giới hạn cho các biểu tượng
    val textMargin = resources.getDimension(R.dimen.text_margin).roundToInt()
    deleteIcon.bounds = Rect(
        textMargin,
        viewHolder.itemView.top + textMargin + 8.dp,
        textMargin + deleteIcon.intrinsicWidth,
        viewHolder.itemView.top + deleteIcon.intrinsicHeight + textMargin + 8.dp
    )
    archiveIcon.bounds = Rect(
        width - textMargin - archiveIcon.intrinsicWidth,
        viewHolder.itemView.top + textMargin + 8.dp,
        width - textMargin,
        viewHolder.itemView.top + archiveIcon.intrinsicHeight + textMargin + 8.dp
    )

    // 3. Vẽ biểu tượng phù hợp dựa trên hướng vuốt
    if (dX > 0) deleteIcon.draw(canvas) else archiveIcon.draw(canvas)

    // Gọi triển khai của lớp cha cho phương thức onChildDraw()
    super.onChildDraw(
        canvas,
        recyclerView,
        viewHolder,
        dX,
        dY,
        actionState,
        isCurrentlyActive
    )
}

Mã này có thể trông hơi phức tạp lúc đầu, vì vậy chúng ta hãy phân tích nó từng bước. Ở phần đầu tiên, chúng tôi đặt màu nền của canvas dựa trên mức độ người dùng đã vuốt. Đây là một lời giải thích rõ ràng hơn về cách chúng tôi thay đổi màu sắc dựa trên giá trị dX:

  • Khi dX < (deviceWidth / 3) hoặc dX < (-deviceWidth / 3), chúng tôi hiển thị màu xám. Chúng tôi có thể đơn giản hóa điều này bằng cách kiểm tra xem giá trị tuyệt đối của dX có nhỏ hơn (deviceWidth / 3) không.
  • Nếu dX > (deviceWidth / 3), chúng tôi hiển thị màu đỏ (được biểu thị bởi biến deleteColor).
  • Trong tất cả các trường hợp khác (tức là khi người dùng vuốt sang trái và dX nhỏ hơn (deviceWidth / 3)), chúng tôi hiển thị màu xanh lá cây (được biểu thị bởi biến archiveColor).

Cách tiếp cận này cho phép chúng tôi thay đổi màu nền tùy thuộc vào mức độ người dùng đã vuốt và hướng của thao tác vuốt.

RecyclerView Android: hiểu về các thao tác vuốt và kéo

Tuyệt vời, chúng ta đã hoàn thành nửa chặng đường trong việc triển khai nền biểu tượng. Bây giờ, chúng ta hãy đặt các biểu tượng vào đúng vị trí của chúng. Đối với thao tác vuốt từ trái sang phải, chúng ta sẽ hiển thị biểu tượng thùng rác và đối với thao tác vuốt từ phải sang trái, chúng ta sẽ hiển thị biểu tượng lưu trữ. Trong bước 2 của đoạn mã trên, chúng tôi đã đặt các giới hạn cho cả hai biểu tượng.

Đối tượng Rect(...) lấy bốn tham số: trái, trên, phải và dưới. Đây là bốn điểm mà chúng ta cần xác định để định vị biểu tượng. 16dp và 8dp bổ sung trong tọa độ theo chiều dọc là để tính toán cho đệm bố cục và đệm biểu tượng kéo, lần lượt là 16dp và 8dp.

Hãy nhớ rằng đối tượng Rect(...) lấy tọa độ, không phải lề hướng. Để in các biểu tượng, chúng tôi xác định tọa độ từ cả bốn hướng.

Cuối cùng, chúng ta kiểm tra xem dX có phải là số dương không; nếu đúng, chúng ta vẽ biểu tượng thùng rác; nếu không, chúng ta vẽ biểu tượng lưu trữ. Với những bước này, bạn sẽ thấy các biểu tượng khi vuốt từ bất kỳ bên nào của mục.

Thao tác kéo và thả

Việc triển khai thao tác kéo và thả tương tự như thao tác vuốt. Tuy nhiên, đối với kéo và thả, chúng ta chỉ định dragDirsItemTouchHelper.UPItemTouchHelper.DOWN, và swipeDirs là 0. Nếu bạn đang sử dụng bố cục dạng lưới thay vì tuyến tính, bạn có thể chỉ định dragDirs theo tất cả các hướng.

dragHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
    ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0
) {
    // onMove() được gọi khi người dùng kéo một mục
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        // Tăng độ nâng của mục được kéo để phân biệt về mặt hình ảnh
        viewHolder.itemView.elevation = 16F

        // Lấy vị trí adapter của mục được kéo và mục tiêu
        val from = viewHolder.adapterPosition
        val to = target.adapterPosition

        // Hoán đổi các mục trong nguồn dữ liệu và thông báo cho adapter
        Collections.swap(list, from, to)
        adapter.notifyItemMoved(from, to)
        return true
    }

    // onSelectedChanged() được gọi khi trạng thái hành động kéo thay đổi
    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        super.onSelectedChanged(viewHolder, actionState)
        // Đặt lại độ nâng của mục sau khi hành động kéo hoàn tất
        viewHolder?.itemView?.elevation = 0F
    }

    // onSwiped() không được sử dụng trong logic kéo và thả, vì vậy chúng tôi để trống
    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
})

Trong triển khai này, chúng ta sử dụng phương thức onMove() và để trống phương thức onSwiped(). Khi kéo mục qua các mục khác, chúng tôi hoán đổi vị trí của mục được kéo với vị trí của mục tiêu và thông báo cho adapter về các mục đã di chuyển.

Bạn cũng có thể thay đổi giao diện của mục được kéo để có hiệu ứng '3D' hơn. Trong ví dụ này, chúng tôi tăng độ nâng của mục lên 16 đơn vị khi nó đang được di chuyển và sau đó đặt lại độ nâng về 0 đơn vị trong callback onSelectedChanged().

Đừng quên gắn dragHelper vào RecyclerView, giống như chúng tôi đã làm với swipeHelper. Điều này đảm bảo rằng chức năng kéo và thả hoạt động như dự định trong ứng dụng của bạn.

dragHelper.attachToRecyclerView(rvList)

Phương pháp này yêu cầu người dùng nhấn giữ mục để kích hoạt chức năng kéo, phù hợp với hầu hết các trường hợp. Tuy nhiên, nếu bạn muốn triển khai một nút kéo không yêu cầu nhấn giữ và luôn sẵn sàng để kéo, bạn có thể thực hiện điều này một cách dễ dàng với sự trợ giúp của một hàm tích hợp:

fun startDragging(holder: RecyclerView.ViewHolder) {
    dragHelper.startDrag(holder)
}

Và bây giờ bạn cần gọi hàm này từ phương thức onBindViewHolder() của adapter như sau:

override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        holder.textView.text = list[position]

        holder.dragButton.setOnTouchListener { _, _ ->
            context.startDragging(holder)
            return@setOnTouchListener true
        }
}

Hãy chắc chắn rằng bạn đã truyền context dưới dạng MainActivity.kt trong Adapter để có thể gọi hàm này.

Kết luận

Việc triển khai thao tác vuốt và kéo thả trong RecyclerView Android không chỉ cải thiện trải nghiệm người dùng mà còn giúp ứng dụng của bạn trở nên trực quan và linh hoạt hơn.

Với những kiến thức vừa học, bạn có thể tạo ra các tính năng tương tác mạnh mẽ, giúp việc quản lý dữ liệu trong danh sách trở nên đơn giản và hiệu quả hơn, phù hợp với mọi ứng dụng Android hiện đại.