一、前言
看完本篇你可能会觉得,为什么要UI图呢?自己绘制一个三角形不行么?当然是可以的,但是,正常情况下,他给你一个汽车🚗图标、或者火箭🚀图标你怎么处理呢?
对于开发Path运动的游戏和地图而言,往往要处理物体跟随线条移动,但是线条的不规则导致需要物体频繁旋转角度,但这种旋转其实是一个很难的问题,因为一个多边形有多个顶点,必须要时应该舍弃一些顶点,比如打车软件,经常看到车头跑出了路线图或者原地旋转,主要原因是之一是车体和路不成比例、且顶点难以获取造成。今天本篇从圆环运动来解析一下这类问题。
假设有张图像不规则的Bitmap,想要实现在圆环上旋转和滑动,有没有更好的办法呢?
本篇使用三角形来表示不规则的Bitmap,实现下面的效果。
1.1 预览效果
注:左上角以及三角形边框是DEBUG视图
1.2 关于三角形Bitmap
本篇为了更好的展示效果,自己绘制了一个三角形Bitmap,但实际过程中,可能情况更加复杂其他图形,有UI提供给你。
本篇的核心点就是处理不规则Bitmap图像的绕圆运动。
二、问题点
当设计给你一个矩形汽车或者三角形,你很容易找到中心点,但是现实情况可能没这么幸运,很多图形都是不成比例的,另外中心点找到了,车头或者三角形头部没有贴着线怎么办,这个问题我们暂不处理,后续PathMeasure的案例中会重点处理一下。
目前的难点
- 【1】 中心点落在圆环上
- 【2】运行过程中自动调整 “箭头” 方向
难点:最大的难点不是虚线动画,而是图中的三角形的 “自旋转” + “整体旋转”,因为三角形物件不是正方形,因此自旋转中心点位置很难处理,并且导致中心点很难做 “骑线” 运动。
解决思路:
这个看似简单的问题,利用了很多初中高中数学知识。对于宽高不一致图片或者图形,我们最简单的办法就是将其视为矩形图片中的图形,那么中心点就是矩形图片的中心。然后通过矩阵旋转,再通过矩阵平移到目标位置就能完成。
当然,如果数学基础很好的话,理论上还有其他方法。
三、方案
不规则的Bitmap处理上,首先要确定其边界,确定边界之后才能确定中心点。下面是本篇的实现步骤。
3.1 实现步骤
由于提供的是不规则的三角形Bitmap,技术上我们通过下面方式实现
- 1: 利用PathEffect 实现的线形片段和旋转
- 2:原图转换,原图如果不是规则图形,那么最好的办法求出其正方形轮廓,否则很难和中心点对齐。当然,旋转Canvas也是可以做到的,这里我们主要是通过matrix,学会图片变幻
- 3:图片旋转中心点计算
- 4:切线夹角推理,这里主要考察数学中的圆形的切线问题,(degree + 90)是切线和X轴正方向推理得到的
- 5:Matrix pre/post关系,变换图像分为2个阶段,pre是预处理阶段,post是绘制阶段,理论上让图片先在固定坐标体系下变换,再移动到指定的位置上展示,更让人能容易理解,否则在Post时坐标系一直是动的,可能产生其他问题
3.2 代码逻辑
3.2.1 下面实现虚线,并且通过phase旋转虚线
mPathPaint.setPathEffect(new DashPathEffect(new float[]{40, 20}, phase));
3.2.2 计算正方形边长的一半
float arrowRadius = Math.max(bmpCenterX, bmpCenterY);
3.2.3 利用Matrix旋转
我们直接旋转Bitmap,然后将Bitmap绘制在圆形区域。
3.2.4 完整代码
public class CirclePathArrowView extends View implements ValueAnimator.AnimatorUpdateListener {
private static final boolean IS_DEBUG = true;
private final Paint mPathPaint;
private Bitmap arrowBitmap = null;
private ValueAnimator animator;
private float degree = 0;
private float phase = 0;
private float speed = dp2px(1);
private float offsetDegree = 5; //补偿角度
public CirclePathArrowView(Context context) {
this(context, null);
}
public CirclePathArrowView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CirclePathArrowView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPathPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPathPaint.setAntiAlias(true);
mPathPaint.setFilterBitmap(false);
mPathPaint.setStyle(Paint.Style.STROKE);
mPathPaint.setStrokeWidth(dp2px(1));
mPathPaint.setColor(0xaaffffff);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = 0;
if (heightMode == MeasureSpec.UNSPECIFIED) {
height = (int) dp2px(120);
} else if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(getMeasuredHeight(), getMeasuredWidth());
} else {
height = MeasureSpec.getSize(heightMeasureSpec);
}
setMeasuredDimension(getMeasuredWidth(), height);
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}
private RectF rectF = new RectF();
private Path path = new Path();
private PaintFlagsDrawFilter paintFlagsDrawFilter = new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
private Matrix matrix = new Matrix();
private Bitmap bmp;
Canvas canvasBitmap;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float centerX = getWidth() / 2F;
float centerY = getHeight() / 2F;
float radius = Math.min(getWidth(), getHeight()) / 3F - mPathPaint.getStrokeWidth();
mPathPaint.setPathEffect(new DashPathEffect(new float[]{40, 20}, phase));
path.reset();
path.addCircle(centerX, centerY, radius, Path.Direction.CCW);
canvas.drawPath(path, mPathPaint);
canvas.setDrawFilter(paintFlagsDrawFilter);
if (arrowBitmap != null && !arrowBitmap.isRecycled()) {
double radians = Math.toRadians(degree);
float bmpCenterX = arrowBitmap.getWidth() / 2F;
float bmpCenterY = arrowBitmap.getHeight() / 2F;
float arrowRadius = Math.max(bmpCenterX, bmpCenterY);
float x = (float) (radius * Math.cos(radians) + centerX);
float y = (float) (radius * Math.sin(radians) + centerY);
//这里变幻图像,主要解决2个问题:【1】原图不是正方形,【2】原图变幻问题
if(bmp == null) {
bmp = Bitmap.createBitmap((int) arrowRadius * 2, (int) arrowRadius * 2, Bitmap.Config.ARGB_8888);
canvasBitmap = new Canvas(bmp);
}
bmp.eraseColor(Color.TRANSPARENT);
//矩阵变幻以图片本身左上角为坐标原点,而不是Canvas坐标,因此使用Matrix
matrix.reset();
//预处理,移动原图坐标系,让原图中心点对齐bmp中心点,计算x,y方向的偏移量
float dx = arrowRadius * 2 - bmpCenterX * 2;
float dy = arrowRadius * 2 - bmpCenterY * 2;
matrix.preTranslate(dx, dy);
//预处理,在新坐标系中,找到坐标原点到旋转中心的偏移量
float pX = arrowRadius - dx; //px,py 也是偏移量,不是绝对坐标
float pY = arrowRadius - dx;
matrix.preRotate(degree + 90 + offsetDegree, pX, pY);
canvasBitmap.drawBitmap(arrowBitmap, matrix, mPathPaint);
if (IS_DEBUG) {
canvas.drawBitmap(arrowBitmap, matrix, mPathPaint);
}
rectF.left = x - arrowRadius;
rectF.right = x + arrowRadius;
rectF.top = y - arrowRadius;
rectF.bottom = y + arrowRadius;
int color = mPathPaint.getColor();
mPathPaint.setColor(Color.MAGENTA);
//将新图会知道矩形区域
canvas.drawBitmap(bmp, null, rectF, null);
canvas.drawRect(rectF, mPathPaint);
mPathPaint.setColor(color);
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (arrowBitmap == null || arrowBitmap.isRecycled()) {
arrowBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_arrow_right);
}
if (animator != null) {
animator.cancel();
}
animator = ValueAnimator.ofFloat(0, 360).setDuration(5000);
animator.setInterpolator(new LinearInterpolator());
animator.setRepeatMode(ValueAnimator.RESTART);
animator.setRepeatCount(ValueAnimator.INFINITE);
animator.addUpdateListener(this);
animator.start();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (arrowBitmap != null) {
arrowBitmap.recycle();
arrowBitmap = null;
}
if (animator != null) {
animator.cancel();
}
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
degree = (float) animation.getAnimatedValue();
invalidate();
phase += speed;
if (phase > Integer.MAX_VALUE) {
phase = phase % speed;
}
}
}
以上是本篇的完整代码,核心思想就是构建一个正方形,其次就是旋转Bitmap。
四、总结
处理这类问题一般是规则化图形,再加一定的偏移,后续使用PathMeasure时做运动时,其本质也是构建一个环规则图形来处理不规则图形的运动,但是理解本篇可以方便理解后续实现原理。
另外,本篇运动旋转也可以利用PathMeasure来实现,PathMeasure提供了getMatrix和getPosTan可以直接获取旋转矩阵和旋转角。
下面利用正切角
// 测量 pos(坐标) 和 tan(正切向量)
mPathMeasure.getPosTan(mPathMeasure.getLength() * mCurrentValue, positon, tan);
//注意tan或得到的值是向量,带有方向的,如果不知道这点那么斜率可能求错,可以利用这个向量计算出与x轴的斜率 (y1-y2)/(x1-x2)
float degrees = (float) (Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI);
//重制成为单位矩阵
matrix.reset();
// 设置旋转角度,中心点为正方形矩形中心
matrix.postRotate(degree, rect.centerX(),rect.CenterY());
// 设置偏移量
matrix.postTranslate(position[0] - rect.centerX(), position[1] - rect.CenterY());
五、附录
其实,本篇前面有说过,本篇的核心内容处理非规则UI图的运行轨迹问题,但其实在这篇之前,也画过三角形。
这里我们也贴出代码,供大家参考
public class RingSlideView extends View {
private final DisplayMetrics mDM;
private TextPaint mArcPaint;
private TextPaint mDrawerPaint;
private int maxRadius;
private double rotateDegree = 90;
private float offsetDegree = 3;
public RingSlideView(Context context) {
this(context, null);
}
public RingSlideView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public RingSlideView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}
private void initPaint() {
// 实例化画笔并打开抗锯齿
mArcPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mArcPaint.setAntiAlias(true);
mArcPaint.setStyle(Paint.Style.STROKE);
mArcPaint.setStrokeCap(Paint.Cap.ROUND);
mDrawerPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mDrawerPaint.setAntiAlias(true);
mDrawerPaint.setStyle(Paint.Style.FILL);
mDrawerPaint.setStrokeCap(Paint.Cap.ROUND);
mDrawerPaint.setStrokeWidth(5);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}
private Path path = new Path();
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width == 0 || height == 0 ) {
return;
}
maxRadius = (int) (Math.min(width / 2, height / 2) - dipsToPixels(10) - mDrawerPaint.getStrokeWidth());
int saveCount = canvas.save();
canvas.translate(width*1F/2,height*1F/2); //平移坐标轴到view中心点
canvas.drawCircle(0,0,maxRadius,mArcPaint);
int length = dipsToPixels(10);
path.reset();
for (int i=0;i<3;i++) {
double radians = Math.toRadians(rotateDegree - offsetDegree + i*offsetDegree );
if(i==0) {
float sx = (float) ((maxRadius - length) * Math.cos(radians));
float sy = (float) ((maxRadius - length) * Math.sin(radians));
float tx = (float) ((maxRadius + length) * Math.cos(radians));
float ty = (float) ((maxRadius + length) * Math.sin(radians));
path.moveTo(sx,sy);
path.lineTo(tx,ty);
}else if(i==1){
float x = (float) (maxRadius * Math.cos(radians));
float y = (float) (maxRadius * Math.sin(radians));
canvas.drawLine(0, 0, x, y, mDrawerPaint);
}else if(i==2){
float x = (float) (maxRadius * Math.cos(radians));
float y = (float) (maxRadius * Math.sin(radians));
path.lineTo(x,y);
path.close();
}
}
canvas.drawPath(path,mDrawerPaint);
canvas.restoreToCount(saveCount);
}
final int dipsToPixels(int dips) {
float scale = getContext().getResources().getDisplayMetrics().density;
return (int) (dips * scale + 0.5f);
}
}