原创首发    
 自定义控件之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
          原创发布于
          阳光沙滩
          ,未经作者授权,禁止转载
        
 

 A lonely cat  回复 @sduakvaiie
 A lonely cat  回复 @sduakvaiie 



























