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

前言
之前跟着康师傅学习 Android开发自定义控件系列课程 时(B站:视频传送门【阳光沙滩】2020安卓开发自定义控件基础课程)写了一个 SlideMenuView 的自定义控件,仿QQ消息列表的侧滑功能。
但是后来实际使用时才发现不对劲,把它当做 RecyclerView 的 Item 时,居然一卡一卡的,无法达到我们想要的效果,不信的同学可以去试试哈~
我判断这很有可能是我们自定义的 SlideMenuView 与 RecyclerView 产生了焦点抢占或滑动冲突的问题,于是我把康师傅的 SlideMenuView 进行了改造、扩展,写了 SlideMenu 控件,代码注释比较详细了,我就不过多解释啦!
Cut the crap and show your code!(少废话,上你的代码)
- xml 属性配置
attrs.xml
<?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
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效果图被压缩过,效果看着没有实际的流畅
请同学们点赞、评论、打赏+关注啦~
本文由
A lonely cat
原创发布于
阳光沙滩
,未经作者授权,禁止转载