前语

上一篇文章用扇形图操练了一下安卓的多点触控,完成了单指旋转、二指放大、三指移动,四指以上一起按下进行复位的功用。今天这篇文章用很多应用常见的小红点,来操练一下贝塞尔曲线的运用。

需求

这儿主意来自QQ的拖动小红点撤销显示聊天条数功用,不过好像是记忆里的了,现在看了下好像作用变了。总而言之,便是一个小圆点,拖动的时分变成水滴状,超越必定规模后触发消失回调,核心思维如下:

  • 1、一个正方形view,中间是小红点,小红点间隔边框有必定间隔
  • 2、拖动小红点,小红点会变形,并发生尾焰作用
  • 3、开释时,如果在设定规模外小红点消失,规模内则康复

作用图

这儿作用在间隔小的时分,仍是不错的,当移动规模过大时,尽管水滴状的曲线仍是接连的,但是变形严重了,不过这个功用并不需要拖动太长间隔把,只需限定好消失规模,仍是能满足要求的。

自定义view实战(10):贝塞尔曲线绘制小红点

代码

import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.core.animation.addListener
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/**
 * 拖拽消失的小红点
 *
 * @author silence
 * @date 2022-11-07
 *
 */
class RedDomView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
): View(context, attributeSet, defStyleAttr) {
    companion object{
        const val STATE_NORMAL = 0
        const val STATE_DRAGGING = 1
        const val STATE_SETTING = 2
        const val STATE_FINISHED = 3
    }
    // 状况
    private var mState = STATE_NORMAL
    /**
     * 红点半径占控件宽高的份额
      */
    var domPercent = 0.25f
    /**
     * 红点消失的长度占最短宽高的份额
     */
    var disappearPercent = 0.25f
    /**
     * 消失回调
     */
    var listener: OnDisappearListener? = null
    // 半径
    private var mDomRadius: Float = 0f
    // 消失长度
    private var mDisappearLength = 0f
    // 滑动间隔和移动间隔的缩放份额
    private val mDraggingScale = 0.5f
    // 圆心所在位置
    private var mRadiusX = 0f
    private var mRadiusY = 0f
    // 上一次touch的点
    private var mLastX = 0f
    private var mLastY = 0f
    // 制作拖拽时的途径
    private val path = Path()
    // 康复的特点动画
    private val animator = ValueAnimator.ofFloat(0f, 1f)
    // 画笔
    private val mPaint = Paint().apply {
        strokeWidth = 5f
        color = Color.RED
        style = Paint.Style.FILL
        flags = Paint.ANTI_ALIAS_FLAG
    }
    /**
     * 重置
     */
    fun reset() {
        mState = STATE_NORMAL
        mRadiusX = width / 2f
        mRadiusY = height / 2f
        invalidate()
    }
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val width = getDefaultSize(100, widthMeasureSpec)
        val height = getDefaultSize(100, heightMeasureSpec)
        // 核算得到半径
        mDomRadius = (if (width < height) width else height) * domPercent
        mRadiusX = width / 2f
        mRadiusY = height / 2f
        // 消失长度
        mDisappearLength = (if (width < height) width else height) * disappearPercent
        setMeasuredDimension(width, height)
    }
    override fun onTouchEvent(event: MotionEvent): Boolean {
        // 完毕了不应该接受事件,经过设置OnClickListener运用reset去重置
        if (mState == STATE_FINISHED) {
            if (event.action == MotionEvent.ACTION_DOWN) performClick()
            else return true
        }
        when(event.action) {
            MotionEvent.ACTION_DOWN -> {
                mLastX = event.x
                mLastY = event.y
                // 设置中或者拖拽时,快速重新按下,应该再次接手动画
                if(mState != STATE_NORMAL) {
                    animator.removeAllListeners()
                    animator.cancel()
                }
                mState = STATE_DRAGGING
            }
            MotionEvent.ACTION_MOVE -> {
                // 注意canvas移动和手指移动是共同的,view的scroll移动的是窗口
                val dx = event.x - mLastX
                val dy = event.y - mLastY
                        // 移动圆心
                mRadiusX += dx * mDraggingScale
                mRadiusY += dy * mDraggingScale
                mLastX = event.x
                mLastY = event.y
                // 请求重绘
                invalidate()
            }
            MotionEvent.ACTION_UP -> {
                mState = STATE_SETTING
                // 这儿用特点动画模拟拖拽,回到初始圆心
                val upRadiusX = mRadiusX
                val upRadiusY = mRadiusY
                animator.addUpdateListener {
                    // 根据份额,按直线移动圆心到中点
                    val progress = it.animatedValue as Float
                    mRadiusX = upRadiusX + (width / 2f - upRadiusX) * progress
                    mRadiusY = upRadiusY + (height / 2f - upRadiusY) * progress
                    invalidate()
                }
                animator.addListener(onEnd = {
                    mState = STATE_NORMAL
                })
                animator.duration = 100
                animator.start()
            }
        }
        return true
    }
    @Suppress("RedundantOverride")
    override fun performClick(): Boolean {
        return super.performClick()
    }
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        when(mState) {
            STATE_NORMAL -> {
                // 正常状况是一个圆
                canvas.drawCircle(width / 2f, height / 2f, mDomRadius, mPaint)
            }
            STATE_DRAGGING, STATE_SETTING -> {
                // 圆心和中点连线相对于X轴的夹角,注意atan2是四象限灵敏[-PI, PI],atan规模为[-PI/2, PI/2]
                val radiansLine = atan2((mRadiusY - height / 2f).toDouble(),
                    (mRadiusX - width /2f).toDouble()).toFloat()
                // 圆心和中点连线的长度,经过角度算,分母为零为什么没问题?
                val lineLength = (mRadiusX - width /2f) / cos(radiansLine)
                // 判别是否达到消失要求,如果消失不应该再制作
                if (lineLength > mDisappearLength) {
                    mState = STATE_FINISHED
                    listener?.onDisappear()
                    return
                }
                // 以圆心为顶点,切点、圆心、中心的夹角值,是一个正值
                val radiansCenter = asin(mDomRadius / lineLength)
                // 切点和中心连线长度
                val length = lineLength * cos(radiansCenter)
                // 由角度获取两个切点的坐标值
                val x1 = width /2f + length * cos(radiansLine + radiansCenter)
                val y1 = height / 2f + length * sin(radiansLine + radiansCenter)
                val x2 = width /2f + length * cos(radiansLine - radiansCenter)
                val y2 = height / 2f + length * sin(radiansLine - radiansCenter)
                // 制作
                // 一般代码,一个圆加三角形
//                canvas.drawCircle(mRadiusX, mRadiusY, mDomRadius, mPaint)
//                path.reset()
//                path.moveTo(x1, y1)
//                path.lineTo(width / 2f, height / 2f)
//                path.lineTo(x2, y2)
//                path.close()
                // 强行贝塞尔曲线
                // 先用完整的圆掩盖lineLength < 2 * mDomRadius的情况,大于时圆会被掩盖
                canvas.drawCircle(mRadiusX, mRadiusY, mDomRadius, mPaint)
                path.reset()
                path.moveTo(x1, y1)
                // 拟合圆弧,三阶贝塞尔曲线,操控点在圆心和中点连线的圆外
                var tempX1 = x1 + (length * cos(radiansLine + radiansCenter))
                var tempY1 = y1 + ( length * sin(radiansLine + radiansCenter))
                var tempX2 = x2 + (length * cos(radiansLine - radiansCenter))
                var tempY2 = y2 + ( length * sin(radiansLine - radiansCenter))
                // 挨近圆不是圆
                path.cubicTo(tempX1, tempY1, tempX2, tempY2, x2, y2)
                // 尾焰,第一个操控点在切线延伸线上,第二个操控点在圆心连线上(越短尾越尖)
                tempX1 = x2 - length * cos(radiansLine - radiansCenter)
                tempY1 = y2 - length * sin(radiansLine - radiansCenter)
                tempX2 = width / 2f + (lineLength * 0.25f * cos(radiansLine))
                tempY2 = height / 2f + (lineLength * 0.25f * sin(radiansLine))
                // 第一条
                path.cubicTo(tempX1, tempY1, tempX2, tempY2, width / 2f, height / 2f)
                // 另一段
                tempX1 = tempX2
                tempY1 = tempY2
                tempX2 = x1 - (length * cos(radiansLine + radiansCenter))
                tempY2 = y1 - ( length * sin(radiansLine + radiansCenter))
                path.cubicTo(tempX1, tempY1, tempX2, tempY2, x1, y1)
                path.close()
                canvas.drawPath(path, mPaint)
            }
            STATE_FINISHED -> {}
        }
        // 这儿便于调试,把消失规模画一下,多加一只画笔,省的麻烦
        canvas.drawCircle(width / 2f, height / 2f, mDisappearLength, tempPaint)
    }
    private val tempPaint = Paint().apply {
        strokeWidth = 3f
        style = Paint.Style.STROKE
        color = Color.LTGRAY
        pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
        flags = Paint.ANTI_ALIAS_FLAG
    }
    interface OnDisappearListener{
        fun onDisappear()
    }
}

首要问题

关于onMeasure、onTouchEvent以及onDraw的内容就不讲了,这儿已经是第十篇自定义view的文章了,下面首要介绍下贝塞尔曲线制作水滴状的功用。

简略画法

这儿最简略的画法便是用一个圆和一个三角形处理了。每次移动对小圆点移动,然后核算得到view中心在圆上的两个切点,将两个切点和view中心围起来画一个实心的三角形,组合起来的作用便是一个近似的小水滴了。

运用贝塞尔曲线

要完成更传神的作用,运用直线是必定不可的了,这儿就要用到曲线了。首先想到的便是弧线了,可是用弧线和上面的圆是没去别的,后面我就直接全用贝塞尔曲线做了。

我这把这个水滴形状的小红点分了三段,都是用三阶的贝塞尔曲线画的,制作的时分最重要的便是找操控点了。首先要知道贝塞尔曲线的临近操控点和端点的连线,便是曲线在该端点的切线,要确保三段线的接连,确保三段线在同一端点的切线共同就行。这儿最上面的那段类似圆弧的曲线,就取了切线延伸线上的点作为操控点,尾焰那段取切线内上的点,这样在(x1, y1)(x2, y2)上就接连了,至于操控点间隔端点间隔取值的巨细就试着取看作用了。剩下在view中点那侧的操控点,就取在中点和圆心上,这样水滴的尾巴看起来就顺眼。

几个操控点的选取和展示的作用相关性很大,我觉得我选的点看起来还行。