RecyclerView ItemTouchHelper Buttons on Swipe

后端 未结 11 1242
春和景丽
春和景丽 2020-11-27 11:05

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

相关标签:
11条回答
  • 2020-11-27 11:07

    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;
        }
    
    0 讨论(0)
  • 2020-11-27 11:09

    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")
                    }
                })
        }
    
    0 讨论(0)
  • 2020-11-27 11:09

    I have a much simpler solution:

    1. Add a button to your row XML, width 0, floating on the right:
    > <Button
    >     android:id="@+id/hidden"
    >     android:layout_width="0dp"
    >     android:layout_height="match_parent"
    >     android:layout_alignParentRight = "true">
    
    1. 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()

    0 讨论(0)
  • 2020-11-27 11:10

    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.

    0 讨论(0)
  • 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.

    0 讨论(0)
  • 2020-11-27 11:14

    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:

    1. 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)
                  );
      
      
              }
          }
      }
      
    2. 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;
              }
      
          }
      
    0 讨论(0)
提交回复
热议问题