kotlin学习之QQ消息气泡简单实现

kotlin学习之QQ消息气泡简单实现

为了不枯燥的学习kotlin,我们当然要搞点事情啦,手动@启舰大佬,看过大佬的QQ气泡实现后我有感而生,不行我要kotlin来搞一个,于是就搞了


别说了,献上效果图

kotlin学习之QQ消息气泡简单实现_第1张图片

来看看怎么实现的吧

直接上源码吧

class Ball : View {
    //可以被设置的属性
    var color: Int = DEFAULT_COLOR
        set(value) {
            field = value
            invalidate()
        }
    //显示的文本    
    var text: String? = null
        set(value) {
            if (value != null) {
            //超过三个字符报异常
                if (value.length > 3) throw IllegalArgumentException("text.length should in 0..3,now is ${value.length}")
                //非数字和加号报异常
                if (!value.matches("^[0-9+]*\$".toRegex())) throw IllegalArgumentException("text can only be number or '+'")
            }
            field = value
            invalidate()
        }
    var textColor = DEFAULT_TEXT_COLOR
        set(value) {
            field = value
            invalidate()
        }
    var radius = DEFAULT_RADIUS
        set(value) {
            field = value
            newRadius = field
            newDragRadius = field
            invalidate()
        }
    var textSize = radius
        set(value) {
            field = value
            invalidate()
        }
    private var _explode: (View) -> Unit = {}
    //爆炸回调
    fun onExplode(e: (View) -> Unit) {
        _explode = e
    }

    private var newRadius = radius //不动圆实时半径
    private var newDragRadius = radius
    private var startPoint = Point()//不动圆圆心
    private var pressPoint = Point()//触摸点
    private var isStart = false//是否开始拖动
    private val paint by lazy { Paint() }
    private var isDead = false//气泡是否爆炸

    private var textY = 0
        get() {
            val fm = paint.fontMetricsInt
            if (!isStart) return startPoint.y - fm.descent + (fm.bottom - fm.top) / 2 else return pressPoint.y - fm.descent + (fm.bottom - fm.top) / 2
        }

    constructor(context: Context?) : super(context)
    //构造器,从xml中获得各属性值
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        val typeArray = context.obtainStyledAttributes(attrs, R.styleable.Ball)
        color = typeArray.getColor(R.styleable.Ball_color, DEFAULT_COLOR)
        text = typeArray.getString(R.styleable.Ball_text)
        textColor = typeArray.getColor(R.styleable.Ball_textColor, DEFAULT_TEXT_COLOR)
        radius = typeArray.getInteger(R.styleable.Ball_radius, DEFAULT_RADIUS.toInt()).toFloat()
        textSize = typeArray.getInteger(R.styleable.Ball_textSize, radius.toInt()).toFloat()
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    init {
        paint.style = Paint.Style.FILL
        paint.isAntiAlias = true
        paint.textAlign = Paint.Align.CENTER
    }

    override fun onDraw(canvas: Canvas) {
        if (!isDead) {
            if (!isDisConnect) {
                paint.color = color
                canvas.drawCircle(startPoint.x.toFloat(), startPoint.y.toFloat(), newRadius, paint)
            }
            if (isStart) {
                paint.color = color
                canvas.drawCircle(pressPoint.x.toFloat(), pressPoint.y.toFloat(), newDragRadius, paint)
                if (!isDisConnect) {
                    canvas.drawPath(path, paint)
                }
            }
            if (text != null) {
                paint.color = textColor
                paint.textSize = textSize
                canvas.drawText(text, if (!isStart) startPoint.x.toFloat() else pressPoint.x.toFloat(), textY.toFloat(), paint)
            }
        }
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            ACTION_DOWN -> {
                pressPoint.x = event.x.toInt()
                pressPoint.y = event.y.toInt()
                if (isInCircle(pressPoint)) {
                    isStart = true
                }
            }
            ACTION_MOVE -> {
                if (isStart) {
                    pressPoint.x = event.x.toInt()
                    pressPoint.y = event.y.toInt()
                    calRadius(pressPoint)
                }
            }
            ACTION_UP -> {
                if (isStart) {
                    if (isDisConnect) {
                        isDead = true
                        isStart = false
                        _explode(this)
                    } else reBound()
                }

            }
        }
        invalidate()
        super.onTouchEvent(event)
        return true
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        setMeasuredDimension(getMeasuredLen(widthMeasureSpec, true), getMeasuredLen(heightMeasureSpec, false))

    }

    private fun getMeasuredLen(len: Int, isWidth: Boolean): Int {
        val specMode = MeasureSpec.getMode(len)
        val specSize = MeasureSpec.getSize(len)
        val padding = if (isWidth) paddingLeft + paddingRight else paddingBottom + paddingTop
        var measuredLen: Int
        if (specMode == MeasureSpec.EXACTLY) {
            measuredLen = specSize
        } else {
            measuredLen = if (isWidth) padding + DEFAULT_WIDTH else padding + DEFAULT_HEIGHT
            if (specMode == MeasureSpec.AT_MOST) {
                measuredLen = Math.min(measuredLen, specSize)
            }
        }
        if (isWidth) startPoint.x = measuredLen / 2 else startPoint.y = measuredLen / 2
        return measuredLen
    }

    private fun isInCircle(pressPoint: Point): Boolean {
        return pressPoint disTo startPoint < radius
    }

    private fun calRadius(pressPoint: Point) {
        newRadius = (radius - 0.1 * (pressPoint disTo startPoint)).toFloat()
        //newDragRadius = (radius + 0.1 * (pressPoint disTo startPoint)).toFloat()
    }

    private var path = Path()
        get() {
            val dx = (pressPoint.x - startPoint.x).toDouble()
            val dy = (pressPoint.y - startPoint.y).toDouble()
            //计算角度
            val angle = Math.atan(dy / dx)
            //起始点偏移
            val spOffsetX = newRadius * Math.sin(angle)
            val spOffsetY = newRadius * Math.cos(angle)
            //按压点偏移
            val ppOffsetX = newDragRadius * Math.sin(angle)
            val ppOffsetY = newDragRadius * Math.cos(angle)

            val point0 = Point((startPoint.x + spOffsetX).toInt(), (startPoint.y - spOffsetY).toInt())
            val point3 = Point((startPoint.x - spOffsetX).toInt(), (startPoint.y + spOffsetY).toInt())

            val point1 = Point((pressPoint.x + ppOffsetX).toInt(), (pressPoint.y - ppOffsetY).toInt())
            val point2 = Point((pressPoint.x - ppOffsetX).toInt(), (pressPoint.y + ppOffsetY).toInt())

            val controlPoint = Point((pressPoint.x + startPoint.x) / 2, (pressPoint.y + startPoint.y) / 2)

            field.reset()
            field.moveTo(point0.x.toFloat(), point0.y.toFloat())
            field.quadTo(controlPoint.x.toFloat(), controlPoint.y.toFloat(), point1.x.toFloat(), point1.y.toFloat())
            field.lineTo(point2.x.toFloat(), point2.y.toFloat())
            field.quadTo(controlPoint.x.toFloat(), controlPoint.y.toFloat(), point3.x.toFloat(), point3.y.toFloat())
            field.lineTo(point0.x.toFloat(), point0.y.toFloat())
            return field
        }

    private var isDisConnect: Boolean = false
        get() = newRadius < 10


    companion object {
        val DEFAULT_WIDTH = 80
        val DEFAULT_HEIGHT = 80
        val DEFAULT_RADIUS = 50F
        val DEFAULT_COLOR = 0xff2196f2.toInt()
        val DEFAULT_TEXT_COLOR = Color.WHITE
    }

    fun reBound() {
        val animtor = ValueAnimator.ofObject(PointEvaluator(), pressPoint, startPoint)
        animtor.addUpdateListener {
            animator ->
            pressPoint = animator.getAnimatedValue() as Point
            calRadius(pressPoint)
            invalidate()
        }

        animtor.addListener(object : Animator.AnimatorListener {
            override fun onAnimationRepeat(p0: Animator?) {

            }

            override fun onAnimationCancel(p0: Animator?) {

            }

            override fun onAnimationStart(p0: Animator?) {

            }

            override fun onAnimationEnd(p0: Animator?) {
                isStart = false
            }
        })
        animtor.duration = 300
        animtor.interpolator = MyOverShootInterpolator()
        animtor.start()
    }

    //初始化,这里可以重新设置一些属性
    fun reset(set: Ball.() -> Unit = {}) {
        this.set()
        isDead = false
        isStart = false
        isDisConnect = false
        newRadius = radius
        newDragRadius = radius
        invalidate()
    }

    class PointEvaluator : TypeEvaluator {
        override fun evaluate(p0: Float, p1: Point, p2: Point): Point {
            fun cal(start: Int, end: Int): Int {
                return (start + p0 * (end - start)).toInt()
            }
            return Point(cal(p1.x, p2.x), cal(p1.y, p2.y))
        }
    }

    //回弹插值器
    class MyOverShootInterpolator(val factor: Double) : Interpolator {
        constructor() : this(0.3)

        override fun getInterpolation(p0: Float): Float {
            return (Math.pow(2.0, (-5 * p0).toDouble()) * Math.sin((p0 - factor / 4) * (2 * Math.PI) / factor) + 1).toFloat()
        }

    }

    infix fun Point.disTo(a: Point): Int {
        return Math.sqrt(((this.x - a.x) * (this.x - a.x) + (this.y - a.y) * (this.y - a.y)).toDouble()).toInt()
    }
}

来分析一下这一坨东西吧

var color: Int = DEFAULT_COLOR
        set(value) {
            field = value
            invalidate()
        }

上面的可设置的属性都有类似这样的写法,这就是kotlin中的自定义setter,这样设置后,只要取这个变量的值就会自动调用这个set函数,那么field又是什么鬼,他就是color本身,其实field就是跳过set函数直接访问color本身,这样就避免了set中set的无线循环。里面还有个invalidate函数,这就只是为了设置后能马上更新。

字体居中

这个就厉害了

x轴居中是很好解决的,如下

paint.textAlign = Paint.Align.CENTER

y轴就费事了,怎么居中都没用,然后百度找到了这么一个方法

 private var textY = 0
        get() {
            val fm = paint.fontMetricsInt
            if (!isStart) return startPoint.y - fm.descent + (fm.bottom - fm.top) / 2 else return pressPoint.y - fm.descent + (fm.bottom - fm.top) / 2
        }

计算路径

这个,启舰大佬写的非常详细了,要看的话还是看大佬的好

仔细看就发现,ondraw中都没有getpath( ),其他地方也没有,但是路径却是实时计算的,这就要说setter的搭档了

 canvas.drawPath(path, paint)

没有getpath( ),只有path变量,同样,取变量值的时候都会调用get函数

private var path = Path()
        get() {...}

扩展函数

这里有这样的写法

pressPoint disTo startPoint//中缀表达式,返回两点距离

事实上代码中有这么一个函数,他给Point类添加了一个函数,这样就可以不修改Point本身而达到直接调用的效果

 fun Point.disTo(a: Point): Int {
        return Math.sqrt(((this.x - a.x) * (this.x - a.x) + (this.y - a.y) * (this.y - a.y)).toDouble()).toInt()
    }

但是像上面这样写还是不能用中缀表达式的写法,在fun前加上infix关键字就可以实现了,66的

回弹效果

这大概是最好玩的地方了,其实就是插值器的效果,Android官方有提供回弹插值器,但是只会弹一次,不够有趣,于是我重新写了一个回弹插值器

 //回弹插值器
    class MyOverShootInterpolator(val factor: Double) : Interpolator {
        constructor() : this(0.3)

        override fun getInterpolation(p0: Float): Float {
            return (Math.pow(2.0, (-5 * p0).toDouble()) * Math.sin((p0 - factor / 4) * (2 * Math.PI) / factor) + 1).toFloat()
        }

    }

关键就在这里,根据上一个进度p0计算出下一个进度

Math.sqrt(((this.x - a.x) * (this.x - a.x) + (this.y - a.y) * (this.y - a.y)).toDouble()).toInt()

附上函数图
kotlin学习之QQ消息气泡简单实现_第2张图片

就这样吧,细节都在启舰大佬

你可能感兴趣的:(android)