本文出處:
炎之鎧csdn博客:http://blog.csdn.net/totond
炎之鎧郵箱:yanzhikai_yjk@qq.com
本項目Github地址:https://github.com/totond/TextPathView
本文原創(chuàng),轉載請注明本出處!
本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家發(fā)布
前言
此博客主要是介紹TextPathView的實現(xiàn)原理,而TextPathView的使用可以參考README,效果如圖:

思路介紹
下面寫的實現(xiàn)TextPathView思路介紹主要有兩部分:一部分是文字路徑的實現(xiàn),包括文字路徑的獲取、同步繪畫和異步繪畫;一部分是畫筆特效,包括各種畫筆特效的實現(xiàn)思路。
文字路徑
文字路徑的實現(xiàn)是核心部分,主要的工作就是把輸入的文字轉化為Path,然后繪畫出來。繪畫分為兩種繪畫:
-
一種是同步繪畫,也就是相當于只有一支“畫筆”,按順序來每個筆畫來繪畫出文字Path。如下面:
image -
一種是異步繪畫,也就是相當于多支“畫筆”,每個筆畫(閉合的路徑)有一支,來一起繪畫出文字Path。如下面:
image 這兩者的區(qū)別大概就像一個線程同步繪畫和多個異步繪畫一樣,當然實際實現(xiàn)是都是在主線程里面繪畫的,具體實現(xiàn)可以看下面介紹。
文字路徑的獲取
獲取文字路徑用到的是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的一個輔助類,可以實現(xiàn)截取Path,獲取Path上點的坐標,正切值等等,具體使用網上很多介紹。
文字路徑的同步繪畫
同步繪畫,也就是按順序繪畫每個筆畫(至于筆畫的順序是誰先誰后,就要看Paint.getTextPath()方法的實現(xiàn)了,這不是重點),這種刻畫在SyncTextPathView實現(xiàn)。
這種繪畫方法不復雜,就是根據(jù)輸入的比例來決定文字路徑的顯示比例就行了,想是這樣想,具體實現(xiàn)還是要通過代碼的,這里先給出一些全局屬性的介紹:
//文字裝載路徑、文字繪畫路徑、畫筆特效路徑
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];
根據(jù)之前init時候獲取的總長度mLengthSum和比例progress,來求取將要繪畫的文字路徑部分的長度mStop,然后用一個while循環(huán)使得mPathMeasure定位到最后一段Path片段,在這期間把循環(huán)的到片段都加入到要繪畫的目標路徑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();
//根據(jù)進度獲取路徑
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實現(xiàn)。
這種繪畫方法也不是很復雜,就是根據(jù)比例來決定文字路徑里面每一個筆畫(閉合的路徑)的顯示比例就行了。
具體就是使用while循環(huán)遍歷所有筆畫(閉合的路徑)Path,循環(huán)里面根據(jù)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();
//根據(jù)進度獲取路徑
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()方法的實現(xiàn)是這樣的(以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 {
//開始動畫的時候執(zhí)行
void onStartAnimation();
/**
* 繪畫畫筆特效時候執(zhí)行
* @param x 當前繪畫點x坐標
* @param y 當前繪畫點y坐標
* @param paintPath 畫筆Path對象,在這里畫出想要的畫筆特效
*/
@Override
void onDrawPaintPath(float x, float y, Path paintPath);
}
public interface AsyncTextPainter extends TextPainter{
/**
* 繪畫畫筆特效時候執(zhí)行
* @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接口來繪畫附加特效。
特效實現(xiàn)示例
TextPathView暫時實現(xiàn)了3種自帶的畫筆特效可以選擇:
//箭頭畫筆特效,根據(jù)傳入的當前點與上一個點之間的速度方向,來調整箭頭方向
public class ArrowPainter implements SyncTextPathView.SyncTextPainter{}
//一支筆的畫筆特效,就是在繪畫點旁邊畫多一支筆
public class PenPainter implements SyncTextPathView.SyncTextPainter,AsyncTextPathView.AsyncTextPainter {}
//火花特效,根據(jù)箭頭引申變化而來,根據(jù)當前點與上一個點算出的速度方向來控制火花的方向
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;
}
}
- 箭頭特效:根據(jù)傳入的當前點與上一個點之間的速度方向,來使箭頭方向始終向前。
所以這個Path就應該是:在前進速度的反方向,以當前繪畫點為起點,以一定夾角畫出兩條直線:

所以我們可以轉化為幾何數(shù)學問題:已知箭頭長別為r,夾角為a,還有當前點坐標(x,y),還有它的速度夾角angle,求出箭頭兩個末端的坐標(字寫的難看,不要在意這些細節(jié)啦O(∩_∩)O):

上面這個簡單的高中數(shù)學問題居然搞了半天,具體是因為我一開始沒有使用Android的View坐標系來畫,一直用傳統(tǒng)的數(shù)學坐標系來畫,所以算出來每次都有偏差,意識到這個問題之后就簡單了。
根據(jù)上面的推導過程我們可以得出箭頭兩個末端的坐標,然后就是用代碼表達出來了:
/**
* author : yany
* e-mail : yanzhikai_yjk@qq.com
* time : 2018/02/09
* desc : 箭頭畫筆特效,根據(jù)傳入的當前點與上一個點之間的速度方向,來調整箭頭方向
*/
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方法...
-
火花特效,是箭頭特效的引申,就是在箭頭的基礎上加多幾個角度隨機,長度隨機的箭頭,然后把箭頭的線段切成隨機的段數(shù)(段長遞增),就成了火花:
image
/**
* author : yany
* e-mail : yanzhikai_yjk@qq.com
* time : 2018/02/11
* desc : 火花特效,根據(jù)箭頭引申變化而來,根據(jù)當前點與上一個點算出的速度方向來控制火花的方向
*/
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;
//同時存在箭頭數(shù)
private static final int arrowCount = 6;
//最大線段切斷數(shù)
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;
}
}
}
整體結構
上面介紹的都是局部的細節(jié)實現(xiàn),但是TextPathView作為一個自定義View,是需要封裝一個整體的工作流程的,這樣才能讓使用者方便地使用,降低耦合性。
父類TextPathView
看過README的都知道,TextPathView并不提供給用戶直接使用,而是讓用戶來使用它的子類SyncTextPathView和AsyncTextPathView來實現(xiàn)同步繪畫和異步繪畫的功能。而父類TextPathView則是負責寫一些給子類復用的代碼。具體代碼就不貼了,可以直接看Github。
工作流程
SyncTextPathView和AsyncTextPathView的工作過程是差不多的,這里以SyncTextPathView為例,介紹它從創(chuàng)建到使用完動畫的過程。
- 首先創(chuàng)建的時候,需要會執(zhí)行
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的一個簡單的工作流程,注釋應該都寫的挺清楚的了,里面還有一些細節(jié),如果想了解可以查看源碼。
更新
- 2018/03/08 version 0.0.5:
- 增加了
showFillColorText()方法來設置直接顯示填充好顏色了的全部文字。 - 把TextPathAnimatorListener從TextPathView的內部類里面解放出來,之前使用太麻煩了。
- 增加
showPainterActually屬性,設置所有時候是否顯示畫筆效果,由于動畫繪畫完畢應該將畫筆特效消失,所以每次執(zhí)行完動畫都會自動將它設置為false。因此它用處就是在不使用自帶Animator的時候顯示畫筆特效。
- 增加了

后話
終于完成了TextPathView的原理介紹,TextPathView我目前想到的應用場景就是做一些簡單的開場動畫或者進度顯示。它是我元旦后在工作外抽空寫的,最近幾個月工作很忙,生活上遇到了很多的事情,但是還是要堅持做一些自己喜歡的事情,TextPathView會繼續(xù)維護下去和開發(fā)新的東西,希望大家喜歡的話給個star,有意見和建議的提個issue,多多指教。


