Всем привет! Меня зовут Артем, я Android-разработчик из ISS. Недавно решал задачу создания свайпа с отрисовкой нескольких кнопок и решил поделиться опытом создания подобной функциональности:
Введение
В этой статье я покажу, как создать свайп для элементов RecyclerView
с использованием ItemTouchHelper
, который будет отрисовывать несколько кнопок. Эта функциональность может быть полезен для добавления взаимодействия с элементами списка, например, для удаления или редактирования элементов.
Решение будет нативным без использования сторонних библиотек, ничего, кроме самого ItemTouchHelper
, нам не понадобится.
Основной код
Для начала создадим класс SwipeHelper
, наследующий ItemTouchHelper.SimpleCallback
Небольшая справка про ItemTouchHelper
Небольшая справка про ItemTouchHelper - подкласс RecyclerView.ItemDecoration, обеспечивающий поддержку смахивания и перетаскивания в RecyclerView. Предоставляет нам для реализации три вложенных типа:
1) ItemTouchHelper.Callback - класс, который предоставляет основу для обработки жестов свайпа и перетаскивания элементов в RecyclerView
. Этот класс определяет основные методы, которые необходимо реализовать для обработки различных взаимодействий с элементами списка.
2) ItemTouchHelper.SimpleCallback - класс, который наследуется от ItemTouchHelper.Callback
и предоставляет упрощенную реализацию для обработки свайпа и перетаскивания. Этот класс предназначен для упрощения создания колбэков для ItemTouchHelper
, предоставляя стандартные реализации большинства методов.
Для удобства будем использовать именного его.
3) ItemTouchHelper.ViewDropHandler - это интерфейс, который определяет методы для обработки падения вида после перетаскивания. Он используется для управления логикой, когда элемент сбрасывается в новое положение после перетаскивания.
В первых двух мы обязаны передать начальное состояние "ACTION_STATE"
и направление свайпа. Подробные значения и определение констант можете посмотреть по ссылкам на оф. документацию.
abstract class SwipeHelper(
private val recyclerView: RecyclerView
) : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.ACTION_STATE_IDLE,
ItemTouchHelper.LEFT
) {
private var swipedPosition = -1
private val buttonsBuffer: MutableMap<Int, List<UnderlayButton>> = mutableMapOf()
private var swipeProgressMap = mutableMapOf<Int, Float>()
private val recoverQueue = object : LinkedList<Int>() {
override fun add(element: Int): Boolean {
if (contains(element)) return false
return super.add(element)
}
}
...
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
if (swipedPosition != position) recoverQueue.add(swipedPosition)
swipedPosition = position
recoverSwipedItem()
}
...
}
Основная логика этих переменных
swipedPosition
используется для отслеживания текущего свайпнутого элемента.buttonsBuffer
хранит кнопки, которые должны быть отображены при свайпе для каждого элемента.swipeProgressMap
отслеживает прогресс свайпа для каждого элемента.recoverQueue
управляет очередью элементов, которые нужно восстановить (перерисовать) после завершения свайпа, чтобы убрать нарисованные кнопки и вернуть элемент в его первоначальное состояние.
private fun recoverSwipedItem() {
while (!recoverQueue.isEmpty()) {
val position = recoverQueue.poll() ?: return
recyclerView.adapter?.notifyItemChanged(position)
}
}
Метод recoverSwipedItem
выполняет следующую задачу:
Пока очередь
recoverQueue
не пуста, он извлекает позиции элементов из очереди и уведомляет адаптерRecyclerView
о том, что элемент на данной позиции изменился.Метод
poll()
удаляет и возвращает первый элемент очереди. Если очередь пуста, он возвращаетnull
.
Далее перейдем к методам отрисовки кнопок:
private fun drawButtons(
canvas: Canvas,
buttons: List<UnderlayButton>,
itemView: View,
dX: Float
) {
var right = itemView.right
buttons.forEach { button ->
val width = button.intrinsicWidth / buttons.intrinsicWidth() * abs(dX)
val left = right - width
button.onDraw(
canvas,
RectF(left, itemView.top.toFloat(), right.toFloat(), itemView.bottom.toFloat())
)
right = left.toInt()
}
}
/** использовал 1.5f как множитель для ширины пространства*/
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
val position = viewHolder.adapterPosition
var maxDX = dX
val itemView = viewHolder.itemView
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
swipeProgressMap[position] = abs(dX) / viewHolder.itemView.width.toFloat()
if (dX < 0) {
if (!buttonsBuffer.containsKey(position)) {
buttonsBuffer[position] = instantiateUnderlayButton(position)
}
val buttons = buttonsBuffer[position] ?: return
if (buttons.isEmpty()) return
maxDX = max(-buttons.intrinsicWidth() * 1.5f, dX)
drawButtons(c, buttons, itemView, maxDX)
}
}
super.onChildDraw(
c,
recyclerView,
viewHolder,
maxDX,
dY,
actionState,
isCurrentlyActive
)
}
abstract fun instantiateUnderlayButton(position: Int): List<UnderlayButton>
drawButtons
: отвечает за отрисовку кнопок под элементомRecyclerView
при свайпе.onChildDraw
: управляет кастомной отрисовкой элемента во время свайпа, включая вызовdrawButtons
для отрисовки кнопок.Метод
instantiateUnderlayButton
предназначен для создания и настройки кнопок, которые будут отображаться под элементомRecyclerView
при свайпе.
Разбираем работу drawButtons
Пошаговое объяснение:
var right = itemView.right
: Начальная правая граница для рисования кнопок устанавливается на правую границу элементаRecyclerView
.buttons.forEach { button -> ... }
: Цикл, который проходит по всем кнопкам, которые нужно нарисовать.val width = button.intrinsicWidth / buttons.intrinsicWidth() * abs(dX)
: Вычисление ширины кнопки на основе прогресса свайпа (dX
). Ширина каждой кнопки пропорциональна общей ширине всех кнопок и прогрессу свайпа.val left = right - width
: Вычисление левой границы кнопки.button.onDraw(canvas, RectF(left, itemView.top.toFloat(), right.toFloat(), itemView.bottom.toFloat()))
: Отрисовка кнопки на канвасе (canvas
) в заданном прямоугольнике.right = left.toInt()
: Обновление правой границы для следующей кнопки.
Разбираем работу onChildDraw
Пошаговое объяснение:
val position = viewHolder.adapterPosition
: Получение позиции элемента вRecyclerView
.var maxDX = dX
: ИнициализацияmaxDX
значениемdX
.dX
- это расстояние, на которое элемент был свайпнут по горизонтали.val itemView = viewHolder.itemView
: Получение ссылки на представление элемента.if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE)
: Проверка, находится ли элемент в состоянии свайпа.swipeProgressMap[position] = abs(dX) / viewHolder.itemView.width.toFloat()
: Сохранение прогресса свайпа для данного элемента.if (dX < 0)
: Проверка, был ли свайп выполнен влево.if (!buttonsBuffer.containsKey(position)) { ... }
: Если для данного элемента нет кнопок в буфере, они создаются с помощьюinstantiateUnderlayButton
.val buttons = buttonsBuffer[position] ?: return
: Получение кнопок для текущего элемента. Если кнопок нет, метод завершается.if (buttons.isEmpty()) return
: Если список кнопок пуст, метод завершается.maxDX = max(-buttons.intrinsicWidth() * 1.5f, dX)
: Ограничение значенияdX
так, чтобы оно не превышало ширину всех кнопок, умноженную на 1.5.drawButtons(c, buttons, itemView, maxDX)
: Вызов методаdrawButtons
для отрисовки кнопок.
super.onChildDraw(c, recyclerView, viewHolder, maxDX, dY, actionState, isCurrentlyActive)
: Вызов метода суперкласса для завершения отрисовки.
Код для создания самих кнопок:
interface UnderlayButtonClickListener {
fun onClick()
}
class UnderlayButton(
private val context: Context,
private val title: String,
textSize: Float,
@ColorRes private val colorRes: Int,
@DrawableRes private val iconRes: Int? = null,
iconSize: Int? = null,
private val clickListener: UnderlayButtonClickListener
) {
private var clickableRegion: RectF? = null
private val textSizeInPixel: Float = textSize * context.resources.displayMetrics.density // dp to px
private val iconSizeInPixel: Float = iconSize?.let { it * context.resources.displayMetrics.density } ?: 100f
private val horizontalPadding = 50.0f
private val verticalPadding = horizontalPadding
val intrinsicWidth: Float
init {
val paint = Paint()
paint.textSize = textSizeInPixel
paint.typeface = Typeface.DEFAULT_BOLD
paint.textAlign = Paint.Align.LEFT
val titleBounds = Rect()
paint.getTextBounds(title, 0, title.length, titleBounds)
intrinsicWidth = (titleBounds.width() + 2 * horizontalPadding)
}
fun onDraw(canvas: Canvas, rect: RectF) {
val paint = Paint()
// Фон
paint.color = ContextCompat.getColor(context, colorRes)
canvas.drawRect(rect, paint)
// Расчет размера иконки
val iconLeft = rect.left + (rect.width() - iconSizeInPixel) / 2
val iconTop = rect.top + (rect.height() * 0.8f - iconSizeInPixel) / 2
// Отрисовка иконки
iconRes?.let {
val drawable = ContextCompat.getDrawable(context, it)
drawable?.let { icon ->
icon.setBounds(
iconLeft.toInt(),
iconTop.toInt(),
(iconLeft + iconSizeInPixel).toInt(),
(iconTop + iconSizeInPixel).toInt()
)
icon.draw(canvas)
}
}
paint.color = ContextCompat.getColor(context, android.R.color.white)
paint.textSize = textSizeInPixel
paint.typeface = Typeface.DEFAULT
paint.textAlign = Paint.Align.CENTER
val titleBounds = Rect()
paint.getTextBounds(title, 0, title.length, titleBounds)
// Отрисовка текста под иконкой с одинаковым отступом от верхней и нижней границы кнопки
val textTop = iconTop + iconSizeInPixel + (verticalPadding / 2 ) // Расстояние от верха кнопки до верха текста
val textY = textTop + titleBounds.height()
canvas.drawText(title, rect.centerX(), textY, paint)
clickableRegion = rect
}
fun handle(event: MotionEvent) {
clickableRegion?.let {
if (it.contains(event.x, event.y)) {
clickListener.onClick()
}
}
}
}
}
private fun List<SwipeHelper.UnderlayButton>.intrinsicWidth(): Float {
if (isEmpty()) return 0.0f
return map { it.intrinsicWidth }.reduce { acc, fl -> acc + fl }
}
Основные части класса UnderlayButton:
Инициализация
В блоке
init
происходит инициализация параметров текста и расчет ширины кнопки.
Метод onDraw
Отвечает за отрисовку кнопки.
Рисует фон, иконку и текст кнопки в заданной области (
rect
).
Метод handle
Обрабатывает события касания.
Проверяет, находится ли касание в пределах кнопки, и вызывает
clickListener.onClick()
, если это так.
Вспомогательная функция List<SwipeHelper.UnderlayButton>.intrinsicWidth
Эта функция расширяет список кнопок UnderlayButton
и вычисляет общую ширину всех кнопок в списке. Если список пуст, возвращается 0.0. В противном случае, она суммирует внутреннюю ширину всех кнопок.
P.S. Возможные доработки
Отмечу, что более правильной реализацией функции onDraw будет передача всех создаваемых внутри объектов как аргументов (т.е. в иделе внутри метода не должны мы не должны создавать объекты). Это ускоряет отрисовку и меньше нагружает систему.
По желанию функциональный интерфейс можно заменить на лямбду (что больше в стиле Котлина), в данном случае оставил его для наглядности, но можно сделать и что-то такое:
class UnderlayButton(
private val context: Context,
private val title: String,
textSize: Float,
@ColorRes private val colorRes: Int,
@DrawableRes private val iconRes: Int? = null,
iconSize: Int? = null,
private val clickListener: (View) -> Unit
) {
...
fun handle(event: MotionEvent) {
clickableRegion?.let {
if (it.contains(event.x, event.y)) {
clickListener.invoke(it)
}
}
}
}
Проверяем работоспособность
Теперь можно назначить обработчик касаний и прикрепить объект нашего класса к rv:
Допишем обработчик в классе:
private val touchListener = View.OnTouchListener { _, event ->
if (swipedPosition < 0) return@OnTouchListener false
buttonsBuffer[swipedPosition]?.forEach { it.handle(event) }
recoverQueue.add(swipedPosition)
swipedPosition = -1
recoverSwipedItem()
true
}
init {
recyclerView.setOnTouchListener(touchListener)
}
Как работает обработчик?
Пошаговое объяснение:
if (swipedPosition < 0) return@OnTouchListener false
:Проверяет, находится ли какой-либо элемент в состоянии свайпа. Если нет (значение swipedPosition меньше 0), возвращает false, указывая, что событие не обработано.
buttonsBuffer[swipedPosition]?.forEach { it.handle(event) }
:Если элемент находится в состоянии свайпа, получает список кнопок для этого элемента из буфера buttonsBuffer и вызывает метод handle для каждой кнопки, передавая текущее событие касания.
recoverQueue.add(swipedPosition)
:Добавляет позицию свайпнутого элемента в очередь восстановления recoverQueue, чтобы позже вернуть этот элемент в исходное состояние.
swipedPosition = -1
:Сбрасывает значение swipedPosition, указывая, что в данный момент ни один элемент не находится в состоянии свайпа.
recoverSwipedItem()
:Вызывает метод recoverSwipedItem, который восстанавливает все элементы в очереди восстановления.
true
:Возвращает true, указывая, что событие касания обработано.
В фрагменте/активити:
...
itemTouchHelper = ItemTouchHelper(
object : SwipeHelper(binding.yourRv) {
override fun instantiateUnderlayButton(position: Int): List<UnderlayButton> {
val deleteButton = UnderlayButton(
context = binding.yourRv.context,
title = resources.getString(R.string.delete),
textSize = 14.0f,
colorRes = android.R.color.holo_red_dark,
iconRes = R.drawable.icon,
clickListener = object : UnderlayButtonClickListener {
override fun onClick() {
}
}
)
listOf(deleteButton)
}
}
)
itemTouchHelper?.attachToRecyclerView(binding.yourRv)
...
Идем проверять.
Как видим, что-то пошло не так, свайп с отрисовкой работает, но не слишком удобно, застревает, если получается открыть, то не получается закрыть и тп.
Обращаемся к документации, где найдем метод:
getSwipeThreshold - возвращает долю (от 0 до 1), на которую пользователь должен переместить представление, чтобы оно считалось перемещенным. Доля вычисляется с учетом границ RecyclerView.
Значение по умолчанию равно 0.5f, что означает, что для прокрутки вида пользователь должен переместить его как минимум на половину ширины или высоты RecyclerView, в зависимости от направления прокрутки.
Казалось бы, достаточно просто переопределить метод, выставив значение поменьше, например, 0.05f (те 5% от ширины) ...
Видим, как легко открыть, но невозможно закрыть. Суть в том, что метод getSwipeThreshold
вызывается при каждом касании, но стандартное значение в половину элемента идет без учета childDraw
, т. е. если вы сдвинете элемент и кнопки займут более чем половину от его размера, вы никогда не сможете его вернуть свайпом в исходное положение без дополнительных действий.
По ходу статьи, вы, наверное, заметили действия с такой переменной: swipeProgressMap = mutableMapOf<Int, Float>(). Её я использовал для того, чтобы динамически менять параметр SwipeThreshold:
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
Log.d("AAA", "вызвали getSwipeEscapeVelocity $defaultValue")
return defaultValue
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
val position = viewHolder.adapterPosition
val progress = swipeProgressMap.getOrDefault(position, 0f)
val swipeThreshold = if (progress > 0.4) 1f else 0.05f
Log.d("AAA", "вызвали getSwipeThreshold $swipeThreshold")
return swipeThreshold
}
override fun getSwipeVelocityThreshold(defaultValue: Float): Float {
Log.d("AAA", "вызвали getSwipeVelocityThreshold $defaultValue")
return defaultValue
}
То есть, мы следим за тем, на сколько сдвинулся изначальный элемент rv, и, если значение выше определенного порога, то меняем настройки getSwipeThreshold
.
Почему 1f
, ведь это, казалось бы, должно усложнить отмену свайпа, но getSwipeThreshold
работает именно на «активацию» свайпа, а для отмены он считает значение 1f — value
, где value
— наше задаваемое значение. Т. е. при 0.05f
нужно сдвинуть 5%
экрана для активации свайпа и 95%
для отмены, но, так как метод getSwipeThreshold
вызывается при каждом касании, то при втором касании, когда мы собираемся скрыть свайп, произойдет перерасчет, и получим 0%
для отмены, и свайп будет легким (тут упремся в скорость для активации и отмены свайпа, поэтому можно не бояться случайной отмены).
Безусловно, в моем случае хватило бы просто булевского значения, но в общем случае, если количество кнопок будет больше 1, хотелось показать, как можно динамически менять этот параметр.
Приведу лог вызовов ниже:
Про getSwipeEscapeVelocity & getSwipeVelocityThreshold
Рассмотрим ещё два метода, которые помогут нам с настройкой свайпа.
getSwipeEscapeVelocity - определяет минимальную скорость, которая будет учитываться пользователем при выполнении свайпа.
Вы можете увеличить это значение, чтобы усложнить выполнение свайпа, или уменьшить, чтобы упростить его. Имейте в виду, что ItemTouchHelper также проверяет перпендикулярную скорость и убеждается, что текущая скорость в направлении больше, чем перпендикулярная. В противном случае движение пользователя будет неоднозначным. Вы можете изменить пороговое значение, переопределив getSwipeVelocityThreshold.
getSwipeVelocityThreshold - определяет максимальную скорость, которую ItemTouchHelper когда-либо будет вычислять для перемещений указателя.
Чтобы рассматривать перемещение как свайп, ItemTouchHelper требует, чтобы оно было больше, чем перпендикулярное перемещение. Если оба направления достигают максимального порогового значения, ни одно из них не будет рассматриваться как свайп, поскольку обычно это указывает на то, что пользователь попытался прокрутить страницу, а не свайпнуть.
Заключение
Таким образом, мы получили удобный свайп с поддержкой отрисовки собственных кнопок и картинок. Надеюсь, статья поможет разобраться с темой и сэкономит время.
Ссылки:
1) Полный код с небольшим примером на гитхаб
2) Официальная документация
3) Stack с обсуждением проблемы отрисовки