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

来源:互联网 发布:杭州大树网络金融 编辑:程序博客网 时间:2024/04/29 06:45

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

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


别说了,献上效果图

像果冻一样的泡泡

来看看怎么实现的吧

直接上源码吧

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<Point> {        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()

附上函数图
这里写图片描述

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