I am trying to port some iOS functionality to Android.
I intent to create a table where on swipe to the left shows 2 button: Edit and Delete.
I have
Following Wenxi Zeng's answer here, if you want to have the text in the buttons on multiple lines, replace UnderlayButton's onDraw method with this:
public void onDraw(Canvas canvas, RectF rect, int pos){
Paint p = new Paint();
// Draw background
p.setColor(color);
canvas.drawRect(rect, p);
// Draw Text
TextPaint textPaint = new TextPaint();
textPaint.setTextSize(UtilitiesOperations.convertDpToPx(getContext(), 15));
textPaint.setColor(Color.WHITE);
StaticLayout sl = new StaticLayout(text, textPaint, (int)rect.width(),
Layout.Alignment.ALIGN_CENTER, 1, 1, false);
canvas.save();
Rect r = new Rect();
float y = (rect.height() / 2f) + (r.height() / 2f) - r.bottom - (sl.getHeight() /2);
canvas.translate(rect.left, rect.top + y);
sl.draw(canvas);
canvas.restore();
clickRegion = rect;
this.pos = pos;
}
Here is the Kotlin version based on the accepted answer approach. With some minor changes I managed to render the buttons width based on the intrinsic size of the text instead of using a fixed width.
Demo project: https://github.com/ntnhon/RecyclerViewRowOptionsDemo
Implementation of SwipeHelper
:
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import java.util.*
import kotlin.math.abs
import kotlin.math.max
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 val recoverQueue = object : LinkedList<Int>() {
override fun add(element: Int): Boolean {
if (contains(element)) return false
return super.add(element)
}
}
@SuppressLint("ClickableViewAccessibility")
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)
}
private fun recoverSwipedItem() {
while (!recoverQueue.isEmpty()) {
val position = recoverQueue.poll() ?: return
recyclerView.adapter?.notifyItemChanged(position)
}
}
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.draw(
canvas,
RectF(left, itemView.top.toFloat(), right.toFloat(), itemView.bottom.toFloat())
)
right = left.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
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 = max(-buttons.intrinsicWidth(), dX)
drawButtons(c, buttons, itemView, maxDX)
}
}
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<UnderlayButton>
//region UnderlayButton
interface UnderlayButtonClickListener {
fun onClick()
}
class UnderlayButton(
private val context: Context,
private val title: String,
textSize: Float,
@ColorRes private val colorRes: Int,
private val clickListener: UnderlayButtonClickListener
) {
private var clickableRegion: RectF? = null
private val textSizeInPixel: Float = textSize * context.resources.displayMetrics.density // dp to px
private val horizontalPadding = 50.0f
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 draw(canvas: Canvas, rect: RectF) {
val paint = Paint()
// Draw background
paint.color = ContextCompat.getColor(context, colorRes)
canvas.drawRect(rect, paint)
// Draw title
paint.color = ContextCompat.getColor(context, android.R.color.white)
paint.textSize = textSizeInPixel
paint.typeface = Typeface.DEFAULT_BOLD
paint.textAlign = Paint.Align.LEFT
val titleBounds = Rect()
paint.getTextBounds(title, 0, title.length, titleBounds)
val y = rect.height() / 2 + titleBounds.height() / 2 - titleBounds.bottom
canvas.drawText(title, rect.left + horizontalPadding, rect.top + y, paint)
clickableRegion = rect
}
fun handle(event: MotionEvent) {
clickableRegion?.let {
if (it.contains(event.x, event.y)) {
clickListener.onClick()
}
}
}
}
//endregion
}
private fun List<SwipeHelper.UnderlayButton>.intrinsicWidth(): Float {
if (isEmpty()) return 0.0f
return map { it.intrinsicWidth }.reduce { acc, fl -> acc + fl }
}
Usage:
private fun setUpRecyclerView() {
binding.recyclerView.adapter = Adapter(listOf(
"Item 0: No action",
"Item 1: Delete",
"Item 2: Delete & Mark as unread",
"Item 3: Delete, Mark as unread & Archive"
))
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
binding.recyclerView.layoutManager = LinearLayoutManager(this)
val itemTouchHelper = ItemTouchHelper(object : SwipeHelper(binding.recyclerView) {
override fun instantiateUnderlayButton(position: Int): List<UnderlayButton> {
var buttons = listOf<UnderlayButton>()
val deleteButton = deleteButton(position)
val markAsUnreadButton = markAsUnreadButton(position)
val archiveButton = archiveButton(position)
when (position) {
1 -> buttons = listOf(deleteButton)
2 -> buttons = listOf(deleteButton, markAsUnreadButton)
3 -> buttons = listOf(deleteButton, markAsUnreadButton, archiveButton)
else -> Unit
}
return buttons
}
})
itemTouchHelper.attachToRecyclerView(binding.recyclerView)
}
private fun toast(text: String) {
toast?.cancel()
toast = Toast.makeText(this, text, Toast.LENGTH_SHORT)
toast?.show()
}
private fun deleteButton(position: Int) : SwipeHelper.UnderlayButton {
return SwipeHelper.UnderlayButton(
this,
"Delete",
14.0f,
android.R.color.holo_red_light,
object : SwipeHelper.UnderlayButtonClickListener {
override fun onClick() {
toast("Deleted item $position")
}
})
}
private fun markAsUnreadButton(position: Int) : SwipeHelper.UnderlayButton {
return SwipeHelper.UnderlayButton(
this,
"Mark as unread",
14.0f,
android.R.color.holo_green_light,
object : SwipeHelper.UnderlayButtonClickListener {
override fun onClick() {
toast("Marked as unread item $position")
}
})
}
private fun archiveButton(position: Int) : SwipeHelper.UnderlayButton {
return SwipeHelper.UnderlayButton(
this,
"Archive",
14.0f,
android.R.color.holo_blue_light,
object : SwipeHelper.UnderlayButtonClickListener {
override fun onClick() {
toast("Archived item $position")
}
})
}
I have a much simpler solution:
> <Button > android:id="@+id/hidden" > android:layout_width="0dp" > android:layout_height="match_parent" > android:layout_alignParentRight = "true">
in onChildDraw(), just increase its width by the dX value.
int position = viewHolder.getAdapterPosition();
View v = recyclerView.getLayoutManager().findViewByPosition(position);
Button hidden = v.findViewById(R.id.hidden);
hidden.setLayoutParams(new LinearLayout.LayoutParams((int)-dX, -1));
Make sure not to call the default super.onChildDraw()
I am late to the party but if anyone looks for an UIKit UITableView delete button behaviour then you can use something like this with a RecyclerView in Xamarin.Android:
public class SwipeDeleteHelper : ItemTouchHelper.Callback
{
private int _startingWidth = 0;
private bool? _rightAlignedText = null;
private bool _alreadyClicked = false;
private static float _previousDx = float.NegativeInfinity;
private static float _viewWidth = float.NegativeInfinity;
private static float _permanentlyDeleteThreshold = float.NegativeInfinity;
private static RecyclerView.ViewHolder _currentViewHolder;
private RecyclerView.ViewHolder CurrentViewHolder
{
get => _currentViewHolder;
set
{
_startingWidth = 0;
_rightAlignedText = null;
_alreadyClicked = false;
_previousDx = float.NegativeInfinity;
_currentViewHolder = value;
}
}
/*
You can create a method in a utility class for the buttonwidth conversion like this:
public static float GetPxFromDp(float dp)
{
return dp * Application.Context.ApplicationContext.Resources.DisplayMetrics.Density;
}
Also you can use text width measurement to determine the optimal width of the button for your delete text.
*/
public static int buttonWidth = 60 * Application.Context.ApplicationContext.Resources.DisplayMetrics.Density;
public override int GetMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder)
{
if (viewHolder is EntryCell)
{
return MakeMovementFlags(ItemTouchHelper.ActionStateIdle, ItemTouchHelper.Left | ItemTouchHelper.Start | ItemTouchHelper.Right | ItemTouchHelper.End);
}
return MakeMovementFlags(ItemTouchHelper.ActionStateIdle, ItemTouchHelper.ActionStateIdle);
}
public override void OnSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState)
{
if (float.IsNegativeInfinity(_permanentlyDeleteThreshold))
{
_viewWidth = viewHolder.ItemView.Width;
_permanentlyDeleteThreshold = (viewHolder.ItemView.Width * 3f / 4f);
}
if (viewHolder != CurrentViewHolder)
{
if (viewHolder != null) // This is a new selection and the button of the previous viewHolder should get hidden.
{
(CurrentViewHolder as EntryCell)?.ResetView(CurrentViewHolder);
CurrentViewHolder = viewHolder;
}
else if (CurrentViewHolder != null) // This is the end of the previous selection
{
var hidden = CurrentViewHolder.ItemView.FindViewById<Button>(Resource.Id.fileListDeleteButton);
_previousDx = float.NegativeInfinity;
if (hidden.LayoutParameters.Width > _permanentlyDeleteThreshold && !_alreadyClicked) // released in permanent delete area
{
_alreadyClicked = true;
hidden.LayoutParameters.Width = CurrentViewHolder.ItemView.Width;
hidden.CallOnClick();
CurrentViewHolder = null;
}
else
{
_startingWidth = hidden.LayoutParameters.Width >= buttonWidth ? buttonWidth : 0;
hidden.LayoutParameters.Width = _startingWidth;
}
AlignDeleteButtonText(hidden);
hidden.RequestLayout();
}
}
base.OnSelectedChanged(viewHolder, actionState);
}
public override void OnChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, bool isCurrentlyActive)
{
if (actionState == ItemTouchHelper.ActionStateSwipe && !_alreadyClicked)
{
var hidden = viewHolder.ItemView.FindViewById<Button>(Resource.Id.fileListDeleteButton);
if (isCurrentlyActive) // swiping
{
if (float.IsNegativeInfinity(_previousDx)) // This is a new swipe
{
_previousDx = dX;
}
if (Math.Abs(dX - _previousDx) > 0.1f && Math.Abs(dX - (-_viewWidth)) > 0.1f)
{
hidden.LayoutParameters.Width = Math.Max(0, (int)Math.Round(hidden.LayoutParameters.Width - (dX >= _previousDx ? 1 : -1) * (Math.Abs(dX - _previousDx))));
_previousDx = dX;
AlignDeleteButtonText(hidden);
hidden.RequestLayout();
}
}
}
}
private void AlignDeleteButtonText(Button hidden)
{
if (_rightAlignedText != false && hidden.LayoutParameters.Width >= _permanentlyDeleteThreshold) // pulled into permanent delete area
{
hidden.Gravity = GravityFlags.AxisSpecified | GravityFlags.AxisPullBefore | GravityFlags.CenterVertical;
_rightAlignedText = false;
}
else if (_rightAlignedText != null && hidden.LayoutParameters.Width <= buttonWidth)
{
hidden.Gravity = GravityFlags.Center;
_rightAlignedText = null;
}
else if (_rightAlignedText != true && hidden.LayoutParameters.Width > buttonWidth && hidden.LayoutParameters.Width < _permanentlyDeleteThreshold) // pulled back from permanent delete area
{
hidden.Gravity = GravityFlags.AxisSpecified | GravityFlags.AxisPullAfter | GravityFlags.CenterVertical;
_rightAlignedText = true;
}
}
public override bool OnMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { return false; }
public override void OnSwiped(RecyclerView.ViewHolder viewHolder, int direction) { }
}
The EntryCell is a descendant of MvxRecyclerViewHolder and it should contain something like this:
public class EntryCell : MvxRecyclerViewHolder
{
public EntryCell(View itemView, IMvxAndroidBindingContext context) : base(itemView, context)
{
Button _delButton = itemView.FindViewById<Button>(Resource.Id.fileListDeleteButton);
_delButton.Text = "Delete";
}
public void ResetView(RecyclerView.ViewHolder currentViewHolder)
{
var hidden = currentViewHolder.ItemView.FindViewById<Button>(Resource.Id.fileListDeleteButton);
hidden.LayoutParameters.Width = 0;
hidden.RequestLayout();
}
}
Your view should have a button (Referenced in EntryCell as Resource.Id.fileListDeleteButton so the ID of the button is fileListDeleteButton) in it. I use an XML as a view and it looks like this:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="40dp"
android:orientation="vertical">
<!-- The rest of your code... -->
<Button
android:id="@+id/fileListDeleteButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_alignParentRight="true"
android:paddingHorizontal="@dimen/abc_button_padding_horizontal_material"
android:background="#f00"
android:textColor="@android:color/white"
android:textAllCaps="false"
android:singleLine="true"
android:ellipsize="none"
android:text="dummy" />
</RelativeLayout>
In your code, where the RecyclerView is, use it like this:
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new SwipeDeleteHelper());
itemTouchHelper.AttachToRecyclerView(yourRecyclerView);
I hope this helps someone.
For all those who wanna use a library for this, check this out:
https://github.com/chthai64/SwipeRevealLayout
And, for a stripped down version of this lib, checkout:
https://android.jlelse.eu/android-recyclerview-swipeable-items-46a3c763498d
P.S. You can create any custom layout (even with Image Buttons
) as your hidden layout using these.
If you want button(s) on left side as well when swipe in the other direction, just try to add this simple lines in the existing answer:
In the drawButtons
method:
private void drawButtons(Canvas c, View itemView, List<UnderlayButton> buffer, int pos, float dX) {
float right = itemView.getRight();
float left = itemView.getLeft();
float dButtonWidth = (-1) * dX / buffer.size();
for (UnderlayButton button : buffer) {
if (dX < 0) {
left = right - dButtonWidth;
button.onDraw(
c,
new RectF(
left,
itemView.getTop(),
right,
itemView.getBottom()
),
pos, dX //(to draw button on right)
);
right = left;
} else if (dX > 0) {
right = left - dButtonWidth;
button.onDraw(c,
new RectF(
right,
itemView.getTop(),
left,
itemView.getBottom()
), pos, dX //(to draw button on left)
);
}
}
}
In the onDraw
method check the value of dX
and set the text and the colour of the buttons:
public void onDraw(Canvas c, RectF rect, int pos, float dX) {
Paint p = new Paint();
// Draw background
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (dX > 0)
p.setColor(Color.parseColor("#23d2c5"));
else if (dX < 0)
p.setColor(Color.parseColor("#23d2c5"));
c.drawRect(rect, p);
// Draw Text
p.setColor(Color.WHITE);
p.setTextSize(36);
// p.setTextSize(LayoutHelper.getPx(MyApplication.getAppContext(), 12));
Rect r = new Rect();
float cHeight = rect.height();
float cWidth = rect.width();
p.setTextAlign(Paint.Align.LEFT);
p.getTextBounds(text, 0, text.length(), r);
float x = cWidth / 2f - r.width() / 2f - r.left;
float y = cHeight / 2f + r.height() / 2f - r.bottom;
if (dX > 0) {
p.setColor(Color.parseColor("#23d2c5"));
c.drawText("Reject", rect.left + x, rect.top + y, p);
} else if (dX < 0) {
c.drawText(text, rect.left + x, rect.top + y, p);
}
clickRegion = rect;
this.pos = pos;
}
}