美文网首页Android技术知识Android知识自定义控件
文字路径动画控件TextPathView解析

文字路径动画控件TextPathView解析

作者: 炎之铠 | 来源:发表于2018-03-09 12:04 被阅读39次

    本文出处
    炎之铠csdn博客:http://blog.csdn.net/totond
    炎之铠邮箱:yanzhikai_yjk@qq.com
    本项目Github地址:https://github.com/totond/TextPathView
    本文原创,转载请注明本出处!
    本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

    前言

    此博客主要是介绍TextPathView的实现原理,而TextPathView的使用可以参考README,效果如图:

    image

    思路介绍

    下面写的实现TextPathView思路介绍主要有两部分:一部分是文字路径的实现,包括文字路径的获取、同步绘画和异步绘画;一部分是画笔特效,包括各种画笔特效的实现思路。

    文字路径

    文字路径的实现是核心部分,主要的工作就是把输入的文字转化为Path,然后绘画出来。绘画分为两种绘画:

    • 一种是同步绘画,也就是相当于只有一支“画笔”,按顺序来每个笔画来绘画出文字Path。如下面:


      image
    • 一种是异步绘画,也就是相当于多支“画笔”,每个笔画(闭合的路径)有一支,来一起绘画出文字Path。如下面:


      image
    • 这两者的区别大概就像一个线程同步绘画和多个异步绘画一样,当然实际实现是都是在主线程里面绘画的,具体实现可以看下面介绍。

    文字路径的获取

    获取文字路径用到的是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相对应比例的文字路径了。


    image

    文字路径的异步绘画

    异步绘画,也就是相当于多支“画笔”,每个笔画(闭合的路径)有一支,来一起绘画出文字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();
        }
    
    

    这样就能以每个笔画作为一个个体,按比例显示文字路径了。


    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()方法的实现是这样的(以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就应该是:在前进速度的反方向,以当前绘画点为起点,以一定夹角画出两条直线

    image

    所以我们可以转化为几何数学问题:已知箭头长别为r,夹角为a,还有当前点坐标(x,y),还有它的速度夹角angle,求出箭头两个末端的坐标(字写的难看,不要在意这些细节啦O(∩_∩)O):

    image

    上面这个简单的高中数学问题居然搞了半天,具体是因为我一开始没有使用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方法...
    
    • 火花特效,是箭头特效的引申,就是在箭头的基础上加多几个角度随机,长度随机的箭头,然后把箭头的线段切成随机的段数(段长递增),就成了火花:
      image
    /**
     * 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的时候显示画笔特效。
    image

    后话

    终于完成了TextPathView的原理介绍,TextPathView我目前想到的应用场景就是做一些简单的开场动画或者进度显示。它是我元旦后在工作外抽空写的,最近几个月工作很忙,生活上遇到了很多的事情,但是还是要坚持做一些自己喜欢的事情,TextPathView会继续维护下去和开发新的东西,希望大家喜欢的话给个star,有意见和建议的提个issue,多多指教。

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

    相关文章

      网友评论

        本文标题:文字路径动画控件TextPathView解析

        本文链接:https://www.haomeiwen.com/subject/qtypfftx.html