Динамические свайпы с помощью ItemTouchHelper

Всем привет! Меня зовут Артем, я Android-разработчик из ISS. Недавно решал задачу создания свайпа с отрисовкой нескольких кнопок и решил поделиться опытом создания подобной функциональности:

988a573dfb8f7a682b5cba39cb55a418.png

  1. Введение

  2. Основной код

  3. Отрисовка дочерних элементов в rv

  4. Создаем кнопки

  5. Проверка работоспособности

  6. Решение проблемы удобства

  7. Заключение и ссылки

Введение

В этой статье я покажу, как создать свайп для элементов 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> = mutableMapOf()
    private var swipeProgressMap = mutableMapOf()
    private val recoverQueue = object : LinkedList() {
        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,
        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
  • 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.intrinsicWidth(): Float {
    if (isEmpty()) return 0.0f
    return map { it.intrinsicWidth }.reduce { acc, fl -> acc + fl }
}

Основные части класса UnderlayButton:

Инициализация

Метод onDraw

  • Отвечает за отрисовку кнопки.

  • Рисует фон, иконку и текст кнопки в заданной области (rect).

Метод handle

  • Обрабатывает события касания.

  • Проверяет, находится ли касание в пределах кнопки, и вызывает clickListener.onClick(), если это так.

Вспомогательная функция List.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)
    }

Как работает обработчик?

Пошаговое объяснение:

  1. if (swipedPosition < 0) return@OnTouchListener false:

    • Проверяет, находится ли какой-либо элемент в состоянии свайпа. Если нет (значение swipedPosition меньше 0), возвращает false, указывая, что событие не обработано.

  2. buttonsBuffer[swipedPosition]?.forEach { it.handle(event) }:

    • Если элемент находится в состоянии свайпа, получает список кнопок для этого элемента из буфера buttonsBuffer и вызывает метод handle для каждой кнопки, передавая текущее событие касания.

  3. recoverQueue.add(swipedPosition):

    • Добавляет позицию свайпнутого элемента в очередь восстановления recoverQueue, чтобы позже вернуть этот элемент в исходное состояние.

  4. swipedPosition = -1:

    • Сбрасывает значение swipedPosition, указывая, что в данный момент ни один элемент не находится в состоянии свайпа.

  5. recoverSwipedItem():

  6. true:

    • Возвращает true, указывая, что событие касания обработано.

В фрагменте/активити:

...
                itemTouchHelper = ItemTouchHelper(
                    object : SwipeHelper(binding.yourRv) {
                        override fun instantiateUnderlayButton(position: Int): List {
                                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% от ширины) …

08efd4e523eae13cd33d75fda11a7442.gif

Видим, как легко открыть, но невозможно закрыть. Суть в том, что метод getSwipeThreshold вызывается при каждом касании, но стандартное значение в половину элемента идет без учета childDraw, т. е. если вы сдвинете элемент и кнопки займут более чем половину от его размера, вы никогда не сможете его вернуть свайпом в исходное положение без дополнительных действий.

По ходу статьи, вы, наверное, заметили действия с такой переменной: swipeProgressMap = mutableMapOf(). Её я использовал для того, чтобы динамически менять параметр 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, хотелось показать, как можно динамически менять этот параметр.

Приведу лог вызовов ниже:

b25dbcbd9365925f22816a954b95d0f5.pngПро getSwipeEscapeVelocity & getSwipeVelocityThreshold

Рассмотрим ещё два метода, которые помогут нам с настройкой свайпа.

getSwipeEscapeVelocity — определяет минимальную скорость, которая будет учитываться пользователем при выполнении свайпа.

Вы можете увеличить это значение, чтобы усложнить выполнение свайпа, или уменьшить, чтобы упростить его. Имейте в виду, что ItemTouchHelper также проверяет перпендикулярную скорость и убеждается, что текущая скорость в направлении больше, чем перпендикулярная. В противном случае движение пользователя будет неоднозначным. Вы можете изменить пороговое значение, переопределив getSwipeVelocityThreshold.

getSwipeVelocityThreshold — определяет максимальную скорость, которую ItemTouchHelper когда-либо будет вычислять для перемещений указателя.

Чтобы рассматривать перемещение как свайп, ItemTouchHelper требует, чтобы оно было больше, чем перпендикулярное перемещение. Если оба направления достигают максимального порогового значения, ни одно из них не будет рассматриваться как свайп, поскольку обычно это указывает на то, что пользователь попытался прокрутить страницу, а не свайпнуть.

Заключение

Таким образом, мы получили удобный свайп с поддержкой отрисовки собственных кнопок и картинок. Надеюсь, статья поможет разобраться с темой и сэкономит время.

5bb2ad0bdd8cc3d15c22c7995542fd0b.gif

Ссылки:

1) Полный код с небольшим примером на гитхаб

2) Официальная документация

3) Stack с обсуждением проблемы отрисовки

© Habrahabr.ru