打造一个可定制的Path动画

前言

创建这个库并非是由于某个需求,而是以前在阅读OkHttp源码时深感设计的精妙,一直有一个模仿其责任链模式做一个自定义View(SimpleLineView)的想法,一是为了好玩,二是希望能够抛砖引玉。

对于View的path动画,PathAnimView甚至是Lottie等都可以作出十分复杂酷炫的path动画。如果你的动画很复杂很酷炫,这个库可能就不太适合了。

当然,SimpleLineView也有自己的优势:
1、可随意定制路径
2、路径可以随意组合
3、支持progress

效果图

image

整体架构

1、自定义Painter(绘制相关接口),提供绘制功能。
2、RealChain实现了Chain接口并且维护了一个Painter的list,控制所有Painter依次执行绘制。
3、SimpleLineView维护了一个RealChain并且对外提供了方法,用于添加Painter以及控制动画的启动、停止和继续。在onDraw里调用当前Painter的onDraw方法实现真正的绘制。
image

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 圆形
PixelPath circlePath = new PixelPath(10, 10, new int[]{1, 100});
CirclePainter ciclePainter = new RealCirclePainter(circlePath, 1000, -120, 360, false);
// 矩形
PixelPath squarePath = new PixelPath(2, 2, new int[]{1, 2, 4, 3});
Painter squarePainter = new SegmentPainter(squarePath, 1000, true);
// 添加路径
mView.addPainter(ciclePainter).addPainter(squarePainter);
// 启动
mView.start();
// 停止
mView.stop();
// 继续
mView.stick();

前期准备:PixelPath

1
2
3
4
5
6
7
// 横向像素
private int mHorizontal;
// 纵向像素
private int mVertical;
// 路径经过的像素序号
private int[] mPath;
SimpleLineView将View的长和宽分成若干个格子,格子数由图片的像素决定,并且规定了path的序号(从1开始,从左往右、从上到下依次递增1)。

例如,一张像素为4 * 4的图片
image

这里的mHorizontal(横向格子数)和mVertical(纵向格子数)都为4。如果mPath为{1, 13, 16, 4}, 则绘制的图形为依次连接1,13,16,4的矩形(是否封闭可设置对应参数)。
如果图形的形状比较复杂,可以用PS打开图片,依次获取像素点的x和y值(这里x和y值的单位可以是像素、厘米等,但是计算时要与图像大小的单位一致)。假设图像宽为w, 高为h, 则当前点的值为 w * ( y - 1) + x。

Painter接口

Painter接口主要提供了绘制的功能以及绘制时所需要的一些参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface Painter {
// 获取Path
PixelPath getPixelPath();
// 时长
int duration();
// 路径是否闭合
boolean close();
// 设置Paint
void setPaint(Paint paint);
// 获取Paint
Paint getPaint();
// 动画是否正在进行
boolean isRunning();
// 开始动画
void start(Chain chain, Action action);
// 停止动画
void stop();
// 真正绘制的地方
void onDraw(Canvas canvas);
// 进行下一笔绘画时,完整画完当前笔
void completeDraw(Canvas canvas);
}

onDraw方法和View的onDraw方法一样实现绘制。这里主要介绍一下completeDraw方法。由于每个Painter是依次绘制的,当下一个Painter进行绘制时,当前Pianter的形状也需要绘制,所以这里的completeDraw应该是当前Painter所要绘制的完整形状。这个方法会被当前Painter之后的每一个Painter调用。有点绕,看一下抽象类AbstractPainter的onDraw实现:

1
2
3
4
5
6
7
@Override
public void onDraw(Canvas canvas) {
// 1.完成之前Painter的绘制
drawPreviouse(canvas);
// 2.绘制当前
realDraw(canvas);
}

drawPreviouse方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 开始当前绘制前,先完成之前的绘制
* @param canvas
*/
private void drawPreviouse(Canvas canvas) {
if (chain != null) {
// 当前Painter的index
final int index = chain.index();
final List<Painter> painters = chain.painters();
// 遍历所有之前的Painter,调用completeDraw方法
for (int i = 0; i < index - 1; i ++) {
final Painter painter = painters.get(i);
painter.completeDraw(canvas);
}
}
}

为了方便说明,看一下AbstractPainter的一个子类RealCirclePainter的completeDraw和realDraw方法:

1
2
3
4
5
6
7
8
9
@Override
public void completeDraw(Canvas canvas) {
canvas.drawArc(mRectF, mStartAngle, mSweepAngle, mUseCenter, paint);
}

@Override
protected void realDraw(Canvas canvas) {
canvas.drawArc(mRectF, mStartAngle, angle(), mUseCenter, paint);
}

mSweepAngle是所需扫过的角度,angle()为当前的角度大小,这个角度会随着时间递增,如此也就有了动画。
当然必须得看一下AbstractPainter的start方法,这个才是每个Painter开始的地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void start(Chain chain, Action action) {
//是否正在执行
mIsRunning = true;
this.chain = chain;
// 计算实际坐标点
pointList = action.fetchCoordinate(this);
// 执行绘制
boolean isFinish = performDraw(action);
mIsRunning = false;
// 如果的确绘制完成,下一步
if (isFinish) {
chain.proceed();
}
}

fetchCoordinate方法为绘制提供了坐标点,实现下文会介绍。之后通过performDraw方法来开始View的绘制,看一下AbstractPainter另一个子类SegmentPainter的performDraw方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public boolean performDraw(Action action) {

// 总路程
float distance = Utils.calDistance(pointList, close());

for (int i = 0; i < pointList.size(); i++) {
// 省略若干代码
while (!current.isPathFinish()) {
if (!isRunning()) {
return false;
}
// 省略计算代码
// 更新界面
action.update(this);
SystemClock.sleep(INTERVAL);
}
// 保证图像都绘制
action.update(this);
}
return true;
}

在performDraw方法中会遍历PixelPoint的list,每隔INTERVAL时间调用Action接口的update方法更新View一次。

Chain接口

Chain主要提供了调控的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface Chain {
// 执行
void proceed();
// 当前Painter的index
int index();
// 设置结束监听
void setOnFinishListener(OnFinishListener listener);
// 所有的Painter
List<Painter> painters();
// 结束接听接口
interface OnFinishListener {
void onFinish(int index);
}
}

看一下唯一实现类RealChain的proceed方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public void proceed() {

if (mOnFinishListener != null && mIndex > 0) {
mOnFinishListener.onFinish(mIndex - 1);
}
// 如果index等于size就返回结束了
if (mIndex == mPainters.size()) {
return;
}
// 设置progress时会不断调用该方法,为了避免不断创建RealChain对象,
// 这里用了SparseArray保存所有已经创建的RealChain对象,key为index
Chain next = mChainPool.get(mIndex);

if (next == null) {
next = new RealChain(mPainters, mIndex + 1, mAction);
next.setOnFinishListener(mOnFinishListener);
mChainPool.put(mIndex, next);
}

final Painter painter = mPainters.get(mIndex);
painter.start(next, mAction);

}

这里照搬了OkHttp,通过在RealChain的procced方法创建新的RealChain对象实现Painter的依次执行。由于Painter都是自定义的,所以当index等于所有Painter的size时return就好了,而OkHttp的最后一个Interceptor是没有创建Chain的。

Action接口

Action接口主要提供了计算当前path实际坐标点、通知更新View以及设置或者获取View的所需的参数的功能,这也是View需要实现的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public interface Action {

/**
* 更新view,实际调用的是{@link SimpleLineView#postInvalidate()}方法
* @param painter 该painter实现view的onDraw
*/
void update(Painter painter);

/**
* 对外接口,设置progress后更新view
* @param progress
*/
void setProgress(int progress);

/**
* painter中通过调用该接口进行相应的绘制工作
* @return
*/
int getProgress();

/**
* 通过当前view执行的状态作出相应处理,可参考{@link com.robog.library.painter.TaskPainter#start(Chain, Action)}方法
* @return
*/
int getStatus();

/**
* 获得当前painter下所有点的实际坐标
* @param painter
* @return
*/
List<PixelPoint> fetchCoordinate(Painter painter);
}

看一下fetchCoordinate方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public List<PixelPoint> fetchCoordinate(Painter painter) {
// 同样的,这里避免频繁设置progress不断创建PixelPoint对象
List<PixelPoint> pixelPoints = mPointPool.get(painter);

if (pixelPoints != null) {
return pixelPoints;
}

pixelPoints = new ArrayList<>();
Utils.setPoint(painter, pixelPoints, mWidth, mHeight);
mPointPool.put(painter, pixelPoints);
return pixelPoints;
}

Utils的setPoint方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public static void setPoint(Painter painter, List<PixelPoint> pixelPoints, int width, int height) {
// 先获取PixelPath
final PixelPath pixelPath = painter.getPixelPath();
final int[] path = pixelPath.getPath();
final int horizontal = pixelPath.getHorizontal();
final int vertical = pixelPath.getVertical();

for (int target : path) {
// 如果PixelPath中点的序号超过总数则抛出异常
if (target > horizontal * vertical) {
throw new IllegalArgumentException("Current coordinate [" + target + "] is invalid!");
}
// 商
int quotient = target / horizontal;
// 余数
int remainder = target % horizontal;
// 实际的x和y坐标
float x;
float y;
// x和y坐标的系数
float coefficientX;
float coefficientY;
if (remainder != 0) {
// 余数不为0时,这里的0.5是让实际坐标点位于方格中心点
coefficientX = remainder - 0.5f;
coefficientY = quotient + 0.5f;
} else {
// 余数为0时
coefficientX = horizontal - 0.5f;
coefficientY = quotient - 0.5f;
}
// width / horizontal为每个方格的宽度
// 每个方格的宽度乘系数即为x的坐标
x = coefficientX * width / horizontal;
// 同理
y = coefficientY * height / vertical;

PixelPoint pixelPoint = new PixelPoint(x, y);
pixelPoints.add(pixelPoint);
}
}

用的小学数学,看一下注释就好了。接着看一下update和onDraw方法:

1
2
3
4
5
6
7
8
9
10
11
@Override
public void update(Painter painter) {
mCurrentPainter = painter;
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas) {
if (mCurrentPainter != null) {
mCurrentPainter.onDraw(canvas);
}
}

在update中设置当前的painter,由于之前的操作在线程中,这里调用postInvalidate通知绘制,在View的onDraw方法中调用Painter的onDraw实现绘制。

其他

介绍完三个接口,整体的流程算是介绍完了,下面看一下两个功能型的Painter。

DelayPainter

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void start(Chain chain, Action action) {
try {
if (Looper.myLooper() == Looper.getMainLooper()) {
throw new RuntimeException("Can't delay in the main thread!");
}
Thread.sleep(mTime);
chain.proceed();
} catch (InterruptedException e) {
e.printStackTrace();
}
}

在start方法通过Thread.sleep进行延时,如果当前方法在主线程执行就抛出异常。

TaskPainter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void start(final Chain chain, final Action action) {
mIsRunning = true;
EXECUTOR.execute(new Runnable() {
@Override
public void run() {
// 当status为start时重置point
if (action.getStatus() == STATUS_START) {
Utils.resetPointStatus(mPainterPool);
}
chain.proceed();
mIsRunning = false;
}
});
}

当Painter有多个时,计算会耗费一定的时间,这里将chain的procced置于线程中,让后续的过程都在线程中执行以保证动画的流畅。并且,当start动画时,将PixelPoint的状态重置,保证下一次绘制是一个完整的过程。

不足

1、图形较为复杂时,通过PS获取计算坐标点较为繁琐。
2、由于计算在子线程,绘制在主线程,当SimpleLineView设置progress过快时,上一步onDraw可能未完成,画面可能会闪动,暂时的解决方法是将此过程放入主线程。使用代码如下:

1
2
mView.addPainter(mCicleProgressPainter).addPainter(mHookProgressPainter).onMain();
mView.setProgress(progress);

OK,基本上介绍完了,如果有什么不足或者有什么问题欢迎指正!

项目地址:https://github.com/XingdongYu/SimpleLineView