柠檬跑步:跑步轨迹回放动画实现(与咕咚类似)
先看效果
7月-12-2017 18-53-39.gif一、要求
1、轨迹动画流畅,慢-快-慢;
2、渐变色尽量与地图渐变API的效果一致;
3、拖动地图,动画消失,显示完整渐变轨迹。
二、分析
1、高德地图并没有提供相应效果的API,但是可以通过经纬度坐标,转换未屏幕坐标,因此可以自定义一个View来实现轨迹动画的效果。(注意:在自定义的View上画轨迹,一定是要在地图缩放完成后执行,有对应的回调方法,API可查)
2、自定义控件这里有两种思路,可以继承自View,也可以继承SurfaceView。他们的区别相信大家都清楚,我的解决方案中使用了自定义的属性动画,所以我是通过View来实现的。欢迎大家提供SurfaceView的解决方案,共同学习。
3、渐变肯定是用Shader来进行实现,但是这里有一个误区,不能对整个运动轨迹的path设置渐变,Shader的渐变不会跟着你的轨迹走,所以只能分段设置渐变色,相信高德也是这么搞的。
4、动画效果的实现是用的属性动画,这里也有多种实现方式,最初我用了一种比较愚蠢的方案,对每一段path设置动画,通过AnimationSet进行队列展示,但是没考虑到界面渲染效率的问题,导致界面卡顿。
View的渲染大家都清楚,每次invalidate都会导致界面重画,所以我的方案也很简单,通过动画进度,可以算出当前动画执行到的path,根据比例截取,进行绘制。
5、至于相关的相应事件就没什么了,实现方案很多,个人认为最简单的就是在自定义View中通过onTouchEvent处理逻辑并进行回调。
三、相关代码
1、轨迹动画相关数据的工具类
package com.lemon.running.utils;
import android.graphics.LinearGradient;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.graphics.Point;
import android.graphics.Shader;
import com.lemon.running.LemonApplication;
import java.util.ArrayList;
/**
* Created by viva on 17/7/5.
*/
public class RecordPathAnimUtil {
private final long MAX_ANIM_DURATION = 5 * 1000;//动画最大执行时间
private final long MIN_ANIM_DURATION = 2 * 1000;
private final int SCREEN_WIDTH_DEBUG = 1080;//当前调试手机的屏幕宽度,作为计算动画执行时间的标准,无实际意义
private int SCREEN_WIDTH_RELEASE;//用户使用手机屏幕的实际宽度
private long ANIM_DURATION = MIN_ANIM_DURATION;//动画执行总时间
private final float PATH_SCREEN_LENGTH_1_KM = 2000.0f;
private ArrayList<RecordPathBean> recordPathList;
private PathMeasure pathMeasure;
private Path totalPath;
public RecordPathAnimUtil(){
recordPathList = new ArrayList<>();
SCREEN_WIDTH_RELEASE = ScreenUtils.getScreenWidth(LemonApplication.getContext());
}
public long getANIM_DURATION() {
return ANIM_DURATION;
}
public void setANIM_DURATION(long ANIM_DURATION) {
this.ANIM_DURATION = ANIM_DURATION;
}
public ArrayList<RecordPathBean> getRecordPathList() {
return recordPathList;
}
/**
* 创建坐标点对应的path 渐变
* @param start
* @param end
* @param startColor
* @param endColor
*/
public void addPath(Point start,Point end,int startColor,int endColor){
if (totalPath == null){
totalPath = new Path();
totalPath.moveTo(start.x,start.y);
totalPath.lineTo(end.x,end.y);
}
totalPath.lineTo(end.x,end.y);
Path path = new Path();
path.moveTo(start.x,start.y);
path.lineTo(end.x,end.y);
pathMeasure = new PathMeasure(path,false);
Shader shader = new LinearGradient(start.x, start.y, end.x, end.y,new int[]{startColor,endColor},null, Shader.TileMode.CLAMP);
RecordPathBean recordPathBean = new RecordPathBean(path,pathMeasure.getLength(),shader);
recordPathBean.setEndPoint(end);
recordPathBean.setEndColor(endColor);
recordPathList.add(recordPathBean);
recordPathBean.setIndex(recordPathList.size() - 1);
}
/**
* 所有path的总长度
* @return
*/
public float getAllPathLength(){
float pathLength = 0;
if (recordPathList != null){
for (int i = 0,count = recordPathList.size();i < count;i++){
pathLength += recordPathList.get(i).getPathLength();
}
}
caculateAnimDuration(pathLength);
return pathLength;
}
/**
* 计算动画执行的总时长
* @param pathLength
*/
private void caculateAnimDuration(float pathLength){
float pathScreenLength1KmRelease = SCREEN_WIDTH_RELEASE * PATH_SCREEN_LENGTH_1_KM / SCREEN_WIDTH_DEBUG;
float durationScale = pathLength / pathScreenLength1KmRelease;
if (durationScale <= 1)
return;
long durationRelease = (long) (durationScale * MIN_ANIM_DURATION);
if (durationRelease >= MAX_ANIM_DURATION){
setANIM_DURATION(MAX_ANIM_DURATION);
return;
}
setANIM_DURATION(durationRelease);
}
public Path getTotalPath() {
return totalPath;
}
public class RecordPathBean{
private Path path;//路径
private Shader shader;//画笔渐变
private float pathLength;
private int index;
private Point endPoint;
private int endColor;
public RecordPathBean(Path path,float pathLength,Shader shader){
this.path = path;
this.pathLength = pathLength;
this.shader = shader;
}
public Path getPath() {
return path;
}
public Shader getShader() {
return shader;
}
public float getPathLength() {
return pathLength;
}
public int getIndex() {
return index;
}
public void setIndex(int index) {
this.index = index;
}
public Point getEndPoint() {
return endPoint;
}
public void setEndPoint(Point endPoint) {
this.endPoint = endPoint;
}
public int getEndColor() {
return endColor;
}
public void setEndColor(int endColor) {
this.endColor = endColor;
}
}
}
2、自定义控件
package com.lemon.running.ui.view;
import android.animation.Animator;
import android.animation.TypeEvaluator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.AccelerateDecelerateInterpolator;
import com.lemon.running.R;
import com.lemon.running.utils.RecordPathAnimUtil;
import java.util.ArrayList;
/**
* Created by viva on 17/6/20.
*/
public class RecordPathView extends View {
private Context context;
private Paint paint, iconPaint;
private Path dstPath, totalPath;
private PathMeasure mPathMeasure, mDstPathMeasure;
private boolean isDrawRecordPath = false;
private float pathLength;
private Bitmap startIcon, endIcon, middleIcon;
private float[] pathStartPoint = new float[2];
private float[] pathEndPoint = new float[2];
private float[] dstPathEndPoint = new float[2];
private float value = 0;
private long ANIM_DURATION;
private ArrayList<RecordPathAnimUtil.RecordPathBean> recordPathList;
private OnAnimEnd onAnimEnd;
private int animIndex;
public RecordPathView(Context context) {
super(context);
this.context = context;
init();
}
public RecordPathView(Context context, AttributeSet attrs) {
super(context, attrs);
this.context = context;
init();
}
public RecordPathView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.context = context;
init();
}
private void init() {
paint = new Paint();
paint.setAntiAlias(true);
paint.setColor(Color.argb(0, 0, 0, 0));
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(10);
iconPaint = new Paint();
iconPaint.setAntiAlias(true);
dstPath = new Path();
startIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.outside_run_record_start_point);
endIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.outside_run_record_stop_point);
middleIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.speed_view_point);
}
public void setPath(RecordPathAnimUtil recordPathAnimUtil) {
if (recordPathAnimUtil == null)
return;
if (!isDrawRecordPath) {
pathLength = recordPathAnimUtil.getAllPathLength();
ANIM_DURATION = recordPathAnimUtil.getANIM_DURATION();
recordPathList = recordPathAnimUtil.getRecordPathList();
totalPath = recordPathAnimUtil.getTotalPath();
mPathMeasure = new PathMeasure(totalPath, false);
mPathMeasure.getPosTan(0, pathStartPoint, null);//轨迹的起点
mPathMeasure.getPosTan(mPathMeasure.getLength(), pathEndPoint, null);//轨迹的终点
if (recordPathList == null || recordPathList.size() == 0)
return;
startPathAnim();
isDrawRecordPath = true;
}
}
public void setOnAnimEnd(OnAnimEnd onAnimEnd) {
this.onAnimEnd = onAnimEnd;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (recordPathList == null || recordPathList.size() == 0)
return;
if (animIndex > 0){
for (int i = 0; i < animIndex; i++) {
RecordPathAnimUtil.RecordPathBean recordPathBean = recordPathList.get(i);
paint.setColor(recordPathBean.getEndColor());
paint.setShader(recordPathBean.getShader());
paint.setStrokeWidth(10);
paint.setStyle(Paint.Style.STROKE);
canvas.drawPath(recordPathBean.getPath(), paint);
paint.setShader(null);
paint.setStrokeWidth(1);
paint.setStyle(Paint.Style.FILL_AND_STROKE);
canvas.drawCircle(recordPathBean.getEndPoint().x, recordPathBean.getEndPoint().y, 5, paint);
}
}
paint.setStyle(Paint.Style.STROKE);
paint.setShader(recordPathList.get(animIndex).getShader());
paint.setStrokeWidth(10);
canvas.drawPath(dstPath, paint);
canvas.drawBitmap(startIcon, pathStartPoint[0] - startIcon.getWidth() / 2, pathStartPoint[1] - startIcon.getHeight() / 2, iconPaint);
if (value >= 1) {
canvas.drawBitmap(endIcon, pathEndPoint[0] - endIcon.getWidth() / 2, pathEndPoint[1] - endIcon.getHeight() / 2, iconPaint);
} else {
canvas.drawBitmap(middleIcon, dstPathEndPoint[0] - middleIcon.getWidth() / 2, dstPathEndPoint[1] - middleIcon.getHeight() / 2, iconPaint);
}
}
private void caculateAnimPathData(){
float length = value * pathLength;
float caculateLength = 0;
float offsetLength = 0;
for (int i = 0,count = recordPathList.size();i < count;i++){
caculateLength += recordPathList.get(i).getPathLength();
if (caculateLength > length){
animIndex = i;
offsetLength = caculateLength - length;
break;
}
}
dstPath.reset();
PathMeasure pathMeasure = new PathMeasure(recordPathList.get(animIndex).getPath(),false);
pathMeasure.getSegment(0, recordPathList.get(animIndex).getPathLength() - offsetLength, dstPath, true);
mDstPathMeasure = new PathMeasure(dstPath, false);
mDstPathMeasure.getPosTan(mDstPathMeasure.getLength(), dstPathEndPoint, null);
}
private void startPathAnim() {
ValueAnimator animator = ValueAnimator.ofObject(new DstPathEvaluator(), 0, mPathMeasure.getLength());
animator.setDuration(ANIM_DURATION);
animator.setInterpolator(new AccelerateDecelerateInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
value = (float) animation.getAnimatedValue();
caculateAnimPathData();
invalidate();
}
});
animator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
if (onAnimEnd != null)
onAnimEnd.animEndCallback();
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
animator.start();
}
class DstPathEvaluator implements TypeEvaluator {
@Override
public Object evaluate(float fraction, Object startValue, Object endValue) {
return fraction;
}
}
public interface OnAnimEnd {
void animEndCallback();
}
float x, y;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
x = event.getX();
y = event.getY();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_CANCEL:
if (Math.abs(event.getX() - x) > 0 || Math.abs(event.getY() - y) > 0) {
if (onAnimEnd != null)
onAnimEnd.animEndCallback();
}
break;
default:
break;
}
return true;
}
}
注:本文只是提供一种实现方案,其实针对于跑步路径过长的情况,这样处理还是会有跳帧的问题。毕竟代码是死的,人是活的,针对这种情况也有多种优化方案,需要与产品、设计的要求找到一个平衡。
我们自己测试的情况,当小段path的数量大概到达2000的时候,就会跳帧。那么在渲染的时候就需要两种解决方案,可以从渐变上考虑,可以从绘制方式上考虑等。
优化方案请看下一篇文章:
http://www.jianshu.com/p/996f2cfeed29
最后给我们产品做个广告吧:
WechatIMG26.jpeg