Android自定义View(二)——亮度条

1,053 阅读11分钟

前言

昨天凌晨在B站抽盲盒抽上瘾,花了六百多块,就中了一个九十多块的miku fufu,还不是世嘉版的,标注的8%概率,假得很。

前天老板问我什么时候能做完,答下周五,毕竟不能留着跨年,然后他让我下周三之前做完,周末又不想跑这么远去公司,没办法,只能把项目copy回家了(没有远程代码仓库,就我一个android开发的小公司,保密协议现在都没给我签...)

亮度条

class LightnessBar @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0
): View(context, attributeSet, defStyleAttr, defStyleRes) {
    companion object {
        private const val TAG = "LightnessBar"
    }

    /**
     * 前景画笔,默认绘制白色的条
     */
    private val mPaint: Paint = Paint()

    /**
     * 背景RectF,负责描述背景条的位置信息
     */
    private val mBackgroundRectF = RectF()

    /**
     * 亮度条RectF,负责描述亮度条的位置信息
     */
    private val mLightnessBarRectF = RectF()

    /**
     * 背景和前景条的切角
     */
    private val mCornerRadius: Float

    /**
     * 当前亮度
     */
    private var mCurrentBrightness: Int

    /**
     * 仿seekbar做个maxWidth
     */
    private val mMaxWidth: Int

    /**
     * 缓存背景bitmap
     */
    private var mBackgroundBitmap: Bitmap? = null

    /**
     * draw()不宜新建对象
     */
    private val mMode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)

    init {
        context.theme.obtainStyledAttributes(
            attributeSet,
            R.styleable.LightBar,
            defStyleAttr, defStyleRes
        ).apply {
            mCornerRadius = getDimension(R.styleable.LightBar_cornerRadius, 0f)

            val contentResolver = context.contentResolver
            val mode = Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS_MODE)
            if (mode == Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC) {
                Settings.System.putInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS_MODE,
                    Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL)
            }

            mCurrentBrightness = Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS)
            mMaxWidth = getDimensionPixelOffset(R.styleable.LightBar_maxWidth, DensityUtils.dip2px(40))
            recycle()
        }
        initPaint()
    }

    private fun initPaint() {
        mPaint.style = Paint.Style.FILL
        mPaint.color = Color.parseColor("#F2FFFFFF")
        mPaint.isAntiAlias = true
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val paddingVertical = (measuredWidth - min(measuredWidth, mMaxWidth)) * 0.5f
        mBackgroundRectF.top = 0f
        mBackgroundRectF.left = paddingVertical
        mBackgroundRectF.right = measuredWidth.toFloat() - paddingVertical
        mBackgroundRectF.bottom = measuredHeight.toFloat()
        mBackgroundBitmap = makeBackground(measuredWidth, measuredHeight)

        mLightnessBarRectF.bottom = mBackgroundRectF.bottom
        mLightnessBarRectF.left = mBackgroundRectF.left
        mLightnessBarRectF.right = mBackgroundRectF.right
        mLightnessBarRectF.top = measuredHeight * (1 - mCurrentBrightness / 255f)
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        if (canvas == null) return
        val sc = canvas.saveLayer(mBackgroundRectF, mPaint)
        drawBackground(canvas)
        mPaint.xfermode = mMode
        drawForeground(canvas)
        mPaint.xfermode = null
        canvas.restoreToCount(sc)
    }

    private fun drawBackground(canvas: Canvas?) {
        if (mBackgroundBitmap != null) {
            canvas?.drawBitmap(mBackgroundBitmap!!, 0f, 0f, mPaint)
        }
    }

    private fun drawForeground(canvas: Canvas?) {
        canvas?.drawRoundRect(mLightnessBarRectF, mCornerRadius, mCornerRadius, mPaint)
    }

    /**
     * 记录上一次亮度变化时的纵坐标y
     */
    private var mLastLightnessY = 0f

    /**
     * 记录指针移动时动态变化的纵坐标y
     */
    private var mPointerMoveY: Float = 0f

    override fun onTouchEvent(event: MotionEvent?): Boolean {
//        LogUtil.i(TAG, "onTouchEvent: $event")
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                mPointerMoveY = event.y
                mLastLightnessY = event.y
                parent.requestDisallowInterceptTouchEvent(true)
            }
            MotionEvent.ACTION_MOVE -> trackTouchEvent(event)
            MotionEvent.ACTION_UP -> {
                parent.requestDisallowInterceptTouchEvent(false)
            }
            MotionEvent.ACTION_CANCEL -> {
                parent.requestDisallowInterceptTouchEvent(false)
            }
        }
        return true
    }

    private fun trackTouchEvent(event: MotionEvent) {
        val newTop = mLightnessBarRectF.top - (mPointerMoveY - event.y)
        if (mPointerMoveY == event.y) {
            LogUtil.e(TAG, "mPointerMoveY: $mPointerMoveY event.y: ${event.y}")
        }
        mPointerMoveY = event.y

        mLightnessBarRectF.top = when {
            newTop <= mBackgroundRectF.top -> {
                if (mCurrentBrightness >= 255) return
                mBackgroundRectF.top
            }
            newTop >= mLightnessBarRectF.bottom -> {
                if (mCurrentBrightness <= 0) return
                mLightnessBarRectF.bottom
            }
            else -> newTop
        }

        LogUtil.i(TAG, """top: ${mLightnessBarRectF.top} newTop: $newTop""")
        if (mCurrentBrightness > 255 || mCurrentBrightness < 0) {
            LogUtil.i(TAG, """mCurrentBrightness: $mCurrentBrightness""")
        }

        val newLightness: Int = mCurrentBrightness + (((mLastLightnessY - mPointerMoveY) / measuredHeight) * 255).toInt()

        if (newLightness < mCurrentBrightness && mCurrentBrightness > 0) {
            mCurrentBrightness = if (newLightness > 0) {
                newLightness
            } else {
                0
            }
            adjustLightness()
        }

        if (newLightness > mCurrentBrightness && mCurrentBrightness < 255) {
            mCurrentBrightness = if (newLightness < 255) {
                newLightness
            } else {
                255
            }
            adjustLightness()
        }

        invalidate()
    }

    /**
     * 限制每次调节系统亮度的协程数量
     */
    private val mMutex = Mutex()

    /**
     * 调节亮度
     */
    private fun adjustLightness() {
        mLastLightnessY = mPointerMoveY
        CoroutineScope(Dispatchers.IO).launch {
            mMutex.tryLock()
            if (mCurrentBrightness in 0..255) {
                Settings.System.putInt(
                    context.contentResolver,
                    Settings.System.SCREEN_BRIGHTNESS,
                    mCurrentBrightness
                )
            } else {
                throw IllegalArgumentException("mCurrentBrightness超出了界限")
            }
            mMutex.unlock()
        }
    }

    /**
     * 绘制背景bitmap
     */
    private fun makeBackground(w: Int, h: Int): Bitmap {
        val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        paint.color = Color.parseColor("#CC050603")
        canvas.drawRoundRect(mBackgroundRectF, mCornerRadius, mCornerRadius, paint)
        ContextCompat.getDrawable(context, R.drawable.icon_light_mode)?.let {
            it.setBounds(
                (DensityUtils.dip2px(10) + mBackgroundRectF.left).toInt(),
                (mBackgroundRectF.bottom - DensityUtils.dip2px(32)).toInt(),
                (mBackgroundRectF.right - DensityUtils.dip2px(10)).toInt(),
                (mBackgroundRectF.bottom - DensityUtils.dip2px(12)).toInt()
            )
            it.draw(canvas)
        }
        return bitmap
    }
}

详解

背景和前景叠加绘制

就像手机上的亮度条、音量条一样,看起来是两个叠加上去的(大佬或许可以一次绘制完毕),背景我使用bitmap变量存下来了

/**
 * 绘制背景bitmap
 */
private fun makeBackground(w: Int, h: Int): Bitmap {
    val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    paint.color = Color.parseColor("#CC050603")
    canvas.drawRoundRect(mBackgroundRectF, mCornerRadius, mCornerRadius, paint)
    ContextCompat.getDrawable(context, R.drawable.icon_light_mode)?.let {
        it.setBounds(
            (DensityUtils.dip2px(10) + mBackgroundRectF.left).toInt(),
            (mBackgroundRectF.bottom - DensityUtils.dip2px(32)).toInt(),
            (mBackgroundRectF.right - DensityUtils.dip2px(10)).toInt(),
            (mBackgroundRectF.bottom - DensityUtils.dip2px(12)).toInt()
        )
        it.draw(canvas)
    }
    return bitmap
}

上面的R.drawable.icon_light_mode是我找的小太阳图标,标注为亮度条;DensityUtils是我的工具类,用来做屏幕适配和在代码中使用dp,这种写法还是麻烦了些,后面有时间优化一下;

前景也类似

canvas?.drawRoundRect(mLightnessBarRectF, mCornerRadius, mCornerRadius, mPaint)

但是要注意的是,我们对于前景表示进度的矩形条可能有不同的需求,比如上平下圆、上圆下圆等;这里我的需求是上圆下圆,但是也可以很简单过渡到上平下圆,关键代码就是这里:

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)

    if (canvas == null) return
    val sc = canvas.saveLayer(mBackgroundRectF, mPaint)
    drawBackground(canvas)
    mPaint.xfermode = mMode
    drawForeground(canvas)
    mPaint.xfermode = null
    canvas.restoreToCount(sc)
}

这里我的xfermode使用的是SRC_ATOP,这方面不懂的可以搜索相关文章,但是可以参考这里:android.googlesource.com/platform/de…

要注意val sc = canvas.saveLayer(mBackgroundRectF, mPaint)和canvas.restoreToCount(sc),如果没有这两行代码,就很有问题,xfermode会出现各种各样的故障。

滑动冲突

像是亮度条这种需求,放进RecyclerView里面很合理吧,那么RecyclerView滑动方向和亮度条的滑动方向一样不久冲突了,就是RecyclerView占用item的滑动。这里我使用的方法是

parent.requestDisallowInterceptTouchEvent

单单是这个方法并不管用,我还在RecyclerView中添加了如下代码:

var disallowIntercept = false
addOnItemTouchListener(object : OnItemTouchListener {
    override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
        return disallowIntercept
    }

    override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {

    }

    override fun onRequestDisallowInterceptTouchEvent(disallow: Boolean) {
        disallowIntercept = disallow
    }
})

onInterceptTouchEvent在return true的时候,RecyclerView就滑动不了,此时item就可以滑动了,关于横向纵向的滑动冲突,有很多文章有介绍。

另外还有一个方案,这是我前天尝试的方法,但是在写到一半看到了requestDisallowInterceptTouchEvent,然后摸到了RecylcerView的addOnItemTouchListener。

方案就是RecylcerView继承NestedScrollingParent3,itemView继承NestedScrollingChild3,这方面不了解的话建议看源码注释,不过我这里有一套RecyclerView继承NestedScrollingParent3的废案代码,还没验证(嗯,就是我前天写的),注释对照着源码注释翻译的,应该有翻译不到位的地方。

/**
 * 竖向
 */
class NestedScrollingRecyclerView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0,
): RecyclerView(context, attributeSet, defStyleAttr), NestedScrollingParent3 {
    /**
     * 仿RecyclerView中的mScrollingChildHelper
     */
    private var mScrollingParentHelper: NestedScrollingParentHelper? = null

    // NestedScrollingParent3

    /**
     * 对正在进行的嵌套滚动做出反应
     *
     * 当该Parent目前的child滑动分发滚动事件时调用。
     * 为了接收到滚动事件,比如在onStartNestedScroll时返回true;
     *
     * 滚动距离的消耗和未消耗部分都会报告给Parent;
     * 一种实例化可能会选择使用消耗的滚动距离来匹配或者chase多个子元素的滚动位置,
     * 例如:未消耗的滚动距离可能会被用于让多个滚动或可拖动元素进行连续拖动,
     * 比如在垂直drawer内滚动列表,一旦到达内部滚动内容的边缘,drawer就开始拖动;
     *
     * 当一个嵌套滚动的child调用dispatchNestedScroll时,本方法会被调用。
     *
     * 实例化必须通过添加消耗距离到consumed参数来报告多少x y滚动距离像素被此嵌套滚动Parent消耗了;
     * 如果此View也implements NestedScrollingChild3,那么消耗的滚动距离也应该传递给它的嵌套Parent滚动,
     * 以便Parent也可以添加它消耗的滚动距离;index 0对应dx,index 1对应dy
     *
     * @param target 控制嵌套滚动的child view
     * @param dxConsumed 被target消耗的像素级横向滚动距离
     * @param dyConsumed 被target消耗的像素级纵向滚动距离
     * @param dxUnconsumed target未消耗的像素级横向滚动距离
     * @param dyUnconsumed target未消耗的像素级纵向滚动距离
     * @param type 导致此滚动事件的输入type
     * @param consumed 输出。此方法返回后,将包含此嵌套滚动Parent(此View)消耗的滚动距离
     *                 以及视图层次结构中任何其他Parent消耗的滚动距离
     */
    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int,
        consumed: IntArray
    ) {
        onNestedScrollInternal(dyUnconsumed, type, consumed)
    }

    /**
     * 抄NestedScrollView的onNestedScrollInternal
     */
    private fun onNestedScrollInternal(dyUnconsumed: Int, type: Int, consumed: IntArray?) {
        val oldScrollY = scrollY
        scrollBy(0, dyUnconsumed)
        val myConsumed = scrollY - oldScrollY

        if (consumed != null) consumed[1] += myConsumed
        val myUnconsumed = dyUnconsumed - myConsumed

        // NestedScrollView的onNestedScrollInternal这里是不是有bug啊,
        // 它一个@Nullable的consumed居然没做区分,那我是不是可以提issue了。
        if (consumed != null) {
            dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed)
        } else {
            dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type)
        }
    }

    // NestedScrollingParent2

    /**
     * 对发起可嵌套的滚动操作的child view做出反应,
     * 在适当情况下声明嵌套滚动操作(意思就是支持哪种滚动,横向?纵向?亦或者不支持嵌套滚动)。
     *
     * 这个方法将会在child view调用startNestedScroll(View, int)时响应。
     * 每个视图结构层次上的Parent在都将有机会通过return true来响应和声明嵌套滚动操作
     *
     * 这个方法会被实例化的ViewParent重写,用于指示View什么时候会支持即将开始的嵌套滚动。
     * 如果return true,那么这个ViewParent就会成为target view滚动操作期间的嵌套滚动parent。
     * 当嵌套滚动结束之后,ViewParent将会收到对onStopNestedScroll(View, int)的调用
     *
     * @param child 这个ViewParent包含target在内的直接子级
     * @param target 发起嵌套滚动的View
     * @param axes 由ViewCompat.SCROLL_AXIS_HORIZONTAL、ViewCompat.SCROLL_AXIS_VERTICAL或
     *             二者组成的Flags(可以同时包含横向纵向吗?interesting)
     * @param type 导致本次滚动事件的输入type
     * @return true 如果ViewParent接收嵌套滚动操作
     */
    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
        return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
    }

    /**
     * 对成功声明嵌套滚动操作作出反应(就是onStartNestedScroll return true表示所支持的滚动操作)
     *
     * 在onStartNestedScroll return true之后就会调用这个方法,它为View及其父类提供了为执行嵌套滚动的初始配置的机会。
     * 此方法的实例化应始终调用其父类的此方法的实例化(如果存在)(这句话没看懂)
     *
     * @param child ViewParent包含target在内的直接子类
     * @param target 发起嵌套滚动的View
     * @param axes 由ViewCompat.SCROLL_AXIS_HORIZONTAL、ViewCompat.SCROLL_AXIS_VERTICAL或 二者组成的Flags
     * @param type 导致本次滚动事件的输入type
     */
    override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
        getScrollingParentHelper().onNestedScrollAccepted(child, target, axes, type)
        // 调用NestedScrollingChild的startNestedScroll
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type)
    }

    /**
     * 对嵌套滚动操作的结尾作出反应。
     *
     * 在嵌套滚动操作结束之后执行清理工作。当嵌套滚动操作停止之后,这个方法就会被调用。
     * 此方法的实例化应时钟被其父类的同方法实例化调用(如果存在)
     *
     * @param target 发起嵌套滚动的View
     * @param type 导致此次滚动事件的输入type
     */
    override fun onStopNestedScroll(target: View, type: Int) {
        getScrollingParentHelper().onStopNestedScroll(target, type)
        // 调用NestedScrollingChild的stopNestedScroll
        stopNestedScroll(type)
    }

    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int
    ) {
        onNestedScrollInternal(dyUnconsumed, type, null)
    }

    /**
     * 在target view消费滚动的一部分之前对正在进行的嵌套滚动做出反应。
     *
     * 当使用嵌套滚动时,parent view可能想要有一个机会在嵌套滚动child之前消费滚动。
     * 比如一个包含可滚动列表的drawer。用户希望能够在列表本身开始滚动之前将列表完全滚动到view中。
     *
     * 此方法会在一个正在嵌套滚动的child调用dispatchNestedPreScroll时调用。
     * The implementation should report how any pixels of the scroll reported by dx,
     * dy were consumed in the consumed array.(这句话看不懂)
     * index 0代表dx,index 1代表dy。这两个数值永远不等于null。初始值就是0
     *
     * @param target 发起嵌套滚动的View
     * @param dx 像素级横向滚动距离
     * @param dy 像素级纵向滚动距离
     * @param consumed 输出。被parent消费的横向和纵向滚动距离
     * @param type 导致此次滚动事件的输入type
     */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        dispatchNestedPreScroll(dx, dy, consumed, null, type)
    }

    // NestedScrollingParent

    override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
        return onStartNestedScroll(child, target, nestedScrollAxes, ViewCompat.TYPE_TOUCH)
    }

    override fun onNestedScrollAccepted(child: View, target: View, axes: Int) {
        onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH)
    }

    override fun onStopNestedScroll(child: View) {
        onStopNestedScroll(child, ViewCompat.TYPE_TOUCH)
    }

    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int
    ) {
        onNestedScrollInternal(dyUnconsumed, ViewCompat.TYPE_TOUCH, null)
    }

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
        onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH)
    }

    /**
     * 请求在嵌套滚动中滑动。
     *
     * 此方法表示嵌套滚动child已经检测到适合滑动的条件。
     * 通常来说这意味着以一定速度end的触摸滚动在滚动方向上满足或超过沿可滚动轴的最小滑动速度。
     *
     * 如果嵌套滚动child view愿意正常滑动,但是它已经处于它自身内容的边缘,
     * 那么它可以通过这个方法将滑动委托给它的嵌套滚动parent view。
     * parent可以选择消费此次滑动,或者观察child滑动
     *
     * @param target 发起嵌套滚动的View
     * @param velocityX 每秒的横向像素级速度
     * @param velocityY 每秒的纵向像素级速度
     * @param consumed true 如果child消费了这次滑动,否则false
     * @return true 如果这个parent消费了,false 以其他方式对滑动作出反应
     */
    override fun onNestedFling(
        target: View,
        velocityX: Float,
        velocityY: Float,
        consumed: Boolean
    ): Boolean {
        if (!consumed) {
            dispatchNestedFling(0f, velocityY, true)
            fling(0, velocityY.toInt())
            return true
        }
        return false
    }

    /**
     * 在target view消费嵌套滑动之前对它作出反应。
     *
     * 这个方法表示嵌套滚动child已经检测到沿每个axes上的给定速度滑动。
     * 通常来说这意味着以一定速度end的触摸滚动在滚动方向上满足或超过沿可滚动轴的最小滑动速度。
     *
     * 如果一个嵌套滚动parent正在消耗motion作为预滚动的一部分,那么它也可能适合消耗预滑动来完成相同的motion。
     * 通过return true,parent指示child也不应该滑动它自身的内部内容。
     *
     * @param target 发起嵌套滚动的View
     * @param velocityX 每秒的横向像素级速度
     * @param velocityY 每秒的纵向像素级速度
     * @return true 如果这个parent在target view之前消费了滑动
     */
    override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
        return dispatchNestedPreFling(velocityX, velocityY)
    }

    /**
     * 返回这个NestedScrollingParent的嵌套滚动axes
     *
     * 当返回的不是ViewCompat.SCROLL_AXIS_NONE时,NestedScrollingParent充当当前视图层次结构中一个或多个child
     * view的嵌套滚动Parent。
     *
     * @return 指示当前嵌套滚动axes的Flags
     */
    override fun getNestedScrollAxes(): Int = getScrollingParentHelper().nestedScrollAxes

    /**
     * 仿RecyclerView的getScrollingChildHelper()
     */
    private fun getScrollingParentHelper(): NestedScrollingParentHelper {
        if (mScrollingParentHelper == null) {
            mScrollingParentHelper = NestedScrollingParentHelper(this)
        }
        return mScrollingParentHelper!!
    }
}

结尾

这里我没解耦,没空,在家办公ing。

Android自定义View(一)——竖向SeekBar