文字路徑動畫控制元件TextPathView解析

Yanzhikai發表於2019-03-03

文字路徑動畫控制元件TextPathView解析

本文出處
炎之鎧csdn部落格:http://blog.csdn.net/totond
炎之鎧郵箱:yanzhikai_yjk@qq.com
本專案Github地址:https://github.com/totond/TextPathView
本文原創,轉載請註明本出處!
本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

前言

  此部落格主要是介紹TextPathView的實現原理,而TextPathView的使用可以參考README,效果如圖:

文字路徑動畫控制元件TextPathView解析

思路介紹

  下面寫的實現TextPathView思路介紹主要有兩部分:一部分是文字路徑的實現,包括文字路徑的獲取、同步繪畫和非同步繪畫;一部分是畫筆特效,包括各種畫筆特效的實現思路。

文字路徑

  文字路徑的實現是核心部分,主要的工作就是把輸入的文字轉化為Path,然後繪畫出來。繪畫分為兩種繪畫:

  • 一種是同步繪畫,也就是相當於只有一支“畫筆”,按順序來每個筆畫來繪畫出文字Path。如下面:

    文字路徑動畫控制元件TextPathView解析
  • 一種是非同步繪畫,也就是相當於多支“畫筆”,每個筆畫(閉合的路徑)有一支,來一起繪畫出文字Path。如下面:

    文字路徑動畫控制元件TextPathView解析
  • 這兩者的區別大概就像一個執行緒同步繪畫和多個非同步繪畫一樣,當然實際實現是都是在主執行緒裡面繪畫的,具體實現可以看下面介紹。

文字路徑的獲取

  獲取文字路徑用到的是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相對應比例的文字路徑了。

文字路徑動畫控制元件TextPathView解析

文字路徑的非同步繪畫

  非同步繪畫,也就是相當於多支“畫筆”,每個筆畫(閉合的路徑)有一支,來一起繪畫出文字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();
    }

複製程式碼

  這樣就能以每個筆畫作為一個個體,按比例顯示文字路徑了。

文字路徑動畫控制元件TextPathView解析

畫筆特效

畫筆特效的原理

  畫筆特效就是以當前繪畫終點為基準,增加一點Path,來使整個動畫看起來更加好看的操作。如下面的火花特效:

文字路徑動畫控制元件TextPathView解析

  具體的原理就是利用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就應該是:在前進速度的反方向,以當前繪畫點為起點,以一定夾角畫出兩條直線

文字路徑動畫控制元件TextPathView解析

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

文字路徑動畫控制元件TextPathView解析

上面這個簡單的高中數學問題居然搞了半天,具體是因為我一開始沒有使用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方法...
複製程式碼
  • 火花特效,是箭頭特效的引申,就是在箭頭的基礎上加多幾個角度隨機,長度隨機的箭頭,然後把箭頭的線段切成隨機的段數(段長遞增),就成了火花:
    文字路徑動畫控制元件TextPathView解析
/**
 * 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我目前想到的應用場景就是做一些簡單的開場動畫或者進度顯示。它是我元旦後在工作外抽空寫的,最近幾個月工作很忙,生活上遇到了很多的事情,但是還是要堅持做一些自己喜歡的事情,TextPathView會繼續維護下去和開發新的東西,希望大家喜歡的話給個star,有意見和建議的提個issue,多多指教。

最後再貼上地址:https://github.com/totond/TextPathView

相關文章