文字路徑動畫控制元件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