文字路径动画控件TextPathView解析

5,825 阅读12分钟

文字路径动画控件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