Android 侧滑菜单的实现思考

SwipeMenuLayout

Posted by XYH on November 25, 2021

前言

本文的由来是群里有好几个群友在咨询关于RecyclerView侧滑菜单的功能,虽然在Github上有很多案例,但是对于实现一个带侧滑功能的Layout没有很好的说明,于是决定写这篇博客来记录一下一个侧滑的Layout怎么实现。

本文涉及到的内容:

  • ViewDragHelper
  • 自定义ViewGroup

效果图

swipemenu

实现流程

首先这种效果是侧滑菜单放在内容View下方,然后允许内容View向左滑动。本来一开始是想用ViewGroup来实现,但是ViewGroup要自己测量、布局、实现touch算法反而会劝退提问的群友们,所以这次使用更加简单的方法。

继承FrameLayout,使用ViewDragHelper。继承FrameLayout能省去测量跟布局的计算,使用ViewDragHelper能省去触摸算法计算。

简单介绍一下ViewDragHelper。

初始化一个ViewDragHelper代码如下:

1
val mViewDragHelper = ViewDragHelper.create(context, 1.0f, ViewDragHelper.Callback)

触摸回调在ViewDragHelper.Callback里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 private val mDragCallback: ViewDragHelper.Callback = object : ViewDragHelper.Callback() {
        override fun tryCaptureView(child: View, pointerId: Int): Boolean {
           
        }

        override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
            
        }

        override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
            
        }

        override fun getViewHorizontalDragRange(child: View): Int {
          
        }
    }

以上为效果图里需要实现的方法,依次解释:

1
2
3
 override fun tryCaptureView(child: View, pointerId: Int): Boolean {
    return whichViewCaptured
}

该方法表示当用户手势捕捉到之后,应该是哪个View被处理,如果该View要处理用户手势则return true,如果该View成果捕捉到手势则顺应会回调到 onViewCaptured(View, Int),当然本文不需要这个回调。


1
2
3
 override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
    return 0
}

该方法会限制被拖动View水平方向运动坐标,其中left参数代表被拖动View的left坐标。比如限制最大left只能是300,则直接return min(left, 300)即可,那该View最大只能滑动到left=300的位置,当超过300的时候会被“修正”为300。


1
2
3
 override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
           
}

该方法顾名思义,就是View拖动释放时的回调,一般在这里做一些缺省操作,比如拖到某个距离触发某些阀值。主要需要对参数做说明:

  • releasedChild - 被释放的View
  • xvel - 水平上的加速度
  • yvel - 垂直方向的加速度

1
2
3
 override fun getViewHorizontalDragRange(child: View): Int {
    return range
}

该方法表示水平运动范围,如果某些View不允许拖动直接返回0即可。

使用ViewDragHelper接管Touch算法。

1
2
3
4
5
6
7
8
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    return mDragHelper.shouldInterceptTouchEvent(ev)
}

override fun onTouchEvent(event: MotionEvent): Boolean {
    mDragHelper.processTouchEvent(event)
        return true
}

完整实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
class SwipeMenuLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : FrameLayout(context, attrs, defStyle) {

    private lateinit var menuView: View

    private lateinit var contentView: View

    private lateinit var mDragHelper: ViewDragHelper
    
    private val mDragCallback: ViewDragHelper.Callback = object : ViewDragHelper.Callback() {
        override fun tryCaptureView(child: View, pointerId: Int): Boolean {
            return child == contentView
        }

        override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
            return min(0, max(-menuView.width, left))
        }

        override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
            val contentViewMovedOffset =
                (releasedChild.width - releasedChild.right).toFloat() / menuView.width.toFloat()

            val finalLeft =
                if (contentViewMovedOffset >= 0.5f) {
                    -menuView.width
                } else {
                    0
                }
            mDragHelper.settleCapturedViewAt(finalLeft, releasedChild.top)
            invalidate()
        }

        override fun getViewHorizontalDragRange(child: View): Int {
            return if (child == contentView) menuView.width else 0
        }
    }

    init {
        mDragHelper = ViewDragHelper.create(this, 1.0f, mDragCallback)
    }

    override fun computeScroll() {
        if (mDragHelper.continueSettling(true)) {
            invalidate()
        }
    }

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        return mDragHelper.shouldInterceptTouchEvent(ev)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        mDragHelper.processTouchEvent(event)
        return true
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        if (childCount != 2) {
            throw RuntimeException("SwipeMenuLayout must have 2 child view only.")
        }
        menuView = getChildAt(0)
        contentView = getChildAt(1)
        menuView.setOnClickListener {
            Toast.makeText(context, "delete", Toast.LENGTH_SHORT).show()
        }
    }

    fun openMenu() {
        mDragHelper.smoothSlideViewTo(contentView, -menuView.width, contentView.top)
        invalidate()
    }

    fun closeMenu() {
        mDragHelper.smoothSlideViewTo(contentView, 0, contentView.top)
        invalidate()
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        contentView.layout(
            0,
            0,
            measuredWidth,
            contentView.measuredHeight
        )
        menuView.layout(
            measuredWidth - menuView.measuredWidth,
            0,
            measuredWidth,
            menuView.measuredHeight
        )
    }
}

这里重写了onLayout,目的是将MenuView放到右侧。

至此一个简单的侧滑布局就完成。

完整布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<RelativeLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <com.zby.base.ui.layout.SwipeMenuLayout
                android:id="@+id/menu_layout"
                android:layout_width="match_parent"
                android:layout_height="80dp"
                android:layout_centerInParent="true">

            <TextView
                    android:layout_width="80dp"
                    android:layout_height="match_parent"
                    android:background="@color/colorAccent"
                    android:gravity="center"
                    android:text="删除"
                    android:textColor="@color/colorWhite"
                    android:textSize="16sp" />

            <TextView
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:background="@color/colorPrimary"
                    android:gravity="center"
                    android:text="This is Content"
                    android:textColor="@color/colorWhite"
                    android:textSize="18sp" />

        </com.zby.base.ui.layout.SwipeMenuLayout>

        <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

            <Button
                    android:id="@+id/btn_open"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="open" />

            <Button
                    android:id="@+id/btn_close"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="close" />
        </LinearLayout>
    </RelativeLayout>

题外

有的群友说他们产品要的需求是侧滑菜单跟在内容View之后,不是在内容View下面,其实在我这博客大部分逻辑都提供了,稍微改造一下应该就能实现该效果。

swipemenu1

需要改造的是onLayout的时候将菜单布局到内容View的后面,然后内容View移动的时候将菜单View的translationX同步更新即可。

onLayout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        contentView.layout(
            0,
            0,
            measuredWidth,
            contentView.measuredHeight
        )
        menuView.layout(
            measuredWidth,
            0,
            measuredWidth + menuView.measuredWidth,
            contentView.measuredHeight
        )
    }

其次为了同步内容View跟菜单View,需要在ViewDraHelper.Callback中多实现一个方法。

1
2
3
4
5
6
7
8
9
  override fun onViewPositionChanged(
            changedView: View,
            left: Int,
            top: Int,
            dx: Int,
            dy: Int
        ) {
     menuView.translationX = left.toFloat()
}

这个会在拖拽View位置发生变化的时候回调,在这里同步菜单View的translationX即可。

总结

关于如何在RecyclerView中使用,首先Item的左右滑动的时候屏蔽掉垂直方向的手势,其次就是触发点击事件的时候不影响RecyclerView Item的点击事件,一般情况下在RecyclerView中可能只有一个Item能打开菜单,为了实现这种互斥效果,可以扩展SwipeMenuLayout的状态以及打开关闭的回调,然后在RecyclerView的Adapter中持有Item的SwipeMenuLayout并监听对应的打开关闭状态,当某一个打开的时候触发回调,关闭其他ViewHolder中持有的SwipeMenuLayout的菜单。

实现功能思路很重要,代码只是思路的一种实现,简而易懂的代码才能称之为优雅。