Skip to content

Instantly share code, notes, and snippets.

@huhbxxd
Last active December 17, 2022 14:42
Show Gist options
  • Save huhbxxd/fb2c98a428b57efadc40a210942f9087 to your computer and use it in GitHub Desktop.
Save huhbxxd/fb2c98a428b57efadc40a210942f9087 to your computer and use it in GitHub Desktop.
Swipe to menu item simple item touch helper
class SwipeButton private constructor(
private val context: Context,
private val title: String?,
@DimenRes private val textSize: Int?,
@ColorRes private val textColor: Int?,
@ColorRes private val backgroundColor: Int?,
@DrawableRes private val drawRes: Int?,
@DimenRes private val horizontalPadding: Int?,
private val clickListener: UnderlayButtonClickListener?
) {
private var clickableRegion: RectF? = null
private val textSizeDimension: Float = textSize
?.let { context.resources.getDimension(it) } ?: ZERO_SIZE
private val horizontalPaddingDimension: Float =
horizontalPadding?.let { context.resources.getDimension(it) } ?: ZERO_SIZE
private val verticalPaddingDimension: Float = horizontalPaddingDimension
val intrinsicWidth: Float
val instrinsicHeight: Float
init {
intrinsicWidth = initBoundsWidth()
instrinsicHeight = initBoundsHeight()
}
private fun initBoundsWidth(): Float {
return if (title != null) { // to safe let
initTextBoundWidth()
} else initDrawableBoundWidth()
}
private fun initBoundsHeight(): Float {
return if (title != null) { // to safe let
initTextBoundHeight()
} else initDrawableBoundHeight()
}
private fun initTextBoundWidth(): Float {
val paint = Paint()
paint.textSize = textSizeDimension
paint.typeface = Typeface.DEFAULT_BOLD
paint.textAlign = Paint.Align.LEFT
val titleBounds = Rect()
paint.getTextBounds(title, 0, title?.length ?: throw NullPointerException(), titleBounds)
return titleBounds.width() + 2 * horizontalPaddingDimension
}
private fun initDrawableBoundWidth(): Float {
return if (drawRes != null) {
val drawable = ContextCompat.getDrawable(context, drawRes)
(drawable?.intrinsicWidth?.toFloat()
?: throw NullPointerException("No drawable by id")) + 2 * horizontalPaddingDimension
} else throw NullPointerException("No define title or drawable")
}
private fun initTextBoundHeight(): Float {
val paint = Paint()
paint.textSize = textSizeDimension
paint.typeface = Typeface.DEFAULT_BOLD
paint.textAlign = Paint.Align.LEFT
val titleBounds = Rect()
paint.getTextBounds(title, 0, title?.length ?: throw NullPointerException(), titleBounds)
return titleBounds.height() + 2 * horizontalPaddingDimension
}
private fun initDrawableBoundHeight(): Float {
return if (drawRes != null) {
val drawable = ContextCompat.getDrawable(context, drawRes)
(drawable?.intrinsicHeight?.toFloat()
?: throw NullPointerException("No drawable by id")) + 2 * horizontalPaddingDimension
} else throw NullPointerException("No define title or drawable")
}
private fun drawTitleWithIcon(canvas: Canvas, rect: RectF, paint: Paint) {
val drawableBitmap =
ContextCompat.getDrawable(
context,
drawRes ?: throw NullPointerException("Require drawable!")
)?.toBitmap()
?: throw NullPointerException("No drawable by id")
// Draw title
paint.color = ContextCompat.getColor(context, textColor ?: R.color.black)
paint.textSize = textSizeDimension
paint.typeface = Typeface.DEFAULT_BOLD
paint.textAlign = Paint.Align.LEFT
val titleBounds = Rect()
paint.getTextBounds(
title,
0,
title?.length ?: throw NullPointerException("Require title!"),
titleBounds
)
val bitmapWidth = drawableBitmap.width
val bitmapHeight = drawableBitmap.height
val yImage =
rect.top + rect.height() / 2 - (bitmapHeight / 2) - titleBounds.height() / 2 - context.resources.getDimension(
R.dimen.low_space
)
val xImage =
rect.right - (titleBounds.width() / 2) - horizontalPaddingDimension - (bitmapWidth / 2)
val yText =
(rect.height() / 2 + titleBounds.height() / 2 - titleBounds.bottom) + rect.top + bitmapHeight / 2
val xText = rect.right - titleBounds.width() - horizontalPaddingDimension
canvas.drawText(title, xText, yText, paint)
// draw icon
canvas.drawBitmap(drawableBitmap, xImage, yImage, null)
}
private fun drawIcon(canvas: Canvas, rect: RectF, paint: Paint) {
val drawableBitmap =
ContextCompat.getDrawable(
context,
drawRes ?: throw NullPointerException("Require drawable!")
)?.toBitmap() ?: throw NullPointerException("No drawable by id")
val bitmapWidth = drawableBitmap.width
val bitmapHeight = drawableBitmap.height
val yImage = rect.top + rect.height() / 2 - (bitmapHeight / 2)
val xImage = rect.right - horizontalPaddingDimension - bitmapWidth
canvas.drawBitmap(drawableBitmap, xImage, yImage, null)
}
private fun drawTitle(canvas: Canvas, rect: RectF, paint: Paint) {
// Draw title
paint.color = ContextCompat.getColor(context, textColor ?: R.color.black)
paint.textSize = textSizeDimension
paint.typeface = Typeface.DEFAULT_BOLD
paint.textAlign = Paint.Align.LEFT
val titleBounds = Rect()
paint.getTextBounds(
title,
0,
title?.length ?: throw NullPointerException("Require title!"),
titleBounds
)
val yText =
(rect.height() / 2 + titleBounds.height() / 2 - titleBounds.bottom) + rect.top
val xText = rect.right - titleBounds.width() - horizontalPaddingDimension
canvas.drawText(title, xText, yText, paint)
}
fun draw(canvas: Canvas, rect: RectF) {
val paint = Paint()
// Draw background
paint.color = ContextCompat.getColor(context, backgroundColor ?: R.color.purple_200)
canvas.drawRect(rect, paint)
when {
title != null && drawRes != null -> drawTitleWithIcon(canvas, rect, paint)
drawRes != null -> drawIcon(canvas, rect, paint)
title != null -> drawTitle(canvas, rect, paint)
}
clickableRegion = rect
}
fun handle(event: MotionEvent) {
clickableRegion?.let {
if (it.contains(event.x, event.y)) {
clickListener?.onClick()
}
}
}
companion object {
private const val ZERO_SIZE = 0.0f
}
class Builder {
private var title: String? = null
@DimenRes
private var textSize: Int? = null
@ColorRes
private var textColor: Int? = null
@ColorRes
private var backgroundColor: Int? = null
@DrawableRes
private var drawRes: Int? = null
@DimenRes
private var horizontalPadding: Int? = null
private var clickListener: UnderlayButtonClickListener? = null
fun setTitle(title: String) = apply { this.title = title }
fun setTextSize(@DimenRes textSize: Int) = apply { this.textSize = textSize }
fun setBackgroundColor(@ColorRes colorRes: Int) = apply { backgroundColor = colorRes }
fun setTextColor(@ColorRes colorRes: Int) = apply { textColor = colorRes }
fun setDrawRes(@DrawableRes drawRes: Int) = apply { this.drawRes = drawRes }
fun setClickListener(listener: UnderlayButtonClickListener) =
apply { clickListener = listener }
fun setHorizontalPadding(@DimenRes horizontalPadding: Int) =
apply { this.horizontalPadding = horizontalPadding }
fun build(context: Context) =
SwipeButton(
context,
title,
textSize,
textColor,
backgroundColor,
drawRes,
horizontalPadding,
clickListener
)
}
}
interface UnderlayButtonClickListener {
fun onClick(position: Int)
}
@SuppressLint("ClickableViewAccessibility")
abstract class SwipeToMenu(
private val recyclerView: RecyclerView,
private val orientation: Orientation = Orientation.VERTICAL
) : ItemTouchHelper.SimpleCallback(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.LEFT) {
enum class Orientation { HORIZONTAL, VERTICAL }
private var swipedPosition = -1
private val buttonsBuffer: MutableMap<Int, List<SwipeButton>> = mutableMapOf()
private val recoverQueue = object : LinkedList<Int>() {
override fun add(element: Int): Boolean {
if (contains(element)) return false
return super.add(element)
}
}
private val touchListener = View.OnTouchListener { _, event ->
if (swipedPosition < 0) return@OnTouchListener false
buttonsBuffer[swipedPosition]?.forEach { it.handle(event, swipePosition) }
recoverQueue.add(swipedPosition)
swipedPosition = -1
recoverSwipedItem()
true
}
init {
recyclerView.setOnTouchListener(touchListener)
}
private fun recoverSwipedItem() {
while (!recoverQueue.isEmpty()) {
val position = recoverQueue.poll() ?: return
recyclerView.adapter?.notifyItemChanged(position)
}
}
private fun drawButtons(
canvas: Canvas,
buttons: List<SwipeButton>,
itemView: View,
dX: Float,
maxHeight: Float
) {
var right = itemView.right
var bottom = itemView.bottom
when (orientation) {
Orientation.VERTICAL -> {
buttons.forEach { button ->
val width =
button.intrinsicWidth / buttons.intrinsicWidth() * kotlin.math.abs(dX)
val left = right - width
button.draw(
canvas,
RectF(
left,
itemView.top.toFloat(),
right.toFloat(),
itemView.bottom.toFloat()
)
)
right = left.toInt()
}
}
Orientation.HORIZONTAL -> {
buttons.forEach { button ->
val height = maxHeight / buttons.size
val top = bottom - height
val width = kotlin.math.abs(dX)
val left = right - width
button.draw(
canvas,
RectF(left, top, right.toFloat(), bottom.toFloat())
)
bottom = top.toInt()
}
}
}
}
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
val isCanceled = dX == 0f && !isCurrentlyActive
if (isCanceled) {
itemView.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
return
}
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
if (dX < 0) {
if (!buttonsBuffer.containsKey(position)) {
buttonsBuffer[position] = instantiateUnderlayButton(position)
}
val buttons = buttonsBuffer[position] ?: return
if (buttons.isEmpty()) return
maxDX = when (orientation) {
Orientation.VERTICAL -> max(-buttons.intrinsicWidth(), dX)
Orientation.HORIZONTAL -> max(
-buttons.maxBy { it.intrinsicWidth }.intrinsicWidth,
dX
)
}
val maxHeight = max(buttons.intrinsicHeight(), itemView.height.toFloat())
drawButtons(c, buttons, itemView, maxDX, maxHeight)
}
}
super.onChildDraw(
c,
recyclerView,
viewHolder,
maxDX,
dY,
actionState,
isCurrentlyActive
)
}
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()
}
abstract fun instantiateUnderlayButton(position: Int): List<SwipeButton>
}
private fun List<SwipeButton>.intrinsicWidth(): Float {
if (isEmpty()) return 0.0f
return map { it.intrinsicWidth }.reduce { acc, fl -> acc + fl }
}
private fun List<SwipeButton>.intrinsicHeight(): Float {
if (isEmpty()) return 0.0f
return map { it.instrinsicHeight }.reduce { acc, fl -> acc + fl }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment