在uplabs上看到一个设计师设计录音备忘录的设计。

动态图很惊艳:


目前比较不满意的是背景气泡冒出时的粘连效果不是很理性。点击之后变成水滴向上移动的动画没有实现。另外,有的地方计算量比较大,没有优化,极少情况下出现卡顿。如果要真正运用到项目中的话,还需要继续优化。哦,对了没有实现padding啊什么的,还有颜色的变化都是直接写在了代码里面。
首先分析一下这个效果的各个部分。 1.有两坨重叠的,DuangDuang的东西,第一层是纯色,第二层的半透明。 2.这两坨东西有一种蠕动的效果。 3.这两坨东西的大小是变化的。 4.时不时有气泡从背景冒出。 5.气泡冒出时有粘连效果。 6.对了,开始点击的时候有一种果冻的弹性效果。
然后完整代码如下:
package com.greendami.View
import android.content.Context
import android.graphics.*
import android.support.animation.DynamicAnimation
import android.support.animation.SpringAnimation
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import com.greendami.pptimer.R
/**
* Created by GreendaMi on 2017/8/3.
*/
class PPBubble(context: Context?, attrs: AttributeSet?) : DynamicAnimation.OnAnimationUpdateListener, View(context, attrs) {
override fun onAnimationUpdate(animation: DynamicAnimation<out DynamicAnimation<*>>?, value: Float, velocity: Float) {
scaleY = value
//点击动画完成
if (value == 1f) {
canUpdatePoints = true
}
}
private var velocityTracker: VelocityTracker = VelocityTracker.obtain()
private var downX: Float = 0.toFloat()
private var downY: Float = 0.toFloat()
val animX = SpringAnimation(this, SpringAnimation.SCALE_X, 1f)
var mPaint = Paint()
var scale = 0f
var mPoints = ArrayList<PPPoint>()
var mRs = ArrayList<Int>()//用于计算个点位置
var mRads = ArrayList<Float>()//用于计算速度
var bPoints = ArrayList<PPPoint>()
var bRs = ArrayList<Int>()//用于计算个点位置
var bRads = ArrayList<Float>()//用于计算速度
var mBubbles = ArrayList<Bubble>()
var mR = 0
var mEffect: PathEffect = CornerPathEffect(0f)//平滑过渡的角度
var startRads = 60f
var mColors = ArrayList<Int>()
var canUpdatePoints = false
var colorIndex = 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
initPaint()
mR = measuredWidth / 8
mEffect = CornerPathEffect(mR / 2f)
creatPoints(mPoints, mRs, mRads)
creatPoints(bPoints, bRs, bRads)
animX.addUpdateListener(this)
mColors.add(context.resources.getColor(R.color.c1))
mColors.add(context.resources.getColor(R.color.c2))
mColors.add(context.resources.getColor(R.color.c3))
mColors.add(context.resources.getColor(R.color.c4))
mColors.add(context.resources.getColor(R.color.c5))
mColors.add(context.resources.getColor(R.color.c6))
mColors.add(context.resources.getColor(R.color.c5))
mColors.add(context.resources.getColor(R.color.c4))
mColors.add(context.resources.getColor(R.color.c3))
mColors.add(context.resources.getColor(R.color.c2))
mColors.add(context.resources.getColor(R.color.c1))
}
private fun creatPoints(mPoints: ArrayList<PPPoint>, mRs: ArrayList<Int>, mRads: ArrayList<Float>) {
var r0 = mR
var r1 = mR
var r2 = mR
var r3 = mR
var r4 = mR
var r5 = mR
mRs.add(r0)
mRs.add(r1)
mRs.add(r2)
mRs.add(r3)
mRs.add(r4)
mRs.add(r5)
mRs.forEach { mRads.add((Math.random() * 360).toFloat()) }
var p0 = PPPoint(measuredWidth / 2 + r0.toFloat(), measuredHeight / 2f)
var p1 = PPPoint(measuredWidth / 2 + r1 / 2f, (measuredHeight / 2f - r1 * Math.sqrt(3.0) / 2f).toFloat())
var p2 = PPPoint(measuredWidth / 2 - r2 / 2f, (measuredHeight / 2f - r2 * Math.sqrt(3.0) / 2f).toFloat())
var p3 = PPPoint(measuredWidth / 2 - r3.toFloat(), measuredHeight / 2f)
var p4 = PPPoint(measuredWidth / 2 - r4 / 2f, (measuredHeight / 2 + r4 * Math.sqrt(3.0) / 2f).toFloat())
var p5 = PPPoint(measuredWidth / 2 + r5 / 2f, (measuredHeight / 2 + r5 * Math.sqrt(3.0) / 2f).toFloat())
mPoints.add(p0)
mPoints.add(p1)
mPoints.add(p2)
mPoints.add(p3)
mPoints.add(p4)
mPoints.add(p5)
}
private fun initPaint() {
mPaint.reset()
mPaint.isAntiAlias = true
mPaint.pathEffect = mEffect
mPaint.color = context.resources.getColor(R.color.colorPrimary)
mPaint.style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
var co = mPaint.color
mPaint.color = co + 0x222222
if (canUpdatePoints) {
mPaint.pathEffect = null
//画泡泡
drawBubble(canvas)
}
mPaint.color = co + 0x222222
mPaint.pathEffect = mEffect
//画背景
drawCircle(canvas, bPoints)
mPaint.color = co
drawCircle(canvas, mPoints)
if (canUpdatePoints) {
updatePoints(bPoints, bRs, bRads, (mR * 1.2f).toInt(), scale)
updatePoints(mPoints, mRs, mRads, mR, scale)
scale += 2
if (scale % 360f == 0f) {
mPaint.color = getNextColor()
scale = 0f
Log.e("TAg", "变色!")
}
}
postInvalidate()
}
private fun drawBubble(canvas: Canvas?) {
//此处控制泡泡的数量
if (mBubbles.size < 5) {
var bubble = Bubble()
bubble.x = width / 2f
bubble.y = height / 2f
bubble.rad = (Math.random() * 360).toFloat()
bubble.distance = 0f
bubble.speed = (Math.random() * 3).toFloat()
bubble.r = mR * 0.4f + (Math.random() * mR * 0.1f).toFloat() * bubble.speed
mBubbles.add(bubble)
// Log.e("TAg", "生产一个泡泡")
}
mBubbles.forEach {
it.r -= 0.4f
it.distance += it.speed
it.x = width / 2f + it.distance * Math.cos(toRadians(it.rad)).toFloat()
it.y = height / 2f - it.distance * Math.sin(toRadians(it.rad)).toFloat()
}
var tempBubbles = ArrayList<Bubble>()
mBubbles.forEach {
if (it.r > 0f) {
tempBubbles.add(it)
}
}
mBubbles = tempBubbles
mBubbles.forEach {
canvas?.drawCircle(it.x, it.y, it.r, mPaint)
var mR = getRecentR()
if (it.distance < this.mR + it.r * 1) {
//小球的切点
var th = toRadians(it.rad) - Math.acos(((mR - it.r) / it.distance).toDouble())
var th2 = toRadians(it.rad) + Math.acos(((mR - it.r) / it.distance).toDouble())
var x1 = (-Math.cos(th) * it.r + it.x).toFloat()
var y1 = (Math.sin(th) * it.r + it.y).toFloat()
var x2 = (-Math.cos(th2) * it.r + it.x).toFloat()
var y2 = (Math.sin(th2) * it.r + it.y).toFloat()
var x3 = (Math.cos(th2) * mR + width / 2).toFloat()
var y3 = (-Math.sin(th2) * mR + height / 2).toFloat()
var x4 = (Math.cos(th) * mR + width / 2).toFloat()
var y4 = (-Math.sin(th) * mR + height / 2).toFloat()
var cx = (it.x + width / 2) / 4
var cy = (it.y + height / 2) / 4
var cx1 = (it.x + x4) / 4 + cx
var cy1 = (it.y + y4) / 4 + cy
var cx2 = (it.x + x3) / 4 + cx
var cy2 = (it.y + y3) / 4 + cy
// var cx1 = ((it.x + x4) / 2).toFloat()
// var cy1 = ((it.y + y4) / 2).toFloat()
// var cx2 = ((it.x + x3) / 2).toFloat()
// var cy2 = ((it.y + y3) / 2).toFloat()
var p = Path()
p.moveTo(it.x, it.y)
p.lineTo(x2, y2)
p.cubicTo(x2, y2, cx1, cy1, x4, y4)
p.lineTo(x3, y3)
p.cubicTo(x3, y3, cx2, cy2, x1, y1)
p.lineTo(it.x, it.y)
p.close()
canvas?.drawPath(p, mPaint)
// mPaint.alpha = 255
// canvas?.drawCircle(cx1.toFloat(), cy1.toFloat(), 4f, mPaint)
// canvas?.drawCircle(cx2.toFloat(), cy2.toFloat(), 4f, mPaint)
// mPaint.color = context.resources.getColor(R.color.abc_btn_colored_borderless_text_material)
// canvas?.drawCircle(x1.toFloat(), y1.toFloat(), 4f, mPaint)
// mPaint.color = context.resources.getColor(R.color.background_floating_material_dark)
// canvas?.drawCircle(x2.toFloat(), y2.toFloat(), 4f, mPaint)
// mPaint.strokeWidth = 4f
// mPaint.color = context.resources.getColor(R.color.colorPrimary)
// canvas?.drawLine(width / 2f, height / 2f, it.x, it.y, mPaint)
// mPaint.color = context.resources.getColor(R.color.abc_btn_colored_borderless_text_material)
// canvas?.drawLine(x3.toFloat(), y3.toFloat(), x4.toFloat(), y4.toFloat(), mPaint)
// mPaint.color = context.resources.getColor(R.color.colorPrimary)
// canvas?.drawLine(x3.toFloat(), y3.toFloat(), width / 2f, height / 2f, mPaint)
// canvas?.drawLine(width / 2f, height / 2f, x4.toFloat(), y4.toFloat(), mPaint)
}
}
}
private fun getRecentR(): Int {
var r = mRs[0]
mRs.forEach { if (it < r) r = it }
return r
}
private fun getNextColor(): Int {
if (colorIndex == mColors.size - 1) {
colorIndex = 0
} else {
colorIndex++
}
return mColors[colorIndex]
}
private fun updatePoints(mPoints: ArrayList<PPPoint>, mRs: ArrayList<Int>, mRads: ArrayList<Float>, mR: Int, scale: Float) {
var range = 0.1f
var temp_mR = mR + (mR * 0.15f * Math.sin(toRadians(scale))).toInt()
mRs[0] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[0]))).toInt()
mRs[1] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[1]))).toInt()
mRs[2] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[2]))).toInt()
mRs[3] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[3]))).toInt()
mRs[4] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[4]))).toInt()
mRs[5] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[5]))).toInt()
mPoints[0].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 0)) * mRs[0]).toFloat()
mPoints[1].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 60)) * mRs[1]).toFloat()
mPoints[2].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 120)) * mRs[2]).toFloat()
mPoints[3].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 180)) * mRs[3]).toFloat()
mPoints[4].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 240)) * mRs[4]).toFloat()
mPoints[5].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 300)) * mRs[5]).toFloat()
mPoints[0].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 0)) * mRs[0]).toFloat()
mPoints[1].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 60)) * mRs[1]).toFloat()
mPoints[2].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 120)) * mRs[2]).toFloat()
mPoints[3].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 180)) * mRs[3]).toFloat()
mPoints[4].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 240)) * mRs[4]).toFloat()
mPoints[5].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 300)) * mRs[5]).toFloat()
startRads += 0.05f
mRads[0] = mRads[0] + (Math.random() * 3f).toFloat()
mRads[1] = mRads[1] + (Math.random() * 3f).toFloat()
mRads[2] = mRads[2] + (Math.random() * 3f).toFloat()
mRads[3] = mRads[3] + (Math.random() * 3f).toFloat()
mRads[4] = mRads[4] + (Math.random() * 3f).toFloat()
mRads[5] = mRads[5] + (Math.random() * 3f).toFloat()
}
private fun drawCircle(canvas: Canvas?, mPoints: ArrayList<PPPoint>) {
val path = Path()
path.moveTo(mPoints[0].x, mPoints[0].y)
path.lineTo(mPoints[1].x, mPoints[1].y)
path.lineTo(mPoints[2].x, mPoints[2].y)
path.lineTo(mPoints[3].x, mPoints[3].y)
path.lineTo(mPoints[4].x, mPoints[4].y)
path.lineTo(mPoints[5].x, mPoints[5].y)
path.close()
canvas?.drawPath(path, mPaint)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
return true
}
MotionEvent.ACTION_MOVE -> {
animX.cancel()
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (this.scale != 1f) {
animX.spring.stiffness = getStiffness()
animX.spring.dampingRatio = getDamping()
animX.setStartVelocity(5f)
animX.start()
canUpdatePoints = false
}
velocityTracker.clear()
return true
}
}
return false
}
private fun getDamping(): Float {
return 0.2f
}
private fun getStiffness(): Float {
return 250f
}
fun toRadians(angel: Float): Double {
return (Math.PI * angel / 180)
}
}
下面我逐一介绍一下,每一种效果我是如何实现的。 1.有两坨重叠的,DuangDuang的东西,第一层是纯色,第二层的半透明 这个效果我使用的是六边形,然后过渡圆角实现的。DuangDuang的效果是通过改变六边形每个角到圆心的距离实现蠕动的效果。为了这个效果更逼真,给这个六边形加上旋转的效果。第一层和第二层的效果是一样的,只是半径不同而已。

计算生成六边形顶点的方法是
private fun creatPoints(mPoints: ArrayList<PPPoint>, mRs: ArrayList<Int>, mRads: ArrayList<Float>) {...}
mPoints存放计算后的顶点(6个),mRs是每个顶点到圆心的半径,mRads是每条半径的角度。
全局变量mPoints存放上层六边形的顶点,bPoints存放背景六边形顶点。 在onDrawa方法中调用了
updatePoints(bPoints, bRs, bRads, (mR * 1.2f).toInt(), scale)
updatePoints(mPoints, mRs, mRads, mR, scale)
来更新六边形的顶点的数据。
private fun updatePoints(mPoints: ArrayList<PPPoint>, mRs: ArrayList<Int>, mRads: ArrayList<Float>, mR: Int, scale: Float) {
var range = 0.1f
var temp_mR = mR + (mR * 0.15f * Math.sin(toRadians(scale))).toInt()
mRs[0] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[0]))).toInt()
mRs[1] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[1]))).toInt()
mRs[2] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[2]))).toInt()
mRs[3] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[3]))).toInt()
mRs[4] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[4]))).toInt()
mRs[5] = temp_mR + (temp_mR * range * Math.cos(toRadians(mRads[5]))).toInt()
mPoints[0].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 0)) * mRs[0]).toFloat()
mPoints[1].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 60)) * mRs[1]).toFloat()
mPoints[2].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 120)) * mRs[2]).toFloat()
mPoints[3].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 180)) * mRs[3]).toFloat()
mPoints[4].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 240)) * mRs[4]).toFloat()
mPoints[5].x = measuredWidth / 2f + (Math.cos(toRadians(startRads + 300)) * mRs[5]).toFloat()
mPoints[0].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 0)) * mRs[0]).toFloat()
mPoints[1].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 60)) * mRs[1]).toFloat()
mPoints[2].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 120)) * mRs[2]).toFloat()
mPoints[3].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 180)) * mRs[3]).toFloat()
mPoints[4].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 240)) * mRs[4]).toFloat()
mPoints[5].y = measuredHeight / 2f - (Math.sin(toRadians(startRads + 300)) * mRs[5]).toFloat()
startRads += 0.05f
mRads[0] = mRads[0] + (Math.random() * 3f).toFloat()
mRads[1] = mRads[1] + (Math.random() * 3f).toFloat()
mRads[2] = mRads[2] + (Math.random() * 3f).toFloat()
mRads[3] = mRads[3] + (Math.random() * 3f).toFloat()
mRads[4] = mRads[4] + (Math.random() * 3f).toFloat()
mRads[5] = mRads[5] + (Math.random() * 3f).toFloat()
}
我使用sinX,通过改变X的值,使sinX的值在-1和+1之间呈函数变化。当X是匀速变化时,sinX是非匀速变化的。当角度是随机增加的时候,sinX的变化就更是随机的。
var temp_mR = mR + (mR * 0.15f * Math.sin(toRadians(scale))).toInt()
这里是计算基础半径,整个六边形的放大和缩小的动画是在这里实现的,变化的范围是-0.15和+0.15之间。 实际的6个顶点的半径是在基础半径上计算的,变化范围是-range~+range的范围。 startRads 是当前半径的角度。用半径+角度,计算出顶点的位置。 背景六边形的基础半径略大。
至此,1.2.3的效果就实现了。
2.然后是气泡冒出效果 在 private fun drawBubble(canvas: Canvas?) {}方法中,是实现气泡效果的。
//此处控制泡泡的数量
if (mBubbles.size < 5) {
var bubble = Bubble()
bubble.x = width / 2f
bubble.y = height / 2f
bubble.rad = (Math.random() * 360).toFloat()
bubble.distance = 0f
bubble.speed = (Math.random() * 3).toFloat()
bubble.r = mR * 0.4f + (Math.random() * mR * 0.1f).toFloat() * bubble.speed
mBubbles.add(bubble)
// Log.e("TAg", "生产一个泡泡")
}
这里是产生一个气泡的过程。气泡的半径,发射的角度,发射的速度都是随机的。distance 是当前气泡的距离圆心的距离。x,y是气泡的圆心坐标。
mBubbles.forEach {
it.r -= 0.4f
it.distance += it.speed
it.x = width / 2f + it.distance * Math.cos(toRadians(it.rad)).toFloat()
it.y = height / 2f - it.distance * Math.sin(toRadians(it.rad)).toFloat()
}
这里是在计算气泡的半径(随着气泡飞出,半径减小),计算气泡的距离,计算坐标。
var tempBubbles = ArrayList<Bubble>()
mBubbles.forEach {
if (it.r > 0f) {
tempBubbles.add(it)
}
}
mBubbles = tempBubbles
移除半径小于等于0的气泡。
mBubbles.forEach {
canvas?.drawCircle(it.x, it.y, it.r, mPaint)
var mR = getRecentR()
if (it.distance < this.mR + it.r * 1) {
//小球的切点
var th = toRadians(it.rad) - Math.acos(((mR - it.r) / it.distance).toDouble())
var th2 = toRadians(it.rad) + Math.acos(((mR - it.r) / it.distance).toDouble())
var x1 = (-Math.cos(th) * it.r + it.x).toFloat()
var y1 = (Math.sin(th) * it.r + it.y).toFloat()
var x2 = (-Math.cos(th2) * it.r + it.x).toFloat()
var y2 = (Math.sin(th2) * it.r + it.y).toFloat()
var x3 = (Math.cos(th2) * mR + width / 2).toFloat()
var y3 = (-Math.sin(th2) * mR + height / 2).toFloat()
var x4 = (Math.cos(th) * mR + width / 2).toFloat()
var y4 = (-Math.sin(th) * mR + height / 2).toFloat()
var cx = (it.x + width / 2) / 4
var cy = (it.y + height / 2) / 4
var cx1 = (it.x + x4) / 4 + cx
var cy1 = (it.y + y4) / 4 + cy
var cx2 = (it.x + x3) / 4 + cx
var cy2 = (it.y + y3) / 4 + cy
var p = Path()
p.moveTo(it.x, it.y)
p.lineTo(x2, y2)
p.cubicTo(x2, y2, cx1, cy1, x4, y4)
p.lineTo(x3, y3)
p.cubicTo(x3, y3, cx2, cy2, x1, y1)
p.lineTo(it.x, it.y)
p.close()
canvas?.drawPath(p, mPaint)
}
}
这里根据计算后的结果画出气泡。后面就是粘连效果的实现。这里使用了贝塞尔曲线。 以一个小球为例:

it.x和it.y是气泡的圆心坐标,width / 2和height / 2是大圆的圆心坐标。大圆的半径var mR = getRecentR(),这里是找出6个半径中最短的。 AB的距离是气泡的distance,∠ABE是气泡的角度it.rad,代码中计算的th是∠EBC,th2是∠DBE。角度知道了,半径也知道了,就可以算出4个切点的坐标了。 cx1,cy2就是第三个辅助点。点4 ,c1和点2,三个点构成一条贝塞尔曲线。点3 ,c2和点1,是另一条。 至此,粘连效果完成。
3.点击时有个模仿物理效果的放大缩小动画,果冻的弹性效果。使用的是SpringAnimation,具体代码很简单,没使用过的老哥们可以看看我的上一篇。