美文网首页
弧形进度条的实现与分析

弧形进度条的实现与分析

作者: GIndoc | 来源:发表于2019-05-05 11:56 被阅读0次

    该控件实现参考:https://www.gcssloop.com/tools/arc-seekbar

    先上一张效果图歪楼:


    渣图一张.gif

    接下来就是一步步实现啦,完整源码放在github(https://github.com/gindoc/ArcSeekBar)上了,关键实现步骤如下:

    1、首先得知道画弧,有两种方法:

    a、直接通过canvas画弧:

    canvas.drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter, @NonNull Paint paint)
    

    b、间接通过path画弧

    path.addArc(RectF oval, float startAngle, float sweepAngle)
    canvas.drawPath(path, paint)
    

    这里采用path画弧,因为后面方便画弧线的border。这里要注意一下,我们提供的rectF需要调整一下,因为paint的线宽问题会导致弧线显示不全,如下:

    不调整的代码

    arcRectF.set(0, 0, width, height)
    

    结果如下图

    弧线被吃了.png

    调整的代码

    val fix = arcPaint.strokeWidth/2
    arcRectF.set(fix, fix, width-fix, height-fix)
    
    这样就正常啦.png
    2、得知道怎么画边框吧

    关键代码就是paint.getFillPath(Path src, Path dst)
    其中src是我们圆弧的path, dst是用来存放边框的path的,拿到dst后,我们就可以画弧线的边框啦:

    arcPaint.getFillPath(arcPath, borderPath)
    borderPath.close()
    canvas?.drawPath(borderPath, borderPaint) 
    

    效果如下:


    卧槽,边框也被吃了.png

    原因是borderPaint的线宽导致的,所以我们还需要调整arcRectF,调整如下:

    val fix = arcPaint.strokeWidth/2 + borderPaint.strokeWidth/2
    arcRectF.set(fix, fix, width-fix, height-fix)
    ...   // 其他照旧
    

    效果就是这样的:


    又正常啦.png
    3、接着就是填充色

    首先,需要给arcPaint设置SweepGradient,因为弧线的颜色是渐变的。关键代码如下:

    val stopPos = sweepAngle / 360        // 弧线滑过的角度在360度中的位置[0,1]
    val len = mArcColors.size - 1
    val distance = stopPos / len
    val pos = FloatArray(mArcColors.size)
    for (i in mArcColors.indices) {
        pos[i] = distance * I                          // 每个颜色的位置
    }
    val gradient = SweepGradient(mCenterX, mCenterY, mArcColors, pos)
    arcPaint.shader = gradient
    

    画出来的结果如下:


    image.png

    WTF!!!什么鬼


    WTF.png

    这是因为我们的角度是从0度开始的(也就是上图中黄蓝交界那里),所以这里我们要将SweepGradient的起始位置旋转到弧线开始的地方,调整后的代码如下:

    val stopPos = sweepAngle / 360
    val len = mArcColors.size - 1
    val distance = stopPos / len
    val pos = FloatArray(mArcColors.size)
    for (i in mArcColors.indices) {
        pos[i] = distance * I
    }
    val gradient = SweepGradient(mCenterX, mCenterY, mArcColors, pos)
    val matrix = Matrix()
    matrix.setRotate(startAngle, mCenterX, mCenterY)  // 顺时针旋转到弧线开始的位置
    gradient.setLocalMatrix(matrix)
    arcPaint.shader = gradient
    

    运行结果如下:


    好像哪里怪怪的.png

    等等,好像哪里怪怪的,为神马蓝色屁股后面有一小截黄色???
    原因是线宽造成的,SweepGradient的颜色分布是0~360,而上图蓝黄交界处是360,那一小截黄色是小于360度的位置,所以显示了黄色。

    怎么解决呢?将SweepGradient的起始位置逆时针旋转一定的角度。
    旋转多少,可以旋转弧线长 arcPaint.strokeWidth/2 所占用的角度。

    代码入下:

    val stopPos = sweepAngle / 360
    val len = mArcColors.size - 1
    val distance = stopPos / len
    val pos = FloatArray(mArcColors.size)
    for (i in mArcColors.indices) {
        pos[i] = distance * I
    }
    val gradient = SweepGradient(mCenterX, mCenterY, mArcColors, pos)
    val matrix = Matrix()
    matrix.setRotate(startAngle - getStrokeDegree(), mCenterX, mCenterY)  // 顺时针旋转到弧线开始的位置
    gradient.setLocalMatrix(matrix)
    arcPaint.shader = gradient
    
    /**
         * 获取线宽占用的角度,因为画弧时线宽会导致突出一小部分
         */
        private fun getStrokeDegree(): Float {
            val path = Path()
            path.addArc(arcRectF, 0f, 360f)
            val tan = FloatArray(2)
            val pos = FloatArray(2)
            val pathMeasure = PathMeasure(path, false)
            pathMeasure.getPosTan(arcPaint.strokeWidth / 2, pos, tan)    // 可以考虑arcPaint.strokeWidth
            val degree = Math.atan2((tan[1]).toDouble(), (tan[0]).toDouble()) * 180.0 / Math.PI
            return Math.abs(degree - 90).toFloat()
        }
    
    后来补的一张图.png

    其实上图你仔细看会发现,蓝色下端还是有一丁点的黄色。考虑到是弧线,我们其实可以逆时针旋转弧线长为arcPaint.strokeWidth所占用的角度。

    4、滑块绘制
    // 计算滑块位置
        private fun computeThumbPos() {
            val distance = pathMeasure.length * mProgressPresent
            pathMeasure.getPosTan(distance, thumbPos, null)    // thumbPos是个数组,用于存储坐标
        }
    
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    
      var fix = arcPaint.strokeWidth/2 + borderPaint.strokeWidth/2
      fix = Math.max(fix, thumbPaint.strokeWidth / 2) + thumbShadowRadius  // arcRectF的范围也要再调整下
      arcRectF.set(fix, fix, width-fix, height-fix)
      ...   // 其他照旧
      computeThumbPos()
      ...
    }
    
    override fun onDraw(canvas: Canvas?) {
      ...
      thumbPaint.color = getColor()
      thumbPaint.setShadowLayer(thumbShadowRadius, 0f, 0f, thumbShadowColor) // 设置滑块阴影
      canvas?.drawPoint(thumbPos[0], thumbPos[1], thumbPaint)                // 画滑块
      thumbPaint.clearShadowLayer()
    }
    

    // 以下获取颜色代码摘自https://www.gcssloop.com/tools/arc-seekbar

    /**
         * 获取当前进度的具体颜色
         *
         * @return 当前进度在渐变中的颜色
         */
        fun getColor(): Int {
            return getColor(mProgressPresent)
        }
    
        /**
         * 获取某个百分比位置的颜色
         *
         * @param radio 取值[0,1]
         * @return 最终颜色
         */
        private fun getColor(radio: Float): Int {
            val diatance = 1.0f / (mArcColors.size - 1)
            val startColor: Int
            val endColor: Int
            if (radio >= 1) {
                return mArcColors[mArcColors.size - 1]
            }
            for (i in mArcColors.indices) {
                if (radio <= i * diatance) {
                    if (i == 0) {
                        return mArcColors[0]
                    }
                    startColor = mArcColors[i - 1]
                    endColor = mArcColors[I]
                    val areaRadio = getAreaRadio(radio, diatance * (i - 1), diatance * i)
                    return getColorFrom(startColor, endColor, areaRadio)
                }
            }
            return -1
        }
    
        /**
         * 计算当前比例在子区间的比例
         *
         * @param radio         总比例
         * @param startPosition 子区间开始位置
         * @param endPosition   子区间结束位置
         * @return 自区间比例[0, 1]
         */
        private fun getAreaRadio(radio: Float, startPosition: Float, endPosition: Float): Float {
            return (radio - startPosition) / (endPosition - startPosition)
        }
    
        /**
         * 取两个颜色间的渐变区间 中的某一点的颜色
         *
         * @param startColor 开始的颜色
         * @param endColor   结束的颜色
         * @param radio      比例 [0, 1]
         * @return 选中点的颜色
         */
        private fun getColorFrom(startColor: Int, endColor: Int, radio: Float): Int {
            val redStart = Color.red(startColor)
            val blueStart = Color.blue(startColor)
            val greenStart = Color.green(startColor)
            val redEnd = Color.red(endColor)
            val blueEnd = Color.blue(endColor)
            val greenEnd = Color.green(endColor)
    
            val red = (redStart + ((redEnd - redStart) * radio + 0.5)).toInt()
            val greed = (greenStart + ((greenEnd - greenStart) * radio + 0.5)).toInt()
            val blue = (blueStart + ((blueEnd - blueStart) * radio + 0.5)).toInt()
            return Color.argb(255, red, greed, blue)
        }
    

    效果如下:


    image.png

    注:如果滑块阴影没出现,要关闭硬件加速
    setLayerType(View.LAYER_TYPE_SOFTWARE, null) //对单独的View在运行时阶段禁用硬件加速

    5、最后就是手势交互

    首先是点击,采用GestureDetector处理点击事件:

    mDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
      override fun onSingleTapUp(e: MotionEvent?): Boolean {
        e?.let {
          // 判断是否点击在了进度区域
          if (!isInArcProgress(it.x, it.y)) return false
          // 点击允许突变
          mProgressPresent = getProgress(it.x, it.y)
          computeThumbPos()
        } ?: return false
          return true
        }
      })
    

    // 以下代码参考https://www.gcssloop.com/tools/arc-seekbar

    // 判断该点是否在进度条上面
        fun isInArcProgress(px: Float, py: Float): Boolean {
            val pos = floatArrayOf(px, py)
            return mArcRegion.contains(pos[0].toInt(), pos[1].toInt())
        }
    
    // 获取滑动进度
        private fun getProgress(px: Float, py: Float): Float {
            val diffAngle = getDiffAngle(px, py)
            var progress = diffAngle / sweepAngle
            log { "real progress: $progress    diffAngle:$diffAngle" }
            if (progress < 0) {
                progress = 0f
            } else if (progress > 1) {
                progress = 1f
            }
            return progress
        }
    
    
        private fun getDiffAngle(px: Float, py: Float): Float {
            val touchAngle = getAngle(px, py)
            val diffAngle = touchAngle - startAngle
    
            log { "progress touchAngle:$touchAngle      diffAngle:$diffAngle " }
            log { "progress diffAngleForStart:${Math.abs(diffAngle)}    diffAngleForEnd:${Math.abs((diffAngle + 360) % 360 - sweepAngle)}" }
    
            // 判断当前 触摸点 离 起点 的角度近,还是离 终点 的角度近
            if (Math.abs(diffAngle) < Math.abs((diffAngle + 360) % 360 - sweepAngle)) {                                                      // 离起点近
                return diffAngle
            } else {                                                            // 离终点近
                return (diffAngle + 360) % 360
            }
        }
    
        // 计算指定位置与内容区域中心点的夹角
        private fun getAngle(px: Float, py: Float): Float {
            var angle = (Math.atan2((py - mCenterY).toDouble(), (px - mCenterX).toDouble()) * 180 / Math.PI).toFloat()
            if (angle < 0) {
                angle += 360f
            }
            return angle
        }
    

    然后是滑动

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?.let {
            when (it.action) {
            MotionEvent.ACTION_DOWN -> judgeCanDrag(it)    // 点击时,判断是否可以拖动滑块
            MotionEvent.ACTION_MOVE -> {
              if (!canDrag) return true
              val tmpProgress = getProgress(it.x, it.y)
              log { "progress: $tmpProgress" }
              log { "progress      " }
    
              // 处理突变,当进度突变大于0.5时,不处理进度(出现场景: 从进度0 在弧线外滑动到 进度1)
              if (Math.abs(tmpProgress - mProgressPresent) > 0.5f) return true
                mProgressPresent = tmpProgress
                computeThumbPos()
              }
            }
        }
        mDetector.onTouchEvent(event)
        invalidate()
        return true
    }
    
    // 是否可以拖动滑块
        private fun judgeCanDrag(event: MotionEvent) {
            val x = event.x - thumbPos[0]
            val y = event.y - thumbPos[1]
            val distance = Math.sqrt((x * x + y * y).toDouble())
            canDrag = distance <= thumbPaint.strokeWidth / 2 * 1.5
        }
    

    完整代码见https://github.com/gindoc/ArcSeekBar
    最后还是要感谢博客https://www.gcssloop.com/tools/arc-seekbar提供的思路和帮助。

    相关文章

      网友评论

          本文标题:弧形进度条的实现与分析

          本文链接:https://www.haomeiwen.com/subject/saovnqtx.html