自定义view实战(9):多点触控扇形图

631 阅读7分钟
前言

上一篇做了一个简单的六边形评分控件,主要对paint的api熟悉了一下,本来还想对六边形控件加入放大旋转的功能,但是paint的api内容够多了,就算了。今天把上一篇的放大旋转功能加到了这篇文章的扇形图里面,对安卓的多点触控学习了一下。

需求

这里就是用传入的数据画一个扇形图,但是画完扇形图后,能够支持多点触控,使用一只手指滑动时触发旋转,两只手指时触发缩放,三指手指时触发移动,四只手指以上时触发重置。核心思想如下:

  • 1、外面传入数据,实现扇形图展示
  • 2、扇形图能够单指旋转、二指放大、三指移动,四指以上同时按下进行复位
  • 3、旋转、放大、平移效果能够叠加

效果图

这里效果图并不是很好,因为我这手机是淘宝买的屏幕自己换的,会断触。而且我发现多个手指这样配合功能上也是有冲突的,多个手指逐步离开屏幕时会到不同的状态,任何微小的移动,都会触发MOVE事件,进而造成不正常的变化,不知道其他有这种功能的控件怎么处理的,等有时间得研究研究。 图片

代码

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.View
import kotlin.math.*

/**
 * 多点触控饼状图
 *
 * @author silence
 * @date 2022-11-04
 */
class PieChartView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
): View(context, attributeSet, defStyleAttr) {

    companion object{
        // 没有手指按下状态
        const val STATE_NORMAL = 0
        // 单指按下,进行旋转
        const val STATE_ROTATING = 1
        // 双指按下,进行缩放
        const val STATE_SCALING = 2
        // 三指按下,进行移动
        const val STATE_MOVING = 3
        // 三指以上按下,进行复位
        const val STATE_RESETTING = 4
    }

    /**
     * 数据,所占比例根据单位占总数计算
     */
    var data: MutableList<Int>? = null
    set(value) {
        field = value
        calculatePercent()
    }

    // 用于图表绘制的数据
    private val mPieData: MutableList<Triple<Int, Float, Int>> = ArrayList()

    // 半径占最小边框的比例
    private val mRadiusPercent = 0.8f

    // 画笔
    private val mPaint: Paint = Paint().apply {
        // 颜色
        color = Color.BLACK
        // 粗细,设置为0时无论怎么放大 都是1像素
        strokeWidth = 5f
        // 抗锯齿
        flags = Paint.ANTI_ALIAS_FLAG
        // 填充模式
        style = Paint.Style.FILL
    }

    // 矩形, 绘制弧形需要用到
    private var mRectF: RectF = RectF()

    // 中点坐标
    private var mCenterX: Int = 0
    private var mCenterY: Int = 0

    // 圆的半径
    private var mRadius: Float = 0f

    // 状态
    private var mState = STATE_NORMAL

    // 单指情况
    // 开始坐标
    private var mLastX = 0f
    private var mLastY = 0f
    private var mDegree = 0f
    private var mCountDegree = 0f

    // 双指情况
    // 第二个手指按下时两指间的距离,即初始距离,放大比例以此为基准
    private var mFirstDistance = 1f
    private var mCurrentDistance = 1f

    // 三指情况
    // 上一次三点中心
    private var mLastTripleCenterX = 0f
    private var mLastTripleCenterY = 0f
    // 本次移动值
    private var mMoveX = 0f
    private var mMoveY = 0f
    // 累计移动值
    private var mCountMoveX = 0f
    private var mCountMoveY = 0f


    // 计算比例
    private fun calculatePercent() {
        var count = 0
        val colors = arrayOf(Color.BLUE, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN)
        // 计算总数
        for (i in data!!) {
            count += i
        }
        // 填入比例
        var color = colors[0]
        var lastColor = colors[1]
        for (i in data!!) {
            // 避免相邻颜色相同
            while (color == lastColor) {
                color = colors[(Math.random() * colors.size).toInt()]
            }
            mPieData.add(Triple(i, i / count.toFloat(), color))
            lastColor = color
        }

        Log.e("TAG", "calculatePercent: mPieData=$mPieData")
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        // 自定义view要设置好默认大小
        val width = getDefaultSize(100, widthMeasureSpec)
        val height = getDefaultSize(100, heightMeasureSpec)

        // 由控件宽高获得中心点坐标
        mCenterX = width / 2
        mCenterY = height / 2

        // 半径,设置为最小宽度的80%
        mRadius = (if (mCenterX < mCenterY) mCenterX else mCenterY) * mRadiusPercent

        // 绘制的矩形
        mRectF.set(mCenterX - mRadius, mCenterY - mRadius,
            mCenterX + mRadius, mCenterY + mRadius)

        setMeasuredDimension(width, height)
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(ev: MotionEvent): Boolean {
        when(ev.actionMasked) {
            // 第一个点按下
            MotionEvent.ACTION_DOWN -> {
                //Log.e("TAG", "ACTION_DOWN --> 1")
                // 一只手指时旋转
                mState = STATE_ROTATING
                mLastX = ev.x
                mLastY = ev.y
            }
            // 第二个或者以上的点按下
            MotionEvent.ACTION_POINTER_DOWN -> {
                when (ev.pointerCount) {
                    2 -> {
                        //Log.e("TAG", "ACTION_POINTER_DOWN --> 2")
                        mState = STATE_SCALING
                        // 两指时计算初始距离
                        mFirstDistance = getDistance(
                            ev.getX(0), ev.getY(0),
                            ev.getX(1), ev.getY(1))
                        mCurrentDistance = mFirstDistance
                    }
                    3 -> {
                        Log.e("TAG", "ACTION_POINTER_DOWN --> 3")
                        mState = STATE_MOVING
                        // 三指时计算三点的中心
                        val pair = getTripleCenter(
                            ev.getX(0), ev.getY(0),
                            ev.getX(1), ev.getY(1),
                            ev.getX(2), ev.getY(2))
                        mLastTripleCenterX = pair.first
                        mLastTripleCenterY = pair.second
                        Log.e("TAG", "ACTION_POINTER_DOWN --> ($mLastTripleCenterX, $mLastTripleCenterY)")
                    }
                    else -> {
                        //Log.e("TAG", "ACTION_POINTER_DOWN --> 4+")
                        mState = STATE_RESETTING
                        // 更改状态,刷新即可
                        invalidate()
                    }
                }
            }
            // 所有的点移动
            MotionEvent.ACTION_MOVE -> {
                // 移动时处理,如果需要跟踪手指,需要用到actionIndex、PointerId、PointerIndex
                if (ev.pointerCount == 1) {
                    //Log.e("TAG", "ACTION_MOVE --> 1")
                    // 当前点和上一次的点计算角度
                    mDegree = getDegree(mLastX, mLastY, ev.x, ev.y).toFloat()
                    mLastX = ev.x
                    mLastY = ev.y
                    invalidate()
                }else if (ev.pointerCount == 2) {
                    //Log.e("TAG", "ACTION_MOVE --> 2")
                    val newDistance = getDistance(
                        ev.getX(0), ev.getY(0),
                        ev.getX(1), ev.getY(1))

                    // 缩小放大,限定些范围减少调用,不用touchSlop值太小了
                    if (abs(mCurrentDistance - newDistance) > 5) {
                        // 更新
                        mCurrentDistance = newDistance
                        // 不使用scaleX和scaleY,直接onDraw里面自己处理
                        invalidate()
                    }
                }else if(ev.pointerCount == 3) {
                    //Log.e("TAG", "ACTION_MOVE --> 3")
                    // 三指时计算三点的中心
                    val pair = getTripleCenter(
                        ev.getX(0), ev.getY(0),
                        ev.getX(1), ev.getY(1),
                        ev.getX(2), ev.getY(2))

                    mMoveX = pair.first - mLastTripleCenterX
                    mMoveY = pair.second - mLastTripleCenterY
                    // 限定移动范围
                    val maxLengthX = width / 2f - mRadius
                    val maxLengthY = height / 2f - mRadius
                    if ((mCountMoveX + mMoveX) >= -maxLengthX ||
                        (mCountMoveX + mMoveX) <= maxLengthX ||
                        (mCountMoveY + mMoveY) >= -maxLengthY ||
                        (mCountMoveY + mMoveY) <= maxLengthY) {
                        mLastTripleCenterX = pair.first
                        mLastTripleCenterY = pair.second
                        //Log.e("TAG", "ACTION_POINTER_DOWN --> ($mLastTripleCenterX, $mLastTripleCenterY)")
                        invalidate()
                    }else{
                        mMoveX = 0f
                        mMoveY = 0f
                    }
                }
            }
            // 非最后一个点抬起
            MotionEvent.ACTION_POINTER_UP -> {
                //Log.e("TAG", "ACTION_POINTER_UP --> ${ev.pointerCount}")
                // 经过测试,手指抬起时还未更改pointerCount
                when (ev.pointerCount) {
                    2 -> mState = STATE_ROTATING
                    3 -> mState = STATE_SCALING
                    4 -> mState = STATE_MOVING
                }
            }
            // 最后一个点抬起
            MotionEvent.ACTION_UP -> {
                //Log.e("TAG", "ACTION_UP --> 1")
                mState = STATE_NORMAL
            }
        }

        // 注意拦截事件,至少拦截ACTION_DOWN
        return true
    }

    private fun getDistance(x1: Float, y1: Float, x2: Float, y2: Float): Float {
        // 平方和公式
        @Suppress("ReplaceJavaStaticMethodWithKotlinAnalog")
        return sqrt((Math.pow((x1 - x2).toDouble(), 2.0)
                + Math.pow((y1 - y2).toDouble(), 2.0)).toFloat())
    }

    private fun getTripleCenter(x1: Float, y1: Float, x2: Float, y2: Float, x3: Float, y3: Float)
        : Pair<Float, Float> {
        val x = (x1 + x2 + x3) / 3
        val y = (y1 + y2 + y3) / 3
        return Pair(x, y)
    }

    private fun getDegree(x1: Float, y1: Float, x2: Float, y2: Float): Double {
        // 带上移动偏移量的圆心
        val centerX = mCenterX + mCountMoveX
        val centerY = mCenterY + mCountMoveY

        // 起点角度
        val radians1 = atan2(y1 - centerY, x1 - centerX).toDouble()
        // 终点角度
        val radians2 = atan2(y2 - centerY, x2 - centerX).toDouble()
        // 从弧度转换成角度
        // Log.e("TAG", "getDegree: $degree")
        return Math.toDegrees(radians2 - radians1)
    }

    override fun onDraw(canvas: Canvas) {
        // 混合效果
        if (mState != STATE_RESETTING) {
            // 对canvas进行旋转,注意每次canvas都会复位,所以要用累加值
            if (mState == STATE_ROTATING) {
                mCountDegree += mDegree
            }
            canvas.rotate(mCountDegree, mCenterX.toFloat() + mCountMoveX,
                mCenterY.toFloat() + mCountMoveY)


            // 对canvas进行缩放
            val scale = mCurrentDistance / mFirstDistance
            canvas.scale(scale, scale, mCenterX.toFloat(), mCenterY.toFloat())


            // 对canvas进行移动
            if(mState == STATE_MOVING) {
                mCountMoveX += mMoveX
                mCountMoveY += mMoveY
            }
            canvas.translate(mCountMoveX, mCountMoveY)
        }else {
            // 重置
            mCountMoveX = 0f
            mCurrentDistance = mFirstDistance
            mCountMoveX = 0f
            mCountMoveY = 0f
        }

        // 绘制圆弧
        var angleCount = 0f
        for (peer in mPieData) {
            val angle = 360 * peer.second
            mPaint.color = peer.third
            canvas.drawArc(mRectF, angleCount, angle, true, mPaint)
            angleCount += angle
        }
    }
}

主要问题

扇形图的功能就没什么好说的了,下面主要讲讲多点触控以及绘制的问题。

MotionEvent的action和actionMasked

在处理TouchEvent的时候,我们一般时通过MotionEvent的action来判断事件类型,可是使用这种方法获得的事件类型只有DOWN、MOVE、UP三种事件,当需要多点触控的时候就不能用action来判断了,而是应该用actionMasked。使用actionMasked才能拿到ACTION_POINTER_DOWN和ACTION_POINTER_UP事件。

下面是两种方法get的源码:

    public final int getAction() {
        return nativeGetAction(mNativePtr);
    }

    public final int getActionMasked() {
        return nativeGetAction(mNativePtr) & ACTION_MASK;
    }
ACTION_DOWN和ACTION_POINTER_DOWN

在使用actionMasked后,我们就能拿到ACTION_POINTER_DOWN事件,这个事件是在第二个或者以上的手指按下的时候才触发。但是第一个手指触发还是在ACTION_DOWN事件,所以处理多个手指时,还得在两个事件中都要处理下。

ACTION_MOVE

多点触控的按下事件和抬起事件都会另外处理,但是ACTION_MOVE是不会另外处理的,所有的MOVE事件都会在ACTION_MOVE中触发。

MotionEvent的pointerCount

由于上面ACTION_MOVE的原因,我们如何在其中判断是几个手指呢?这里就要用到pointerCount了,它能够获得当前按下手指的个数,所以当你不需要跟踪手指,只需要判断有几个的问题时,用它就可以了,如果还要跟踪手指那就要看下面的几个参数了。

actionIndex、PointerId、PointerIndex

这里可以看看我上一篇转载的文章:Android多点触控详解,里面写的还不错。

我这就简单讲讲,actionIndex就是一个会复用的action的下标,还会自动改变值保持顺序(中间抬起,紧挨着的后面补上),但是没法在ACTION_MOVE中使用。PointerId是唯一的,每一个手指事件序列的PointerId是不会变的(actionIndex是会改变的),但是新增的PointerId会填充前面空缺的位置。PointerIndex的作用就是在ACTION_MOVE中替代actionIndex,因为在ACTION_MOVE中actionIndex一直是0,但还是要有一个按下手指的下标的,那就是PointerIndex了。

所以如果要跟踪手指的移动情况,就需要用到pointerId和pointerIndex了,配合下面两个方法就能处理了。

ev.findPointerIndex(pointerId)
ev.getPointerId(pointerIndex)
一指旋转

上面多点触控讲的差不多了,要判断一指的移动事件,只需要在ACTION_MOVE处理pointerCount等于1时的情况。旋转最重要的就是旋转角度,这里只需要知道起始两点相对于X坐标的夹角就行,两个角度一减就是旋转角度了。算的角度后,使用invalidate()触发更新,在onDraw里对canvas rotate就行了。

这里要注意下每次canvas都会复位,所以要自己统计一个累加值。

双指放大

和旋转类似,只不过要在ACTION_POINTER_DOWN的pointerCount等于2时获得初始的距离,之后再ACTION_MOVE中再获得新的距离,并触发更新,在onDraw里对canvas放大就行。

三指移动

这个也类似双指放大,在ACTION_POINTER_DOWN中获得三个点时的中心,在ACTION_MOVE中更新中心,触发移动,在onDraw里对canvas进行translate。(ps. 我发现范围限定没有用,不知道怎么回事)

四指以上重置

本来我以为重置会很复杂的,后面发现每次canvas都会复位,所以只有在onDraw中判断下是否是STATE_RESETTING事件,是的话把累加值全部置零就可以了。

多种状态累加

在多种状态累加的时候,状态这种东西就有用了。在onDraw中,当属于自己状态的时候累加值,当不是自己状态时,按旧的累加值处理canvas就行了。