自定义控件之SlideMenu-仿QQ侧滑功能菜单

前言

之前跟着康师傅学习 Android开发自定义控件系列课程 时(B站:视频传送门【阳光沙滩】2020安卓开发自定义控件基础课程)写了一个 SlideMenuView 的自定义控件,仿QQ消息列表的侧滑功能。

但是后来实际使用时才发现不对劲,把它当做 RecyclerView 的 Item 时,居然一卡一卡的,无法达到我们想要的效果,不信的同学可以去试试哈~

我判断这很有可能是我们自定义的 SlideMenuView 与 RecyclerView 产生了焦点抢占或滑动冲突的问题,于是我把康师傅的 SlideMenuView 进行了改造、扩展,写了 SlideMenu 控件,代码注释比较详细了,我就不过多解释啦!

Cut the crap and show your code!(少废话,上你的代码)

  • xml 属性配置

attrs.xml

1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SlideMenu">
<attr name="sm_press_drawable" format="reference" />
<attr name="sm_normal_drawable" format="reference" />
</declare-styleable>
</resources>

  • SlideMenu 自定义控件部分源码

SlideMenu.kt

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.*
import android.widget.Scroller
import android.widget.TextView
import cn.cqautotest.imui.R
import cn.cqautotest.imui.view.SlideMenu.Companion.DEFAULT_DRAWABLE
import kotlin.math.abs

/**
* 仿 QQ 消息列表的侧滑菜单
*
* @property DEFAULT_DRAWABLE Int
* @property normalDrawable Int
* @property pressDrawable Int
* @property mContentView View
* @property mEditView View
* @property mTvRead TextView
* @property mTvTop TextView
* @property mTvDelete TextView
* @property mScaledTouchSlop Int
* @property mScroller Scroller
* @property mInterceptDownX Float
* @property mInterceptDownY Float
* @property mDownX Float
* @property mDownY Float
* @property mOpen Boolean
* @property mCurrentDirection Direction
* @property mListener OnEditClickListener
* @property mMaxDuration Int
* @property mMinDuration Int
*/
class SlideMenu : ViewGroup {

private var normalDrawable: Int = DEFAULT_DRAWABLE
private var pressDrawable: Int = DEFAULT_DRAWABLE

// 被包裹的内容 View
private lateinit var mContentView: View

// 包裹功能按钮的容器
private lateinit var mEditView: View

// 右边的侧滑功能按钮
private lateinit var mTvRead: TextView
private lateinit var mTvTop: TextView
private lateinit var mTvDelete: TextView

// 在我们认为用户正在滚动之前,手指触摸可以移动的像素距离
private var mScaledTouchSlop = 0
private var mScroller: Scroller
private var mInterceptDownX = 0f
private var mInterceptDownY = 0f
private var mDownX = 0f
private var mDownY = 0f

// 是否已经打开
private var mOpen = false
private var mCurrentDirection = Direction.NONE

// 功能按钮的相关监听
private lateinit var mListener: OnEditClickListener

internal enum class Direction {
NONE, LEFT, RIGHT
}

constructor(context: Context) : super(context)

constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
initAttrs(attrs)
}

constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
initAttrs(attrs)
}

/**
* 初始化 xml 中配置的属性
*
* @param attrs AttributeSet
*/
private fun initAttrs(attrs: AttributeSet) {
val ta = context.obtainStyledAttributes(attrs, R.styleable.SlideMenu)
// 获取正常状态的 Drawable 资源id
normalDrawable = ta.getInteger(R.styleable.SlideMenu_sm_normal_drawable, -1)
// 获取按下状态的 Drawable 资源id
pressDrawable = ta.getInteger(R.styleable.SlideMenu_sm_press_drawable, -1)
ta.recycle()
}

init {
// 可以点击
isClickable = true
// 确保可以获取到触摸的焦点
isFocusableInTouchMode = true
// 在我们认为用户正在滚动之前,手指触摸可以移动的像素距离
mScaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
mScroller = Scroller(context)
}

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
Log.d(TAG, "onInterceptTouchEvent: ===> x:${ev.x} y:${ev.y}")
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
mInterceptDownX = ev.x
mInterceptDownY = ev.y
}
MotionEvent.ACTION_MOVE -> {
val endX = ev.x
val endY = ev.y
// 在水平方向移动的位移
val distanceX = abs(endX - mInterceptDownX)
// 在垂直方向移动的位移
val distanceY = abs(endY - mInterceptDownY)
// 如果横向的滑动距离大于系统认为的滑动距离 ,且纵向的滑动距离小于系统认为的滑动距离则自己消费
if (distanceX > mScaledTouchSlop && distanceY < mScaledTouchSlop) {
return true
}
}
}
return super.onInterceptTouchEvent(ev)
}

// 100 --> 500 50 --> 250
// mDuration 走完的mEditView 5/6宽度所需要的时间
private val mMaxDuration = 800
private val mMinDuration = 300

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
mDownX = event.x
mDownY = event.y
// 如果没有显示功能按钮时才有按下的背景效果
if (isOpen().not()) setPressBg()
}
MotionEvent.ACTION_MOVE -> {
val moveX = event.x
val moveY = event.y
// 移动的差值
val dx = (moveX - mDownX).toInt()
mCurrentDirection = if (dx > 0) Direction.RIGHT else Direction.LEFT
// 判断边界
val resultScrollX = -dx + scrollX
when {
resultScrollX <= 0 -> {
scrollTo(0, 0)
}
resultScrollX > mEditView.measuredWidth -> {
scrollTo(mEditView.measuredWidth, 0)
}
else -> {
scrollBy(-dx, 0)
}
}
// 把差值使用起来
mDownX = moveX
mDownY = moveY
// 在滑动的时候要判断是否开关
checkOpenOrClose()
}
MotionEvent.ACTION_UP -> {
resetBg()
// 处理释放以后,判断是显示还是收缩回去
checkOpenOrClose()
}
MotionEvent.ACTION_CANCEL -> {
resetBg()
}
}
return true
}

/**
* 检查是否应该打开或关闭
*/
private fun checkOpenOrClose() {
// 两个关注点
// 是否已经打开
// 方向
val hasBeenScrollX = scrollX
val editViewWidth = mEditView.measuredWidth
if (mOpen) {
// 当前状态是打开
if (mCurrentDirection == Direction.RIGHT) {
// 方向向右,如果小于3/4,那么就关闭
// 否则打开
if (hasBeenScrollX <= editViewWidth * 5 / 6) {
close()
} else {
open()
}
} else if (mCurrentDirection == Direction.LEFT) {
open()
}
} else {
// 当前状态是关闭
if (mCurrentDirection == Direction.LEFT) {
// 向左滑动,判断滑动的距离
if (hasBeenScrollX > editViewWidth / 6) {
open()
} else {
close()
}
} else if (mCurrentDirection == Direction.RIGHT) {
// 向右滑动
close()
}
}
}

fun isOpen() = mOpen

fun setOpen(open: Boolean) {
if (open) {
open()
} else {
close()
}
}

fun open() {
// 显示出来
// scrollTo(mEditView.getMeasuredWidth(), 0)
val dx = mEditView.measuredWidth - scrollX
val duration: Int = (dx / mEditView.measuredWidth * 5 / 6f).toInt() * mMaxDuration
var absDuration = abs(duration)
if (absDuration < mMinDuration) {
absDuration = mMinDuration
}
mScroller.startScroll(scrollX, 0, dx, 0, absDuration)
mOpen = true
invalidate()
}

fun close() {
// 隐藏
// scrollTo(0,0)
val dx = -scrollX
val duration: Int = (dx / mEditView.measuredWidth * 5 / 6f).toInt() * mMaxDuration
var absDuration = abs(duration)
if (absDuration < mMinDuration) {
absDuration = mMinDuration
}
mScroller.startScroll(scrollX, 0, dx, 0, absDuration)
mOpen = false
invalidate()
}

/**
* 由父级调用,以请求子级在必要时更新其 mScrollX 和 mScrollY 的值。
* 如果孩子使用 {@link android.widget.Scroller Scroller} 对象为滚动动画设置动画,通常可以完成此操作。
*/
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
val currX: Int = mScroller.currX
// 滑动到指定位置即可
scrollTo(currX, 0)
invalidate()
}
}

override fun onFinishInflate() {
super.onFinishInflate()
val childCount = childCount
// 加个判断,只能有一个直接的子 View
require(childCount <= 1) { "Cannot add multiple children,You can only add one child!" }
// 获取到内容 View
mContentView = getChildAt(0)
// 根据属性,继续添加我们的菜单按钮容器 View
mEditView = LayoutInflater.from(context).inflate(R.layout.item_slide_action, this, false)
initEditView()
addView(mEditView)
}

@SuppressLint("ClickableViewAccessibility")
fun initEditView() {
// 设置点击事件
mEditView.run {
mTvRead = findViewById(R.id.tvRead)
mTvTop = findViewById(R.id.tvTop)
mTvDelete = findViewById(R.id.tvDelete)
}
// 功能按钮按下后要置为关闭状态
mTvRead.setOnClickListener {
resetBg()
close()
if (::mListener.isInitialized) {
mListener.onReadClick()
}
}
mTvTop.setOnClickListener {
resetBg()
close()
if (::mListener.isInitialized) {
mListener.onTopClick()
}
}
mTvDelete.setOnClickListener {
resetBg()
close()
if (::mListener.isInitialized) {
mListener.onDeleteClick()
}
}
}

/**
* 将 mContentView 设置成按下的背景资源
*/
private fun setPressBg() {
mContentView.setBackgroundResource(if (pressDrawable == DEFAULT_DRAWABLE) R.drawable.msg_press else pressDrawable)
}

/**
* 重置 mContentView 的背景资源
*/
private fun resetBg() {
mCurrentDirection = Direction.NONE
mContentView.setBackgroundResource(if (normalDrawable == DEFAULT_DRAWABLE) R.drawable.msg_normal else normalDrawable)
}

/**
* 测量布局
*
* @param widthMeasureSpec Int
* @param heightMeasureSpec Int
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 测量第一个孩子,也就是内容部分
// 宽度,跟父控件一样宽。高度有三种情况,如果指定大小,那我们获取到它的大小,直接测量
// 如果是wrap_content,AT_MOST,如果是match_parent,那就给它大小。
val contentLayoutParams: LayoutParams = mContentView.layoutParams
val contentHeightMeasureSpace = when (val contentHeight = contentLayoutParams.height) {
LayoutParams.MATCH_PARENT -> {
MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)
}
LayoutParams.WRAP_CONTENT -> {
MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.AT_MOST)
}
else -> {
// 指定大小
MeasureSpec.makeMeasureSpec(contentHeight, MeasureSpec.EXACTLY)
}
}
mContentView.measure(widthMeasureSpec, contentHeightMeasureSpace)
// 拿到内容部分测量以后的高度
val contentMeasuredHeight: Int = mContentView.measuredHeight
// 测量编辑部分,宽度:3/4,高度跟内容高度一样
val editWidthSize = widthSize * 3 / 4
mEditView.measure(
MeasureSpec.makeMeasureSpec(editWidthSize, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(contentMeasuredHeight, MeasureSpec.EXACTLY)
)
// 测量自己
// 宽就是前面的宽度之和,高度和内容一样
setMeasuredDimension(widthSize + editWidthSize, contentMeasuredHeight)
}

/**
* 摆放布局
*
* @param changed Boolean
* @param l Int
* @param t Int
* @param r Int
* @param b Int
*/
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 摆放内容
val contentRight: Int = mContentView.measuredWidth
val contentBottom = mContentView.measuredHeight
mContentView.layout(0, 0, contentRight, contentBottom)
val editViewRight = contentRight + mEditView.measuredWidth
val editViewBottom = mEditView.measuredHeight
// 摆放编辑部分
mEditView.layout(contentRight, 0, editViewRight, editViewBottom)
}

/**
* 设置监听事件
*
* @param listener OnEditClickListener
*/
fun setOnEditClickListener(listener: OnEditClickListener) {
mListener = listener
}

/**
* 动作的一个监听
*/
interface OnEditClickListener {

/**
* 在向左侧滑时回调
*/
fun onLeft()

/**
* 点击已读按钮
*/
fun onReadClick() {

}

/**
* 点击置顶按钮
*/
fun onTopClick() {

}

/**
* 点击删除按钮
*/
fun onDeleteClick() {

}
}

companion object {
private const val TAG = "SlideMenu"

// 使用默认的背景资源对象
private const val DEFAULT_DRAWABLE = -1
}
}

展示效果

Tips:GIF效果图被压缩过,效果看着没有实际的流畅


请同学们点赞、评论、打赏+关注啦~


自定义控件之SlideMenu-仿QQ侧滑功能菜单
https://blog.funkt.cn/2021/02/08/1358653158540267520/
作者
anjiemo
发布于
2021年2月8日
许可协议
未经作者授权,禁止转载