文字路径动画控件TextPathView解析
本文出处: 炎之铠csdn博客:http://blog.csdn.net/totond 炎之铠邮箱:yanzhikai_yjk@qq.com 本项目Github地址:https://github.com/totond/TextPathView 本文原创,转载请注明本出处! 本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
前言
此博客主要是介绍TextPathView的实现原理,而TextPathView的使用可以参考README,效果如图:
思路介绍
下面写的实现TextPathView思路介绍主要有两部分:一部分是文字路径的实现,包括文字路径的获取、同步绘画和异步绘画;一部分是画笔特效,包括各种画笔特效的实现思路。
文字路径
文字路径的实现是核心部分,主要的工作就是把输入的文字转化为Path,然后绘画出来。绘画分为两种绘画:
-
一种是同步绘画,也就是相当于只有一支“画笔”,按顺序来每个笔画来绘画出文字Path。如下面:
-
一种是异步绘画,也就是相当于多支“画笔”,每个笔画(闭合的路径)有一支,来一起绘画出文字Path。如下面:
-
这两者的区别大概就像一个线程同步绘画和多个异步绘画一样,当然实际实现是都是在主线程里面绘画的,具体实现可以看下面介绍。
文字路径的获取
获取文字路径用到的是Paint的一个方法getTextPath(String text, int start, int end,float x, float y, Path path)
,这个方法可以获取到一整个String的Path(包括所有闭合Path),然后设置在一个PathMeasure类里面,方便后面绘画的时候截取路径。如SyncTextPathView里面的:
//初始化文字路径
@Override
protected void initTextPath(){
//...
mTextPaint.getTextPath(mText, 0, mText.length(), 0, mTextPaint.getTextSize(), mFontPath);
mPathMeasure.setPath(mFontPath, false);
mLengthSum = mPathMeasure.getLength();
//获取所有路径的总长度
while (mPathMeasure.nextContour()) {
mLengthSum += mPathMeasure.getLength();
}
}
复制代码
每次设定输入的String值的时候都会调用initTextPath()
来初始化文字路径。
PathMeasure是Path的一个辅助类,可以实现截取Path,获取Path上点的坐标,正切值等等,具体使用网上很多介绍。
文字路径的同步绘画
同步绘画,也就是按顺序绘画每个笔画(至于笔画的顺序是谁先谁后,就要看Paint.getTextPath()
方法的实现了,这不是重点),这种刻画在SyncTextPathView实现。 这种绘画方法不复杂,就是根据输入的比例来决定文字路径的显示比例就行了,想是这样想,具体实现还是要通过代码的,这里先给出一些全局属性的介绍:
//文字装载路径、文字绘画路径、画笔特效路径
protected Path mFontPath = new Path(), mDst = new Path(), mPaintPath = new Path();
//属性动画
protected ValueAnimator mAnimator;
//动画进度值
protected float mAnimatorValue = 0;
//绘画部分长度
protected float mStop = 0;
//是否展示画笔
protected boolean showPainter = false, canShowPainter = false;
//当前绘画位置
protected float[] mCurPos = new float[2];
复制代码
根据之前init时候获取的总长度mLengthSum和比例progress,来求取将要绘画的文字路径部分的长度mStop,然后用一个while循环使得mPathMeasure定位到最后一段Path片段,在这期间把循环的到片段都加入到要绘画的目标路径mDst,然后最后在按照剩下的长度截取最后一段Path片段:
/**
* 绘画文字路径的方法
* @param progress 绘画进度,0-1
*/
@Override
public void drawPath(float progress) {
if (!isProgressValid(progress)){
return;
}
mAnimatorValue = progress;
mStop = mLengthSum * progress;
//重置路径
mPathMeasure.setPath(mFontPath, false);
mDst.reset();
mPaintPath.reset();
//根据进度获取路径
while (mStop > mPathMeasure.getLength()) {
mStop = mStop - mPathMeasure.getLength();
mPathMeasure.getSegment(0, mPathMeasure.getLength(), mDst, true);
if (!mPathMeasure.nextContour()) {
break;
}
}
mPathMeasure.getSegment(0, mStop, mDst, true);
//绘画画笔特效
if (canShowPainter) {
mPathMeasure.getPosTan(mStop, mCurPos, null);
drawPaintPath(mCurPos[0], mCurPos[1], mPaintPath);
}
//绘画路径
postInvalidate();
}
复制代码
在最后调用的onDraw():
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//...
//画笔特效绘制
if (canShowPainter) {
canvas.drawPath(mPaintPath, mPaint);
}
//文字路径绘制
canvas.drawPath(mDst, mDrawPaint);
}
复制代码
这样子就可以画出progress相对应比例的文字路径了。
文字路径的异步绘画
异步绘画,也就是相当于多支“画笔”,每个笔画(闭合的路径)有一支,来一起绘画出文字Path。,这种刻画在AsyncTextPathView实现。 这种绘画方法也不是很复杂,就是根据比例来决定文字路径里面每一个笔画(闭合的路径)的显示比例就行了。 具体就是使用while循环遍历所有笔画(闭合的路径)Path,循环里面根据progress比例算出截取的长度mStop,然后加入到mDst中,最后绘画出来。这里给出drawPath()
代码就行了:
/**
* 绘画文字路径的方法
* @param progress 绘画进度,0-1
*/
@Override
public void drawPath(float progress){
if (!isProgressValid(progress)){
return;
}
mAnimatorValue = progress;
//重置路径
mPathMeasure.setPath(mFontPath,false);
mDst.reset();
mPaintPath.reset();
//根据进度获取路径
while (mPathMeasure.nextContour()) {
mLength = mPathMeasure.getLength();
mStop = mLength * mAnimatorValue;
mPathMeasure.getSegment(0, mStop, mDst, true);
//绘画画笔特效
if (canShowPainter) {
mPathMeasure.getPosTan(mStop, mCurPos, null);
drawPaintPath(mCurPos[0],mCurPos[1],mPaintPath);
}
}
//绘画路径
postInvalidate();
}
复制代码
这样就能以每个笔画作为一个个体,按比例显示文字路径了。
画笔特效
画笔特效的原理
画笔特效就是以当前绘画终点为基准,增加一点Path,来使整个动画看起来更加好看的操作。如下面的火花特效:
具体的原理就是利用PathMeasurel类的getPosTan(float distance, float pos[], float tan[])
方法,在每次绘画文字路径的时候调用drawPaintPath()
来绘画附近的mPaintPath,然后在ondraw()
画出来就好了:
/**
* 绘画文字路径的方法
* @param progress 绘画进度,0-1
*/
@Override
public void drawPath(float progress) {
//...
//绘画画笔特效
if (canShowPainter) {
mPathMeasure.getPosTan(mStop, mCurPos, null);
drawPaintPath(mCurPos[0], mCurPos[1], mPaintPath);
}
//绘画路径
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//...
//画笔特效绘制
if (canShowPainter) {
canvas.drawPath(mPaintPath, mPaint);
}
//文字路径绘制
canvas.drawPath(mDst, mDrawPaint);
}
复制代码
而drawPaintPath()
方法的实现是这样的(以SyncTextPathView为例):
//画笔特效
private SyncTextPainter mPainter;
private void drawPaintPath(float x, float y, Path paintPath) {
if (mPainter != null) {
mPainter.onDrawPaintPath(x, y, paintPath);
}
}
复制代码
这里的画笔特效Painter就是一个接口,可以让使用者自定义的,因为绘画的原理不一样,Painter也分两种:
public interface SyncTextPainter extends TextPainter {
//开始动画的时候执行
void onStartAnimation();
/**
* 绘画画笔特效时候执行
* @param x 当前绘画点x坐标
* @param y 当前绘画点y坐标
* @param paintPath 画笔Path对象,在这里画出想要的画笔特效
*/
@Override
void onDrawPaintPath(float x, float y, Path paintPath);
}
public interface AsyncTextPainter extends TextPainter{
/**
* 绘画画笔特效时候执行
* @param x 当前绘画点x坐标
* @param y 当前绘画点y坐标
* @param paintPath 画笔Path对象,在这里画出想要的画笔特效
*/
@Override
void onDrawPaintPath(float x, float y, Path paintPath);
}
复制代码
TextPainter就不用说了,是父接口。然后使用者是通过set方法来传入TextPainter
//设置画笔特效
public void setTextPainter(SyncTextPainter listener) {
this.mPainter = listener;
}
复制代码
以上就是画笔特效的原理,使用者通过重写TextPainter接口来绘画附加特效。
特效实现示例
TextPathView暂时实现了3种自带的画笔特效可以选择:
//箭头画笔特效,根据传入的当前点与上一个点之间的速度方向,来调整箭头方向
public class ArrowPainter implements SyncTextPathView.SyncTextPainter{}
//一支笔的画笔特效,就是在绘画点旁边画多一支笔
public class PenPainter implements SyncTextPathView.SyncTextPainter,AsyncTextPathView.AsyncTextPainter {}
//火花特效,根据箭头引申变化而来,根据当前点与上一个点算出的速度方向来控制火花的方向
public class FireworksPainter implements SyncTextPathView.SyncTextPainter{}
复制代码
下面介绍箭头和火花,笔太简单了不用说,直接看代码就可以懂。然后这两者都用到了一个计算速度的类:
/**
* author : yany
* e-mail : yanzhikai_yjk@qq.com
* time : 2018/02/08
* desc : 计算传入的当前点与上一个点之间的速度
*/
public class VelocityCalculator {
private float mLastX = 0;
private float mLastY = 0;
private long mLastTime = 0;
private boolean first = true;
private float mVelocityX = 0;
private float mVelocityY = 0;
//重置
public void reset(){
mLastX = 0;
mLastY = 0;
mLastTime = 0;
first = true;
}
//计算速度
public void calculate(float x, float y){
long time = System.currentTimeMillis();
if (!first){
//因为只需要方向,不需要具体速度值,所以默认deltaTime = 1,提高效率
// float deltaTime = time - mLastTime;
// mVelocityX = (x - mLastX) / deltaTime;
// mVelocityY = (y - mLastY) / deltaTime;
mVelocityX = x - mLastX;
mVelocityY = y - mLastY;
}else {
first = false;
}
mLastX = x;
mLastY = y;
mLastTime = time;
}
public float getVelocityX() {
return mVelocityX;
}
public float getVelocityY() {
return mVelocityY;
}
}
复制代码
- 箭头特效:根据传入的当前点与上一个点之间的速度方向,来使箭头方向始终向前。
所以这个Path就应该是:在前进速度的反方向,以当前绘画点为起点,以一定夹角画出两条直线:
所以我们可以转化为几何数学问题:已知箭头长别为r,夹角为a,还有当前点坐标(x,y),还有它的速度夹角angle,求出箭头两个末端的坐标(字写的难看,不要在意这些细节啦O(∩_∩)O):
上面这个简单的高中数学问题居然搞了半天,具体是因为我一开始没有使用Android的View坐标系来画,一直用传统的数学坐标系来画,所以算出来每次都有偏差,意识到这个问题之后就简单了。
根据上面的推导过程我们可以得出箭头两个末端的坐标,然后就是用代码表达出来了:
/**
* author : yany
* e-mail : yanzhikai_yjk@qq.com
* time : 2018/02/09
* desc : 箭头画笔特效,根据传入的当前点与上一个点之间的速度方向,来调整箭头方向
*/
public class ArrowPainter implements SyncTextPathView.SyncTextPainter {
private VelocityCalculator mVelocityCalculator = new VelocityCalculator();
//箭头长度
private float radius = 60;
//箭头夹角
private double angle = Math.PI / 8;
//...
@Override
public void onDrawPaintPath(float x, float y, Path paintPath) {
mVelocityCalculator.calculate(x, y);
double angleV = Math.atan2(mVelocityCalculator.getVelocityY(), mVelocityCalculator.getVelocityX());
double delta = angleV - angle;
double sum = angleV + angle;
double rr = radius / (2 * Math.cos(angle));
float x1 = (float) (rr * Math.cos(sum));
float y1 = (float) (rr * Math.sin(sum));
float x2 = (float) (rr * Math.cos(delta));
float y2 = (float) (rr * Math.sin(delta));
paintPath.moveTo(x, y);
paintPath.lineTo(x - x1, y - y1);
paintPath.moveTo(x, y);
paintPath.lineTo(x - x2, y - y2);
}
@Override
public void onStartAnimation() {
mVelocityCalculator.reset();
}
}
//一些set方法...
复制代码
- 火花特效,是箭头特效的引申,就是在箭头的基础上加多几个角度随机,长度随机的箭头,然后把箭头的线段切成随机的段数(段长递增),就成了火花:
/**
* author : yany
* e-mail : yanzhikai_yjk@qq.com
* time : 2018/02/11
* desc : 火花特效,根据箭头引申变化而来,根据当前点与上一个点算出的速度方向来控制火花的方向
*/
public class FireworksPainter implements SyncTextPathView.SyncTextPainter {
private VelocityCalculator mVelocityCalculator = new VelocityCalculator();
private Random random = new Random();
//箭头长度
private float radius = 100;
//箭头夹角
private double angle = Math.PI / 8;
//同时存在箭头数
private static final int arrowCount = 6;
//最大线段切断数
private static final int cutCount = 9;
public FireworksPainter(){
}
public FireworksPainter(int radius,double angle){
this.radius = radius;
this.angle = angle;
}
@Override
public void onDrawPaintPath(float x, float y, Path paintPath) {
mVelocityCalculator.calculate(x, y);
for (int i = 0; i < arrowCount; i++) {
double angleV = Math.atan2(mVelocityCalculator.getVelocityY(), mVelocityCalculator.getVelocityX());
double rAngle = (angle * random.nextDouble());
double delta = angleV - rAngle;
double sum = angleV + rAngle;
double rr = radius * random.nextDouble() / (2 * Math.cos(rAngle));
float x1 = (float) (rr * Math.cos(sum));
float y1 = (float) (rr * Math.sin(sum));
float x2 = (float) (rr * Math.cos(delta));
float y2 = (float) (rr * Math.sin(delta));
splitPath(x, y, x - x1, y - y1, paintPath, random.nextInt(cutCount) + 2);
splitPath(x, y, x - x2, y - y2, paintPath, random.nextInt(cutCount) + 2);
}
}
@Override
public void onStartAnimation() {
mVelocityCalculator.reset();
}
//分解Path为虚线
//注意count要大于0
private void splitPath(float startX, float startY, float endX, float endY, Path path, int count) {
float deltaX = (endX - startX) / count;
float deltaY = (endY - startY) / count;
for (int i = 0; i < count; i++) {
if (i % 3 == 0) {
path.moveTo(startX, startY);
path.lineTo(startX + deltaX, startY + deltaY);
}
startX += deltaX;
startY += deltaY;
}
}
}
复制代码
整体结构
上面介绍的都是局部的细节实现,但是TextPathView作为一个自定义View,是需要封装一个整体的工作流程的,这样才能让使用者方便地使用,降低耦合性。
父类TextPathView
看过README的都知道,TextPathView并不提供给用户直接使用,而是让用户来使用它的子类SyncTextPathView和AsyncTextPathView来实现同步绘画和异步绘画的功能。而父类TextPathView则是负责写一些给子类复用的代码。具体代码就不贴了,可以直接看Github。
工作流程
SyncTextPathView和AsyncTextPathView的工作过程是差不多的,这里以SyncTextPathView为例,介绍它从创建到使用完动画的过程。
- 首先创建的时候,需要会执行
init()
方法:
protected void init() {
//初始化画笔
initPaint();
//初始化文字路径
initTextPath();
//是否自动播放动画
if (mAutoStart) {
startAnimation(0,1);
}
//是否一开始就显示出完整的文字路径
if (mShowInStart){
drawPath(1);
}
}
protected void initPaint(){
mTextPaint = new Paint();
mTextPaint.setTextSize(mTextSize);
mDrawPaint = new Paint();
mDrawPaint.setAntiAlias(true);
mDrawPaint.setColor(mTextStrokeColor);
mDrawPaint.setStrokeWidth(mTextStrokeWidth);
mDrawPaint.setStyle(Paint.Style.STROKE);
if (mTextInCenter){
mDrawPaint.setTextAlign(Paint.Align.CENTER);
}
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setColor(mPaintStrokeColor);
mPaint.setStrokeWidth(mPaintStrokeWidth);
mPaint.setStyle(Paint.Style.STROKE);
}
//省略对initTextPath()和drawPath()方法的代码,因为前面已经有...
复制代码
- 进入测量过程onMeasure:
/**
* 重写onMeasure方法使得WRAP_CONTENT生效
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
// int hSpeMode = MeasureSpec.getMode(heightMeasureSpec);
int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
// int wSpeMode = MeasureSpec.getMode(widthMeasureSpec);
int width = wSpeSize;
int height = hSpeSize;
mTextWidth = TextUtil.getTextWidth(mTextPaint,mText);
mTextHeight = mTextPaint.getFontSpacing() + 1;
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
width = (int) mTextWidth;
}
if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
height = (int) mTextHeight;
}
setMeasuredDimension(width,height);
}
复制代码
- 用户调用
startAnimation()
开始绘制文字路径动画:
/**
* 开始绘制文字路径动画
* @param start 路径比例,范围0-1
* @param end 路径比例,范围0-1
*/
public void startAnimation(float start, float end) {
if (!isProgressValid(start) || !isProgressValid(end)){
return;
}
if (mAnimator != null) {
mAnimator.cancel();
}
initAnimator(start, end);
initTextPath();
canShowPainter = showPainter;
mAnimator.start();
if (mPainter != null) {
mPainter.onStartAnimation();
}
}
复制代码
以上就是SyncTextPathView的一个简单的工作流程,注释应该都写的挺清楚的了,里面还有一些细节,如果想了解可以查看源码。
更新
- 2018/03/08 version 0.0.5:
- 增加了
showFillColorText()
方法来设置直接显示填充好颜色了的全部文字。 - 把TextPathAnimatorListener从TextPathView的内部类里面解放出来,之前使用太麻烦了。
- 增加
showPainterActually
属性,设置所有时候是否显示画笔效果,由于动画绘画完毕应该将画笔特效消失,所以每次执行完动画都会自动将它设置为false。因此它用处就是在不使用自带Animator的时候显示画笔特效。
- 增加了
后话
终于完成了TextPathView的原理介绍,TextPathView我目前想到的应用场景就是做一些简单的开场动画或者进度显示。它是我元旦后在工作外抽空写的,最近几个月工作很忙,生活上遇到了很多的事情,但是还是要坚持做一些自己喜欢的事情,TextPathView会继续维护下去和开发新的东西,希望大家喜欢的话给个star,有意见和建议的提个issue,多多指教。
最后再贴上地址:https://github.com/totond/TextPathView