今天手机被测试同学征用了……想想好久没有写文章了,正好来水一篇。(近期也在学习音视频的东西,因为还没了解透彻不敢乱写,这几天应该也会慢慢补上)。
UI同学给出的效果图是这样的
1. 拆分动画
从动画里可以看出,整个动画分4个阶段
(1)主圆点从左向右
(2)主圆点在到达绿色点时减速到0,并且左边界重合
(3)主圆点开始向左运动
(4)主圆点到达红色点时减速到0,重新开始1
其中,① 点与点之间的动画可以拆分开来。② 点与点之 间主圆点会渐变颜色
2. 分析动画
很明显,主圆点的吸附效果是一个贝塞尔曲线的动画,是不是感觉似曾相识?我起初也这么觉得,于是开始找一些效果,找到了SpringIndicator和BezierIndicator两种效果。
其实仔细想想,QQ的气泡拖动已读也是SpringIndicator的效果,对比之下显然是SpringIndicator中的效果更符合UI的预期,四个点的动画也都可以拆分成两两之间的动画。
最难的部分已经找到解决方案了,剩下在边界顶点的移动也只是简单的动画了,接下来可以开始编码了。
3. 编写动画
- 首先要把四个小圆点画出来
- 画出主圆点
- 设定牵引点(就是目标点)的坐标和尾巴点(原始位置)坐标
- 设定动画参数和边界条件(用来控制哪里需要切断曲线)
自定义BubbleView,用来画5个圆点。(为了简洁,以下代码段都省去了部分代码)
public class BubbleView extends View {
private Paint mPaint;
// 气泡颜色
private int mBubbleColor = Color.GRAY;
// 气泡半径
private int mRadius = 100;
private float mBubbleX;
private float mBubbleY;
private int mBubbleHeight;
private int mBubbleWidth;
private PointF pointF;
public BubbleView(Context context) {
this(context, null);
}
public BubbleView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public BubbleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setColor(mBubbleColor);
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(1);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
mBubbleHeight = getMeasuredHeight();
mBubbleWidth = getMeasuredWidth();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.translate(mBubbleWidth >> 1, mBubbleHeight >> 1);
canvas.drawCircle(0, 0, mRadius, mPaint);
}
}
自定义ViewGroup摆放5个圆点的位置
public class BubbleContainer extends LinearLayout {
private static final int ANIMATION_DURATION = 16000;
private static final int REPEAT_COUNT = Animation.INFINITE;
private int[] mColors = {Color.parseColor("#ff3925"),
Color.parseColor("#ff8c14"),
Color.parseColor("#0091d7"),
Color.parseColor("#50c414"),
};
private int mRadius = dp2px(3);
private int mBigBubbleRadius = dp2px(5);
private int mMaxInterval = dp2px(8);
private ArrayList mBubbles;
private SpringView springView;
private final int defaultInterval = -mRadius;
private int mBubbleInterval = defaultInterval;
private boolean mStopFlag;
private int mWidth;
private BubbleState mBubbleState;
private ValueAnimator valueAnimator;
public BubbleContainer(Context context) {
this(context, null);
}
public BubbleContainer(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public BubbleContainer(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setOrientation(HORIZONTAL);
setWillNotDraw(false);
mBubbles = new ArrayList<>(4);
for (int i = 0; i < 4; i++) {
BubbleView bubble = new BubbleView(getContext());
bubble.setColor(mColors[i]);
bubble.setRadius(mRadius);
LinearLayout.LayoutParams params = new LayoutParams(2 * mRadius, 2 * mRadius);
mBubbles.add(bubble);
// 将圆点添加到ViewGroup
addView(bubble, params);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int middleWidth = (r - l) / 2;
int middleHeight = (b - t) / 2;
if (springView == null) {
addPointView(r - l, b - t);
}
int childCount = mBubbles.size();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
BubbleView bubble = mBubbles.get(i);
layoutChildBubble(bubble, i, middleWidth, middleHeight);
bubble.setBubbleX(child.getX() + mRadius);
bubble.setBubbleY(child.getY() + mRadius);
}
springView.layout(l, middleHeight - mBigBubbleRadius, r, middleHeight + mBigBubbleRadius);
if (mBubbleState == null) {
mBubbleState = new BubbleState(springView);
mBubbleState.setBubbles(mBubbles);
}
}
// 摆放4个圆点
private void layoutChildBubble(BubbleView bubble, int position, int middleWidth, int middleHeight) {
int distance = mBubbleInterval + mRadius;
switch (position) {
case 0:
bubble.layout(middleWidth - 3 * distance - mRadius,
middleHeight - mRadius,
middleWidth - 3 * distance + mRadius,
middleHeight + mRadius);
break;
case 1:
bubble.layout(middleWidth - distance - mRadius,
middleHeight - mRadius,
middleWidth - distance + mRadius,
middleHeight + mRadius);
break;
case 2:
bubble.layout(middleWidth + mBubbleInterval,
middleHeight - mRadius,
middleWidth + distance + mRadius,
middleHeight + mRadius);
break;
case 3:
bubble.layout(middleWidth + 3 * mBubbleInterval + 2 * mRadius,
middleHeight - mRadius,
middleWidth + 3 * mBubbleInterval + 4 * mRadius,
middleHeight + mRadius);
break;
}
}
}
然后加入主圆点
private void addPointView(int width, int height) {
springView = new SpringView(getContext());
springView.setIndicatorColor(mColors[0]);
LinearLayout.LayoutParams params = new LayoutParams(width, height);
addView(springView, params);
}
为主圆点加入动画。主圆点的动画模仿了SpringIndicator的模式,不过这里加入了3个点,一个牵引一个尾巴,还有主圆点,这样更能表现水滴粘滞的效果。这里具体的绘画流程不做具体分析,感兴趣的同学可以直接参看Android 贝塞尔曲线解析。
// 贝塞尔曲线绘制数据
private void makePath() {
float headOffsetX = (float) (headPoint.getRadius() * Math.sin(Math.atan((footPoint.getY() - headPoint.getY()) / (footPoint.getX() - headPoint.getX()))));
float headOffsetY = (float) (headPoint.getRadius() * Math.cos(Math.atan((footPoint.getY() - headPoint.getY()) / (footPoint.getX() - headPoint.getX()))));
float footOffsetX = (float) (footPoint.getRadius() * Math.sin(Math.atan((footPoint.getY() - headPoint.getY()) / (footPoint.getX() - headPoint.getX()))));
float footOffsetY = (float) (footPoint.getRadius() * Math.cos(Math.atan((footPoint.getY() - headPoint.getY()) / (footPoint.getX() - headPoint.getX()))));
float pullOffsetX = (float) (pullPoint.getRadius() * Math.sin(Math.atan((headPoint.getY() - pullPoint.getY()) / (headPoint.getX() - pullPoint.getX()))));
float pullOffsetY = (float) (pullPoint.getRadius() * Math.cos(Math.atan((headPoint.getY() - pullPoint.getY()) / (headPoint.getX() - pullPoint.getX()))));
float x1 = headPoint.getX() - headOffsetX;
float y1 = headPoint.getY() + headOffsetY;
float x2 = headPoint.getX() + headOffsetX;
float y2 = headPoint.getY() - headOffsetY;
float x3 = footPoint.getX() - footOffsetX;
float y3 = footPoint.getY() + footOffsetY;
float x4 = footPoint.getX() + footOffsetX;
float y4 = footPoint.getY() - footOffsetY;
float x5 = headPoint.getX() + headOffsetX;
float y5 = headPoint.getY() + headOffsetY;
float x6 = headPoint.getX() - headOffsetX;
float y6 = headPoint.getY() - headOffsetY;
float x7 = pullPoint.getX() - pullOffsetX;
float y7 = pullPoint.getY() + pullOffsetY;
float x8 = pullPoint.getX() + pullOffsetX;
float y8 = pullPoint.getY() - pullOffsetY;
float anchorX = (footPoint.getX() + headPoint.getX()) / 2;
float anchorY = (footPoint.getY() + headPoint.getY()) / 2;
float anchorX2 = (headPoint.getX() + pullPoint.getX()) / 2;
float anchorY2 = (headPoint.getY() + pullPoint.getY()) / 2;
path.reset();
if (Math.abs(pullPoint.getX() - headPoint.getX()) > headPoint.getRadius()) {
path.moveTo(x1, y1);
path.quadTo(anchorX, anchorY, x3, y3);
path.lineTo(x4, y4);
path.quadTo(anchorX, anchorY, x2, y2);
path.lineTo(x1, y1);
}
if (Math.abs(pullPoint.getX() - headPoint.getX()) < headPoint.getRadius() + headPoint.getRadius()) {
path.moveTo(x5, y5);
path.quadTo(anchorX2, anchorY2, x7, y7);
path.lineTo(x8, y8);
path.quadTo(anchorX2, anchorY2, x6, y6);
path.lineTo(x5, y5);
}
}
加入ValueAnimator控制主圆点位置
private void headPointRadiusAnimation() {
if (valueAnimator != null) {
return;
}
float starX = mBubbles.get(0).getBubbleX();
float endX = mBubbles.get(3).getBubbleX();
valueAnimator = ValueAnimator.ofFloat(starX, endX, endX + mRadius, starX, starX - mRadius, starX);
valueAnimator.setDuration(ANIMATION_DURATION);
valueAnimator.setRepeatCount(REPEAT_COUNT);
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
Point headPoint = springView.getHeadPoint();
headPoint.setX(animatedValue);
springView.invalidate();
}
});
valueAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mStopFlag = true;
}
});
}
在这之后,我们的圆点就可以动起来了,但是有一个问题,就是牵引点和尾巴点的位置没有改变,我们需要控制他们的位置和状态。
封装各个圆点的位置状态
class BubbleState {
private ArrayList bubbleViews;
private Point footPoint;
private Point pullPoint;
private Point headPoint;
private float x0;
private float x1;
private float x2;
private float x3;
private boolean back;
private ValueAnimator colorValueAnimator;
BubbleState(SpringView springView) {
footPoint = springView.getFootPoint();
headPoint = springView.getHeadPoint();
pullPoint = springView.getPullPoint();
}
void setBubbles(ArrayList bubbles) {
bubbleViews = bubbles;
x0 = bubbles.get(0).getBubbleX();
x1 = bubbles.get(1).getBubbleX();
x2 = bubbles.get(2).getBubbleX();
x3 = bubbles.get(3).getBubbleX();
}
void setValue(float animatedValue) {
if (back) {
back(animatedValue);
} else {
forward(animatedValue);
}
}
private void forward(float animatedValue) {
if (x0 < animatedValue && animatedValue < x1) {
state1();
} else if (x1 <= animatedValue && animatedValue < x2) {
state2();
} else if (x2 <= animatedValue && animatedValue < x3) {
state3();
} else if (animatedValue >= x3) {
state4();
back = true;
}
}
private void back(float animatedValue) {
if (x0 < animatedValue && animatedValue < x1) {
state7();
} else if (x1 <= animatedValue && animatedValue < x2) {
state6();
} else if (x2 <= animatedValue && animatedValue < x3) {
state5();
} else if (animatedValue <= x0) {
state8();
back = false;
}
}
void initState() {
state1();
}
/**
* 0-1
*/
private void state1() {
BubbleView foot = bubbleViews.get(0);
BubbleView pull = bubbleViews.get(1);
setState(pull, foot);
}
/**
* 1-2
*/
private void state2() {
BubbleView foot = bubbleViews.get(1);
BubbleView pull = bubbleViews.get(2);
setState(pull, foot);
}
// ...省略部分状态设置
private void setState(BubbleView pull, BubbleView foot) {
setX(pull, foot);
setColor(pull, foot);
}
private void setX(BubbleView pull, BubbleView foot) {
if (pull.getBubbleX() == pullPoint.getX()) {
return;
}
pullPoint.setX(pull.getBubbleX());
footPoint.setX(foot.getBubbleX());
}
private void setColor(BubbleView pull, BubbleView foot) {
setHeadColor(pull, foot);
if (pull.getBubbleColor() == pullPoint.getColor()) {
return;
}
pullPoint.setColor(pull.getBubbleColor());
footPoint.setColor(foot.getBubbleColor());
}
private void setHeadColor(BubbleView pull, BubbleView foot) {
if (Math.abs(headPoint.getX() - foot.getBubbleX()) > headPoint.getRadius()) {
headPoint.setColor(pull.getBubbleColor());
} else {
headPoint.setColor(foot.getBubbleColor());
}
}
}
这样在ValueAnimator中只需要不断调用X的值就能安排好每个圆点的位置了
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
mBubbleState.setValue(animatedValue);
springView.invalidate();
}
});
这里的圆点间距、大小、颜色、主圆点的速度都可以调整。唯一不足的就是颜色的渐变效果不明显,我尝试使用系统和自定义的颜色渐变都不能很好的解决,因为我们自定的是按一定的规则变更颜色数值的,但是这里的颜色排列并不是按照颜色渐变来的,在第二个点到第三个点的过程中,圆点会先变成绿色再变成蓝色,这样显然是有问题的,解决的方案也是在BubbleState中加入颜色的渐变,不过因为速度太快,这样调整的投入产出比太低,UI同学也认可了上面的动画,就没有进一步的修改,不过我认为还是有时间补上吧,虽然最近并没有
(:з」∠)
写的确实有点水了……具体还是看代码吧BubbleLoading
参考 SpringIndicator