文字路徑動畫控件TextPathView解析

本文出處
炎之鎧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,效果如圖:

image

思路介紹

下面寫的實現(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相對應比例的文字路徑了。


image

文字路徑的異步繪畫

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

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


image

畫筆特效

畫筆特效的原理

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


image

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

image

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

image

上面這個簡單的高中數(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的時候顯示畫筆特效。
image

后話

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

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

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • 發(fā)現(xiàn) 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 15,704評論 4 61
  • 早上她煮螺螄粉,我煮面條,兩人在廚房里熱熱鬧鬧。 給黃姑娘換手機電池,她趴在一邊傻傻的看,喜歡這種感覺。 逛超市我...
    黃姑娘與高先生的日常閱讀 154評論 0 0
  • 懷念在遙遠的大山里的那棟房子,那是曾經做夢的地方,在風雨中屹立了二十多年。自從爺爺奶奶前幾年的相繼離世,它就像沒了...
    David_Panda閱讀 783評論 1 0
  • 楊柳淺草醉春風,赤日朝霞浴長空, 一年三百六十日,策馬揚鞭任馳騁。
    妖嬈郎閱讀 330評論 2 6
  • 菟絲子附著在一片枯黃的老葉上,憤怒的農夫拽起菟絲子的藤蔓,真的好遠好遠…… 農...
    趙先森的小地瓜閱讀 776評論 0 2

友情鏈接更多精彩內容