今天我们再体验一把图标切换动画。之前看过Material Design的图标切换,
感觉效果挺好的,但是发现很多实现是通过多个图片切换产生的动画效果。如果想要定制属于自己的切换效果显然得要去制作很多张图片,导致apk变大不说,这得需要一定的flash功底啊~,于是我就想是否可以通过属性动画,根据起始path数据和最终的path数据产生动画效果。先来个我们的最终效果图,让你更有动力往下看(PS:以下gif是放慢了的动画,另外gif丢帧导致不流畅,各位不要觉得很卡哈~):
在API 21后,系统内置了AnimatedVectorDrawable ,它能将两个Path以动画方式切换。可是,毕竟不兼容5.0之前的版本,这个类还是过几年再用吧~。既然不用AnimatedVectorDrawable 类,我们就自己写一个呗~。
SVG绘制路径的命令虽然不多,如下:
M : 相当于moveTo 两个参数表示移动终点位置的x,y
L :相当于lineto 两个参数表示x ,y
H :相当于水平的Line to,需要一个参数表示lineto的x坐标,y坐标则是当前绘制点的坐标
V :相当于垂直的line to需要一个参数表示lineto的y坐标
C :curveto(相当于cubicTo,需要6个参数,分别表示第1、2控制点坐标以及结束点的坐标
S :4个参数,表示平滑的使用3阶贝塞尔曲线,另一个控制点坐标被省略,需要我们去计算
Q :二阶贝塞尔曲线,4个参数,分别表示控制点和结束点坐标
T :平滑使用二阶贝塞尔曲线,只有2个参数表示结束点,控制点需要我们计算
A :绘制弧线,参数比较复杂,有7个参数
Z :相当于close path,无参数
其中S、T、A几个命令较复杂,本文先不去实现这几个命令,感兴趣的童鞋可以自己去实现。首先,一个Path是由多个Path组成,由于需要实现动画效果,也就是Path里面的数据我们需要动态变化,我们把各个Path“片段”封装到一个对象中。一个“片段”对应一个svg path的命令,因为参数最多是3个点(Point),我们只需封装3个Point对象:
class FragmentPath { //记录当前path片段的命令 public PathType pathType; // 数据占用长度,同样是Line to,V、H与L后面携带的数据长度不同,这里需要记录 public int dataLen; public Point p1; public Point p2; public Point p3; }
其中,PathType是枚举类型,枚举类型无需加V、H命令,因为V、H在最终绘制的时候还是要转为Line To,dataLen参数用于记录当前的命令所占的字符串长度。PathType枚举类型如下:
enum PathType { MOVE, LINE_TO, CURVE_TO, QUAD_TO, CLOSE }
对SVG path的操作太多,我们把这些操作单独封装到一个SVGUtil中,并将其设置为单例模式:
package com.hc.transformicon;import java.util.ArrayList;import java.util.HashSet;import java.util.List;import java.util.Set;import java.util.regex.Matcher;import java.util.regex.Pattern;import android.graphics.Path;import android.graphics.Point;import android.util.Log;public class SVGUtil { private static volatile SVGUtil svgUtil; private Set<String> svgCommandSet; private String[] command = { "M", "L", "H", "V", "C", "S", "Q", "T", "A", "Z" }; private SVGUtil() { svgCommandSet = new HashSet<String>(); for (String cmd : command) { svgCommandSet.add(cmd); } } public static SVGUtil getInstance() { if (svgUtil == null) { synchronized (SVGUtil.class) { if (svgUtil == null) { svgUtil = new SVGUtil(); } } } return svgUtil; } static class FragmentPath { //记录当前path片段的命令 public PathType pathType; // 数据占用长度,同样是Line to,V、H与L后面携带的数据长度不同,这里需要记录 public int dataLen; public Point p1; public Point p2; public Point p3; } static enum PathType { MOVE, LINE_TO, CURVE_TO, QUAD_TO, ARC, CLOSE } }
由于SVG path中的数据可能写的格式不同,比如使用M命令,有些人会写成:M 100 100而有些人会写成M 100,100这还算好的了,因为看起来比较“规矩”,以空格或逗号分隔字符串就可以提取数据。有些人可能会写成M100,100,也就是在命令字母两边没有加空格,这就让你没办法提取数据了。另外还有就是用户不小心多加了几个空格,或者多加了几个逗号,这让你读取也会带来很多麻烦。还有就是用户还可能把M写成小写的m,在SVG中大小写的含义是不同的,但是我们不是去实现标准的SVG显示,我们可以去忽略大小写,我们只是借鉴一下SVG的命令,顺带学习一下SVG而已。说了那么多,就是为了引入一个话题:需要对用户原始数据进行预处理,在SVGUtil类中添加如下函数:
// 提取SVG数据public ArrayList<String> extractSvgData(String svgData) { //以下为了将命令字母两边添加空格 //保存已经替换过的字母 Set<String> hasReplaceSet = new HashSet<String>(); //正则表达式,用于匹配path里面的字母 Pattern pattern = Pattern.compile("[a-zA-Z]"); Matcher matcher = pattern.matcher(svgData); //遍历匹配正则表达式的字符串 while (matcher.find()) { //s为匹配的字符串 String s = matcher.group(); //如果该字符串没有替换,则在改字符串两边加空格 if (!hasReplaceSet.contains(s)) { svgData = svgData.replace(s, " " + s + " "); hasReplaceSet.add(s); } } //---end--命令字母两边添加字母结束--- //将","替换为" ",并强制转为大写字母 svgData = svgData.replace(",", " ").trim().toUpperCase(); //以" "为分割符分割字符串 String[] ss = svgData.split(" "); //将最终分割成的字符串数组转为List ArrayList<String> data = new ArrayList<String>(); for (String s : ss) { //只有当前的字符串不是空格,才将该字符串加入到List中 //相当于实现了自动删除多余的空格 if (s != null && !"".equals(s)) { data.add(s); } } return data; }
对原始数据做了预处理后,开始真正的将数据转换为Path对象了,在SVGUtil类中添加如下函数:
//根据ArrayList保存的数据,将path数据转为Android中的Path对象//widthFactor,宽度放缩倍数//heightFactor,高度放缩倍数public Path parsePath(ArrayList<String> svgDataList, float widthFactor, float heightFactor) { //new一个需要返回的Path对象 Path path = new Path(); //解析字符串偏移位置 int startIndex = 0; //上一次绘制的终点,默认为左上角 Point lastPoint = new Point(0, 0); //提取下一条FragmentPath对象 FragmentPath fp = nextFrag(svgDataList, startIndex, lastPoint); //如果下一条FragmentPath不为null,则循环 while (fp != null) { //根据命令类型,执行Path的不同方法,主要,所有的坐标需要乘以放缩倍数 switch (fp.pathType) { case MOVE: { path.moveTo(fp.p1.x * widthFactor, fp.p1.y * heightFactor); lastPoint = fp.p1; break; } case LINE_TO: { path.lineTo(fp.p1.x * widthFactor, fp.p1.y * heightFactor); lastPoint = fp.p1; break; } case CURVE_TO: { path.cubicTo(fp.p1.x * widthFactor, fp.p1.y * heightFactor, fp.p2.x * widthFactor, fp.p2.y * heightFactor, fp.p3.x * widthFactor, fp.p3.y * heightFactor); lastPoint = fp.p3; break; } case QUAD_TO: { path.quadTo(fp.p1.x * widthFactor, fp.p1.y * heightFactor, fp.p2.x * widthFactor, fp.p2.y * heightFactor); lastPoint = fp.p2; break; } case CLOSE: { path.close(); } default: break; } //设置下一条Path的偏移量,以便提取下一条命令 startIndex = startIndex + fp.dataLen + 1; fp = nextFrag(svgDataList, startIndex, lastPoint); } return path; }
我们看到,参数中有宽高的放缩倍数。为什么需要放缩倍数呢?我们知道,SVG是矢量图,放缩后图片清晰度是无影响的,因此我们这里需要加放缩倍数。另外我们注意到还有个nextFrag函数,用于提取下一条命令,并封装为FragmentPath对象,在SVGUtil类中添加如下函数:
//根据偏移量,解析下一条命令,并将命令封装为FragmentPath对象private FragmentPath nextFrag(ArrayList<String> svgData, int startIndex, Point lastPoint) { if (svgData == null) return null; int svgDataSize = svgData.size(); if (startIndex >= svgDataSize) return null; // 当前的path片段下标范围[startIndex,i) int i = startIndex + 1; //保存该命令的长度(指数据长度,不包括命令字母) int length = 0; FragmentPath fp = new FragmentPath(); //计算命令的长度 while (i < svgDataSize) { if (svgCommandSet.contains(svgData.get(i))) break; i++; length++; } //数据长度保存到FragmentPath对象中 fp.dataLen = length; // 根据数据的长度,把各个数据封装到Point对象,并保存到FragmentPath中 switch (length) { case 0: { Log.d("", svgData.get(startIndex) + " none data"); break; } case 1: {//如果数据只有一个,那么可能是H或V命令,我们需要根据上一次的终端推算x或y坐标 int d = (int) Float.parseFloat(svgData.get(startIndex + 1)); if (svgData.get(startIndex).equals("H")) { fp.p1 = new Point(d, lastPoint.y); } else {// "V" fp.p1 = new Point(lastPoint.x, d); } break; } case 2: {//两个数据,只有一个Point对象(x,y) int x = (int) Float.parseFloat(svgData.get(startIndex + 1)); int y = (int) Float.parseFloat(svgData.get(startIndex + 2)); fp.p1 = new Point(x, y); break; } case 4: {//4个数据,则封装到两个Point对象中 int x1 = (int) Float.parseFloat(svgData.get(startIndex + 1)); int y1 = (int) Float.parseFloat(svgData.get(startIndex + 2)); int x2 = (int) Float.parseFloat(svgData.get(startIndex + 3)); int y2 = (int) Float.parseFloat(svgData.get(startIndex + 4)); fp.p1 = new Point(x1, y1); fp.p2 = new Point(x2, y2); break; } case 6: {//6个数据,封装到3个Point对象中 int x1 = (int) Float.parseFloat(svgData.get(startIndex + 1)); int y1 = (int) Float.parseFloat(svgData.get(startIndex + 2)); int x2 = (int) Float.parseFloat(svgData.get(startIndex + 3)); int y2 = (int) Float.parseFloat(svgData.get(startIndex + 4)); int x3 = (int) Float.parseFloat(svgData.get(startIndex + 5)); int y3 = (int) Float.parseFloat(svgData.get(startIndex + 6)); fp.p1 = new Point(x1, y1); fp.p2 = new Point(x2, y2); fp.p3 = new Point(x3, y3); break; } default: break; } // 设置当前路径片段的绘制类型 switch (svgData.get(startIndex)) { case "M": { fp.pathType = PathType.MOVE; break; } case "H": case "V": case "L": { fp.pathType = PathType.LINE_TO; break; } case "C": { fp.pathType = PathType.CURVE_TO; break; } case "Q": { fp.pathType = PathType.QUAD_TO; break; } case "Z": { fp.pathType = PathType.CLOSE; break; } } return fp; }
接下来就是自定义View了,由于接下来我们需要实现动画效果,因此我们就将自定义的View继承SurfaceView:
package com.hc.transformicon;import java.util.ArrayList;import android.animation.Animator;import android.animation.ObjectAnimator;import android.animation.TimeInterpolator;import android.animation.ValueAnimator;import android.content.Context;import android.content.res.TypedArray;import android.graphics.Bitmap;import android.graphics.Canvas;import android.graphics.Color;import android.graphics.Paint;import android.graphics.Paint.Cap;import android.graphics.Paint.Join;import android.graphics.Paint.Style;import android.graphics.Path;import android.graphics.Bitmap.Config;import android.util.AttributeSet;import android.util.Log;import android.view.SurfaceHolder;import android.view.SurfaceView;import android.view.View;/** * Created by HuaChao on 2016/6/17. */public class SVGPathView extends SurfaceView implements SurfaceHolder.Callback { // 动画起始Path数据 private ArrayList<String> svgStartDataList; // 动画结束时的Path数据 private ArrayList<String> svgEndDataList; private SurfaceHolder surfaceHolder; // 用于SurfaceView显示的对象 private Bitmap mBitmap; private Canvas mCanvas; private Paint mPaint; // view的宽高 private int mWidth; private int mHeight; // SVG path里面的数据中参考的宽高 private int mViewWidth; private int mViewHeight; // 绘制线条的宽度 private int mPaintWidth; // 用于等比放缩 private float widthFactor; private float heightFactor; private int mPaintColor; public SVGPathView(Context context) { super(context); init(); } public SVGPathView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.SVGPathView); // 读取布局文件设置的起始Path数据和结束Path数据 String svgStartPath = ta .getString(R.styleable.SVGPathView_svg_start_path); String svgEndPath = ta.getString(R.styleable.SVGPathView_svg_end_path); // 如果二者有一个没有设置,就将没有设置的那个设定为已经设置的数据 if (svgStartPath == null && svgEndPath != null) { svgStartPath = svgEndPath; } else if (svgStartPath != null && svgEndPath == null) { svgEndPath = svgStartPath; } // 读取布局文件的配置 mViewWidth = ta.getInteger(R.styleable.SVGPathView_svg_view_width, -1); mViewHeight = ta .getInteger(R.styleable.SVGPathView_svg_view_height, -1); mPaintWidth = ta.getInteger(R.styleable.SVGPathView_svg_paint_width, 5); mPaintColor = ta.getColor(R.styleable.SVGPathView_svg_color, Color.BLACK); // 将原始数据做预处理 svgStartDataList = SVGUtil.getInstance().extractSvgData(svgStartPath); svgEndDataList = SVGUtil.getInstance().extractSvgData(svgEndPath); ta.recycle(); init(); } // 初始化 private void init() { surfaceHolder = getHolder(); surfaceHolder.addCallback(this); mPaint = new Paint(); mPaint.setStrokeJoin(Join.ROUND); mPaint.setStrokeCap(Cap.ROUND); mPaint.setColor(mPaintColor); } // 开始绘制 public void drawPath() { clearCanvas(); mPaint.setStyle(Style.STROKE); mPaint.setColor(mPaintColor); Path path = SVGUtil.getInstance().parsePath(svgStartDataList, widthFactor, heightFactor); mCanvas.drawPath(path, mPaint); Canvas canvas = surfaceHolder.lockCanvas(); canvas.drawBitmap(mBitmap, 0, 0, mPaint); surfaceHolder.unlockCanvasAndPost(canvas); } // 清屏 private void clearCanvas() { mPaint.setColor(Color.WHITE); mPaint.setStyle(Style.FILL); mCanvas.drawRect(0, 0, mWidth, mHeight, mPaint); } // 调用invalidate时,把Bitmap对象绘制到View中 @Override public void invalidate() { super.invalidate(); Canvas canvas = surfaceHolder.lockCanvas(); canvas.drawBitmap(mBitmap, 0, 0, mPaint); surfaceHolder.unlockCanvasAndPost(canvas); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { // 保存当前的View宽高 mWidth = width; mHeight = height; // 如果没有设置Path的参考宽高,默认设置为View的宽高 if (mViewWidth <= 0) { mViewWidth = width; } if (mViewHeight <= 0) { mViewHeight = height; } // 计算放缩倍数 widthFactor = 1.f * width / mViewWidth; heightFactor = 1.f * height / mViewHeight; // 创建Bitmap对象,用于绘制到屏幕中 mBitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); mCanvas = new Canvas(mBitmap); // 将画笔绘制线条的宽度设置为经过放缩后的宽度 mPaint.setStrokeWidth(mPaintWidth * widthFactor); // 清屏 clearCanvas(); // 将清屏结果绘制到屏幕 invalidate(); } @Override public void surfaceCreated(SurfaceHolder holder) { } @Override public void surfaceDestroyed(SurfaceHolder holder) { } }
最后,再看看我们的布局文件以及自定义的布局属性:
styles.xml添加如下:
<declare-styleable name="SVGPathView"> <attr name="svg_start_path" format="reference" /> <attr name="svg_end_path" format="reference" /> <attr name="svg_paint_width" format="integer" /> <attr name="svg_view_width" format="integer" /> <attr name="svg_view_height" format="integer" /> <attr name="svg_color" format="color" /></declare-styleable>
activity_main.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res/com.hc.transformicon" android:layout_width="match_parent" android:layout_height="match_parent" > <com.hc.transformicon.SVGPathView android:id="@+id/svgPathView" android:layout_width="100dp" android:layout_height="100dp" app:svg_color="#00ff00" app:svg_paint_width="18" app:svg_start_path="@string/svg_back" app:svg_view_height="100" app:svg_view_width="100" /></RelativeLayout>
布局文件中可以看到,我们设定的path里面的数据,参考的宽高是100,看看我们的path是怎么写的:
<string name="svg_back">M 50 14 L 90 50 M 10 50 H 90 M 50 86 L 90 50</string>
最终会有一个箭头显示处理,无论我们的SVGPathView宽高如何,都会等比放缩。先看看最后显示的图吧~
为了避免每次都通过解析字符串的方式来生成Path对象,我们需要把ArrayList<String> 转为ArrayList<FragmentPath>即保存已经解析过的命令,减少重复解析。修改SVGPathView类中的svgStartDataList和svgEndDataList:
// 动画起始Path数据private ArrayList<FragmentPath> svgStartDataList;// 动画结束时的Path数据private ArrayList<FragmentPath> svgEndDataList;
并在构造函数中,修改svgStartDataList和svgEndDataList对象创建方式:
SVGUtil svgUtil = SVGUtil.getInstance();// 将原始数据做预处理ArrayList<String> svgStartStrList = svgUtil.extractSvgData(svgStartPath); ArrayList<String> svgEndStrList = svgUtil.extractSvgData(svgEndPath);// 将经过预处理后的path数据,转为FragmentPath列表svgStartDataList = svgUtil.strListToFragList(svgStartStrList); svgEndDataList = svgUtil.strListToFragList(svgEndStrList);
SVGUtil中添加strListToFragList函数:
// 将path字符串列表转为封装成FramentPath片段的列表public ArrayList<FragmentPath> strListToFragList(ArrayList<String> svgDataList) { ArrayList<FragmentPath> fragmentPaths = new ArrayList<SVGUtil.FragmentPath>(); int startIndex = 0; Point lastPoint = new Point(0, 0); FragmentPath fp = nextFrag(svgDataList, startIndex, lastPoint); while (fp != null) { fragmentPaths.add(fp); switch (fp.pathType) { case MOVE: case LINE_TO: { lastPoint = fp.p1; break; } case CURVE_TO: { lastPoint = fp.p3; break; } case QUAD_TO: { lastPoint = fp.p2; break; } default: break; } startIndex = startIndex + fp.dataLen + 1; fp = nextFrag(svgDataList, startIndex, lastPoint); } return fragmentPaths; }
SVGPathView类中的drawPath函数也需要修改,因为我们是通过属性动画动态生成Path了,而不是当初直接解析原始数据生成Path,将drawPath修改如下:
public void drawPath(Path path) { clearCanvas(); mPaint.setStyle(Style.STROKE); mPaint.setColor(mPaintColor); mCanvas.drawPath(path, mPaint); Canvas canvas = surfaceHolder.lockCanvas(); canvas.drawBitmap(mBitmap, 0, 0, mPaint); surfaceHolder.unlockCanvasAndPost(canvas); }
在SVGPathView类中新加一个函数startTransform,用于开启动画,作为开始执行的入口函数:
public void startTransform() {if (!isAnim) { isAnim = true; ValueAnimator va = ValueAnimator.ofFloat(0, 1f); va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float animatorFactor = (float) animation.getAnimatedValue(); Path path = SVGUtil.getInstance().parseFragList( svgStartDataList, svgEndDataList, widthFactor, heightFactor, animatorFactor); drawPath(path); } }); va.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationRepeat(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { isAnim = false; } @Override public void onAnimationCancel(Animator animation) { isAnim = false; } }); va.setDuration(1000).start(); } }// 开始绘制public void drawPath(Path path) { clearCanvas(); mPaint.setStyle(Style.STROKE); mPaint.setColor(mPaintColor); mCanvas.drawPath(path, mPaint); Canvas canvas = surfaceHolder.lockCanvas(); canvas.drawBitmap(mBitmap, 0, 0, mPaint); surfaceHolder.unlockCanvasAndPost(canvas); }
可以看到,真正的核心函数是SVGUtil的parseFragList函数,这个函数是根据起始的Path数据和终止的Path数据,以及动画变化时刻的数据,生成新的Path,这个函数也不复杂:
public Path parseFragList(ArrayList<FragmentPath> svgStartDataList, ArrayList<FragmentPath> svgEndDataList, float widthFactor, float heightFactor, float animatorFactor) { Path path = new Path(); for (int i = 0; i < svgStartDataList.size(); i++) { FragmentPath startFp = svgStartDataList.get(i); FragmentPath endFp = svgEndDataList.get(i); //计算出当前的3个点的位置 int x1 = 0; int y1 = 0; int x2 = 0; int y2 = 0; int x3 = 0; int y3 = 0; if (startFp.p1 != null) { x1 = (int) (startFp.p1.x + (endFp.p1.x - startFp.p1.x) * animatorFactor); y1 = (int) (startFp.p1.y + (endFp.p1.y - startFp.p1.y) * animatorFactor); } if (startFp.p2 != null) { x2 = (int) (startFp.p2.x + (endFp.p2.x - startFp.p2.x) * animatorFactor); y2 = (int) (startFp.p2.y + (endFp.p2.y - startFp.p2.y) * animatorFactor); } if (startFp.p3 != null) { x3 = (int) (startFp.p3.x + (endFp.p3.x - startFp.p3.x) * animatorFactor); y3 = (int) (startFp.p3.y + (endFp.p3.y - startFp.p3.y) * animatorFactor); } switch (startFp.pathType) { case MOVE: { path.moveTo(x1 * widthFactor, y1 * heightFactor); break; } case LINE_TO: { path.lineTo(x1 * widthFactor, y1 * heightFactor); break; } case CURVE_TO: { path.cubicTo(x1 * widthFactor, y1 * heightFactor, x2 * widthFactor, y2 * heightFactor, x3 * widthFactor, y3 * heightFactor); break; } case QUAD_TO: { path.quadTo(x1 * widthFactor, y1 * heightFactor, x2 * widthFactor, y2 * heightFactor); break; } case CLOSE: { path.close(); } default: break; } } return path; }
好啦,看看动画吧~
我们再加上旋转动画一起执行,让切换效果更自然一点,先设置rotateDegree属性,并在onAnimationUpdate函数中添加rotateDegree = animatorFactor * 360;注意,需要在drawPath函数执行之前添加。
将drawPath中的
mCanvas.drawPath(path, mPaint);
改为
mCanvas.save(); mCanvas.rotate(rotateDegree, mWidth / 2, mHeight / 2); mCanvas.drawPath(path, mPaint);
动画设置时间为1秒,加上Gif丢帧的原因,所以上面效果看起似乎有点不流畅
最后,请注意,两个变形的Path数据中,对应的命令格式一定要一模一样,否则会出错!!!!
path数据则必须写成:
<string name="svg_add"> M 10,50 H 90 M 50 10 V 90 </string><string name="svg_remove">M 10,50 H 90 M 10 50 H 90</string>
虽然减号可以通过如下就可以画出来
<string name="svg_remove">M 10,50 H 90 </string>
但是,我们需要加号中后半段数据的最终变形位置,因此不可以省去后面的。