查看原文
其他

来做一个3D效果的小米指南针

ChaosAlaska 鸿洋 2019-04-05

本文作者


作者:ChaosAlaska

链接:

https://www.jianshu.com/p/cee9b7c3abe8

本文由作者投稿发布。


吾幼时即嗜画,家贫无从至笔墨纸砚,遂从姑苏城外寒山寺搬得红枫叶两筐。未几,吾发现红枫叶蒸包子很不错,所以现在我包的包子很好吃。


我的测试机是小米,我发现小米的指南针和钟表挺好玩的,Android画画又不花钱,所以没事就画一画呗。


先看一下效果图:


仿小米指南针:



仿小米时钟:



此篇简书我会 一步一步实现 小米指南针(指南针知道了时钟就很简单了)

先放一张图,以我的思路剖析一下小米指南针,下文碰到的东西都可以对照这张图 帮助理解:



1、首先我们要OnMeasure确定View的大小


经过上图分析,View大小就是:指南针整体正方形上面加一个同宽度的字体矩形


代码如下:


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
   super.onMeasure(widthMeasureSpec, heightMeasureSpec);
   super.onMeasure(widthMeasureSpec, heightMeasureSpec);
   int widthSize = MeasureSpec.getSize(widthMeasureSpec);
   int widthMode = MeasureSpec.getMode(widthMeasureSpec);
   int heightSize = MeasureSpec.getSize(heightMeasureSpec);
   int heightMode = MeasureSpec.getMode(heightMeasureSpec);
   width = Math.min(widthSize, heightSize);
   if (widthMode == MeasureSpec.UNSPECIFIED) {
       width = heightSize;
   } else if (heightMode == MeasureSpec.UNSPECIFIED) {
       width = widthSize;
   }
   //为指南针上面的文字预留空间,定为1/3边张
   mTextHeight = width/3;
   //设置圆心点坐标
   mCenterX = width/2;
   mCenterY = width/2+mTextHeight;
   //外部圆的外径
   mOutSideRadius = width*3/8;
   //外接圆的半径
   mCircumRadius = mOutSideRadius*4/5;
   //camera最大平移距离
   mMaxCameraTranslate = 0.02f*mOutSideRadius;
   //View布局大小
   setMeasuredDimension(width, width+width/3 );
}


2、画指南针上面的文字:


(1)我们用SensorManager,配合SensorEventListener获取到当前手机方位,拿到View的实例,把对应访问参数set给View


chaosCompassView = (ChaosCompassView) findViewById(R.id.ccv);
mSensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
mSensorEventListener = new SensorEventListener() {
   @Override
   public void onSensorChanged(SensorEvent event)
{
       val = event.values[0];
       //给View方位值
       chaosCompassView.setVal(val);
   }
   @Override
   public void onAccuracyChanged(Sensor sensor, int accuracy)
{
   }
};
mSensorManager.registerListener(
           mSensorEventListener,mSensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION),
           SensorManager.SENSOR_DELAY_GAME);


(3)根据方位值,我们可以大体确认文字的具体值,通过观察小米原声指南针,我把方位大体定义 如下图所示:




(3)确定字体位置居中


我们要用Paint.getTextBounds方法来确认字体外接矩形的大小,然后求出字体的宽度,让字体在对应坐标居中显示,字体还涉及到基准线,配合一张图理解。



(4)绘制文字相关代码代码


private void drawText() {
   if (val<=15||val>=345){
       text = "北";
   }else if (val>15&&val<=75){
       text= "东北";
   }else if (val>75&&val<=105){
       text= "东";
   }else if (val>105&&val<=165){
       text="东南";
   }else if (val>165&&val<=195){
       text = "南";
   }else if (val>195&&val<=255){
       text = "西南";
   }else if (val>255&&val<=285){
       text = "西";
   }else if (val>285&&val<345){
       text="西北";
   }
   mTextPaint.getTextBounds(text,0,text.length(),mTextRect);
   //文字宽度
   int mTextWidth = mTextRect.width();
   //让文字水平居中显示
   mCanvas.drawText(text,width/2-mTextWidth/2,mTextHeight/2,mTextPaint);
}


3、画指南针外层圆弧和三角形。


(1)确定好小三角形顶点坐标,然后根据初中数学,算出其余坐标,用Path封闭一个三角型就好。

(2)剩下是画四个圆弧,直接贴代码了


/**
* 指南针外部可简单分为两部分
* 1、用Path实现小三角形
* 2、两个圆弧
*/

private void drawCompassOutSide() {
   mCanvas.save();
   //小三角形的高度
   int mTriangleHeight=40;
   //定义Path画小三角形
   mOutsideTriangle.moveTo(width/2,mTextHeight-mTriangleHeight);
   //小三角形的边长
   float mTriangleSide = 46.18f;
   //画出小三角形
   mOutsideTriangle.lineTo(width/2-mTriangleSide/2,mTextHeight);
   mOutsideTriangle.lineTo(width/2+mTriangleSide/2,mTextHeight);
   mOutsideTriangle.close();
   mCanvas.drawPath(mOutsideTriangle,mOutSideCircumPaint);
   //画圆弧
   mDarkRedPaint.setStrokeWidth((float) 5);
   mLightGrayPaint.setStrokeWidth((float)5);
   mDeepGrayPaint.setStrokeWidth((float)3);
   mLightGrayPaint.setStyle(Paint.Style.STROKE);
   mCanvas.drawArc(width/2-mOutSideRadius,mTextHeight,width/2+mOutSideRadius,mTextHeight+mOutSideRadius*2,-80,120,false,mLightGrayPaint);
   mCanvas.drawArc(width/2-mOutSideRadius,mTextHeight,width/2+mOutSideRadius,mTextHeight+mOutSideRadius*2,40,20,false,mDeepGrayPaint);
   mCanvas.drawArc(width/2-mOutSideRadius,mTextHeight,width/2+mOutSideRadius,mTextHeight+mOutSideRadius*2,-100,-20,false,mLightGrayPaint);
   mCanvas.drawArc(width/2-mOutSideRadius,mTextHeight,width/2+mOutSideRadius,mTextHeight+mOutSideRadius*2,-120,-120,false,mDarkRedPaint);
   mCanvas.restore();
}


到此外部分的处理我们就完事了,应该是这个样子:



4、画指南针内层圆弧和三角形。


(1) 红色的三角形 会跟踪方位值大小移动。所以旋转画布实现跟踪


//参数:  方位值,圆心X,圆心Y
mCanvas.rotate(-val,width/2,mOutSideRadius+mTextHeight);


道理和外层都是一样的直接贴代码了:


/**
* 指南针外接圆,和外部圆换道理差不多
*/

private void drawCompassCircum() {
   mCanvas.save();
   //外接圆小三角形的高度
   int mTriangleHeight=(mOutSideRadius-mCircumRadius)/2;
   mCanvas.rotate(-val,width/2,mOutSideRadius+mTextHeight);
   mCircumTriangle.moveTo(width/2,mTriangleHeight+mTextHeight);
   //内接三角形的边长,简单数学运算
   float mTriangleSide = (float) ((mTriangleHeight/(Math.sqrt(3)))*2);
   mCircumTriangle.lineTo(width/2-mTriangleSide/2,mTextHeight+mTriangleHeight*2);
   mCircumTriangle.lineTo(width/2+mTriangleSide/2,mTextHeight+mTriangleHeight*2);
   mCircumTriangle.close();
   mCanvas.drawPath(mCircumTriangle,mCircumPaint);
   mCanvas.drawArc(width/2-mCircumRadius,mTextHeight+mOutSideRadius-mCircumRadius,
           width/2+mCircumRadius,mTextHeight+mOutSideRadius+mCircumRadius,-85,350,false,mDeepGrayPaint);
   mAnglePaint.setStrokeWidth(5f);
   //这里需要注意的是,对比原声 左右的红色弧线方向会不一样
   if (val<=180){
       valCompare = val;
       mCanvas.drawArc(width/2-mCircumRadius,mTextHeight+mOutSideRadius-mCircumRadius,
               width/2+mCircumRadius,mTextHeight+mOutSideRadius+mCircumRadius,-85,valCompare,false,mAnglePaint);
   }else{
       valCompare = 360-val;
       mCanvas.drawArc(width/2-mCircumRadius,mTextHeight+mOutSideRadius-mCircumRadius,
               width/2+mCircumRadius,mTextHeight+mOutSideRadius+mCircumRadius,-95,-valCompare,false,mAnglePaint);
   }
   mCanvas.restore();
}


效果图:



5、画指南针内心,颜色辐射渐变的圆。


(1)用RadialGradient实现辐射渐变


构造方法:

RadialGradient(float centerX, float centerY, float radius, int centerColor, int edgeColor, TileMode tileMode)。


参数:

  • centerX centerY:辐射中心的坐标

  • radius:辐射半径

  • centerColor:辐射中心的颜色

  • edgeColor:辐射边缘的颜色

  • tileMode:辐射范围之外的着色模式。


代码如下


private void drawInnerCricle() {
   mInnerShader = new RadialGradient(width/2,mOutSideRadius+mTextHeight,mCircumRadius-40, Color.parseColor("#323232"),
           Color.parseColor("#000000"),Shader.TileMode.CLAMP);
   mInnerPaint.setShader(mInnerShader);
   mCanvas.drawCircle(width/2,mOutSideRadius+mTextHeight,mCircumRadius-40,mInnerPaint);
}


对应效果:



6、画指南针刻度表盘 和 圆心数字。


(1)为了让 N E S W 完全居中,我连W都给他单独设置了一个矩阵。。。毕竟很宽。。

(2)表盘的N 也是追随方位值 移动的,注意移动画布就行

(3)圆心数字值 就是方位值的整形。


这块主要也是算坐标,没什么难度,仔细就行。


//刻度
private void drawCompassDegreeScale() {
   mCanvas.save();
   //获取N文字的宽度
   mNorthPaint.getTextBounds("N",0,1,mPositionRect);
   int mPositionTextWidth = mPositionRect.width();
   int mPositionTextHeight = mPositionRect.height();
   //获取W文字宽度,因为W比较宽 所以要单独获取
   mNorthPaint.getTextBounds("W",0,1,mPositionRect);
   int mWPositionTextWidth = mPositionRect.width();
   int mWPositionTextHeight = mPositionRect.height();
   //获取小刻度,两位数的宽度
   mSamllDegreePaint.getTextBounds("30",0,1,mSencondRect);
   int mSencondTextWidth = mSencondRect.width();
   int mSencondTextHeight = mSencondRect.height();
   //获取小刻度,3位数的宽度
   mSamllDegreePaint.getTextBounds("30",0,1,mThirdRect);
   int mThirdTextWidth = mThirdRect.width();
   int mThirdTextHeight = mThirdRect.height();
   mCanvas.rotate(-val,width/2,mOutSideRadius+mTextHeight);
   //画刻度线
   for (int i = 0; i < 240; i++) {
       if (i==0||i==60||i==120||i==180){
           mCanvas.drawLine(getWidth() / 2, mTextHeight+mOutSideRadius-mCircumRadius+10,
                   getWidth() / 2,  mTextHeight+mOutSideRadius-mCircumRadius+30, mDeepGrayPaint);
       }else{
           mCanvas.drawLine(getWidth() / 2, mTextHeight+mOutSideRadius-mCircumRadius+10,
                   getWidth() / 2,  mTextHeight+mOutSideRadius-mCircumRadius+30, mLightGrayPaint);
       }
       if (i==0){
           mCanvas.drawText("N", this.width /2-mPositionTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mPositionTextHeight,mNorthPaint);
       }else if (i==60){
           mCanvas.drawText("E", this.width /2-mPositionTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mPositionTextHeight,mOthersPaint);
       }else if (i==120){
           mCanvas.drawText("S", this.width /2-mPositionTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mPositionTextHeight,mOthersPaint);
       }else if (i==180){
           mCanvas.drawText("W", this.width /2-mWPositionTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mWPositionTextHeight,mOthersPaint);
       }else if (i==20){
           mCanvas.drawText("30", this.width /2-mSencondTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mSencondTextHeight,mSamllDegreePaint);
       }else if (i==40){
           mCanvas.drawText("60", this.width /2-mSencondTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mSencondTextHeight,mSamllDegreePaint);
       }else if (i==80){
           mCanvas.drawText("120", this.width /2-mThirdTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mThirdTextHeight,mSamllDegreePaint);
       }else if (i==100){
           mCanvas.drawText("150", this.width /2-mThirdTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mThirdTextHeight,mSamllDegreePaint);
       }else if (i==140){
           mCanvas.drawText("210", this.width /2-mThirdTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mThirdTextHeight,mSamllDegreePaint);
       }else if (i==160){
           mCanvas.drawText("240", this.width /2-mThirdTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mThirdTextHeight,mSamllDegreePaint);
       }else if (i==200){
           mCanvas.drawText("300", this.width /2-mThirdTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mThirdTextHeight,mSamllDegreePaint);
       }else if (i==220){
           mCanvas.drawText("330", this.width /2-mThirdTextWidth/2,mTextHeight+mOutSideRadius-mCircumRadius+40+mThirdTextHeight,mSamllDegreePaint);
       }
       mCanvas.rotate(1.5f, mCenterX, mOutSideRadius+mTextHeight);
   }
   mCanvas.restore();
}


中心文字


private void drawCenterText() {
   String centerText=String.valueOf((int) val+"°");
   mCenterPaint.getTextBounds(centerText,0,centerText.length(),mCenterTextRect);
   int centerTextWidth = mCenterTextRect.width();
   int centerTextHeight = mCenterTextRect.height();
   mCanvas.drawText(centerText,width/2-centerTextWidth/2,mTextHeight+mOutSideRadius+centerTextHeight/5,mCenterPaint);
}


效果图:



其实做到这里,指南针的功能和View就已经画完了。


接下来我们要做点击动画和Camera实现3D效果


7、通过Camera实现3D效果。


(1)当手指触摸View的时候,通过getX和getY方法得到触摸点,然后通过定义比例值,得到Camera的旋转大小和平移大小。然后在手指抬起时,通过属性动画让View复原。


(2) 使用 Matrix 来做自定义变换


Camera默认旋转是View左上角为旋转中心,所以要我们需要定义Matrix来改变旋转中心。要不扭曲的太夸张了。


camera的设置


/**
* 设置camera相关
*/

private void set3DMetrix() {
   mCameraMatrix.reset();
   mCamera.save();
   mCamera.rotateX(mCameraRotateX);
   mCamera.rotateY(mCameraRotateY);
   mCamera.getMatrix(mCameraMatrix);
   mCamera.restore();
   //camera默认旋转是View左上角为旋转中心
   //所以动作之前要,设置矩阵位置 -mTextHeight-mOutSideRadius
   mCameraMatrix.preTranslate(-getWidth()/2,-getHeight()/2);
   //动作之后恢复位置
   mCameraMatrix.postTranslate(getWidth()/2,getHeight()/2);
   //基于 Canvas 当前的变换,叠加上 Matrix 中的变换。
   mCanvas.concat(mCameraMatrix);
}


onTouchEvent


@Override
public boolean onTouchEvent(MotionEvent event)
{
   switch (event.getAction()){
       case MotionEvent.ACTION_DOWN:
           if (mValueAnimator!=null&&mValueAnimator.isRunning()){
               mValueAnimator.cancel();
           }
           //3D 效果让Camera旋转,获取旋转偏移大小
           getCameraRotate(event);
           //获取平移大小
           getCameraTranslate(event);
           break;
       case MotionEvent.ACTION_MOVE:
           //3D 效果让Camera旋转,获取旋转偏移大小
           getCameraRotate(event);
           //获取平移大小
           getCameraTranslate(event);
           break;
       case MotionEvent.ACTION_UP:
           //松开手 复原动画
           startRestore();
           break;
   }
   return true;
}


获取对应比例


/**
* 获取Camera,平移大小
* @param event
*/

private void getCameraTranslate(MotionEvent event) {
   float translateX = (event.getX() - getWidth() / 2);
   float translateY = (event.getY() - getHeight()/2);
   //求出此时位移的大小与半径之比
   float[] percentArr = getPercent(translateX, translateY);
   //最终位移的大小按比例匀称改变
   mCameraTranslateX = percentArr[0] * mMaxCameraTranslate;
   mCameraTranslateY = percentArr[1] * mMaxCameraTranslate;
}
/**
* 让Camera旋转,获取旋转偏移大小
* @param event
*/

private void getCameraRotate(MotionEvent event) {
   float mRotateX = -(event.getY()-(getHeight())/2);
   float mRotateY = (event.getX()-getWidth()/2);
   //求出旋转大小与半径之比
   float[] percentArr = getPercent(mRotateX,mRotateY);
   mCameraRotateX = percentArr[0]*mMaxCameraRotate;
   mCameraRotateY = percentArr[1]*mMaxCameraRotate;
}
/**
* 获取比例
* @param mCameraRotateX
* @param mCameraRotateY
* @return
*/

private float[] getPercent(float mCameraRotateX, float mCameraRotateY) {
   float[] percentArr = new float[2];
   float percentX = mCameraRotateX/width;
   float percentY = mCameraRotateY/width;
   //处理一下比例值
   if (percentX > 1) {
       percentX = 1;
   } else if (percentX < -1) {
       percentX = -1;
   }
   if (percentY > 1) {
       percentY = 1;
   } else if (percentY < -1) {
       percentY = -1;
   }
   percentArr[0] = percentX;
   percentArr[1] = percentY;
   return percentArr;
}


View复原的属性动画


private void startRestore() {
   final String cameraRotateXName = "cameraRotateX";
   final String cameraRotateYName = "cameraRotateY";
   final String canvasTranslateXName = "canvasTranslateX";
   final String canvasTranslateYName = "canvasTranslateY";
   PropertyValuesHolder cameraRotateXHolder =
           PropertyValuesHolder.ofFloat(cameraRotateXName, mCameraRotateX, 0);
   PropertyValuesHolder cameraRotateYHolder =
           PropertyValuesHolder.ofFloat(cameraRotateYName, mCameraRotateY, 0);
   PropertyValuesHolder canvasTranslateXHolder =
           PropertyValuesHolder.ofFloat(canvasTranslateXName, mCameraTranslateX, 0);
   PropertyValuesHolder canvasTranslateYHolder =
           PropertyValuesHolder.ofFloat(canvasTranslateYName, mCameraTranslateY, 0);
   mValueAnimator = ValueAnimator.ofPropertyValuesHolder(cameraRotateXHolder,
           cameraRotateYHolder, canvasTranslateXHolder, canvasTranslateYHolder);
   mValueAnimator.setInterpolator(new TimeInterpolator() {
       @Override
       public float getInterpolation(float input) {
           float f = 0.571429f;
           return (float) (Math.pow(2, -2 * input) * Math.sin((input - f / 4) * (2 * Math.PI) / f) + 1);
       }
   });
   mValueAnimator.setDuration(1000);
   mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
       @Override
       public void onAnimationUpdate(ValueAnimator animation) {
           mCameraRotateX = (float) animation.getAnimatedValue(cameraRotateXName);
           mCameraRotateY = (float) animation.getAnimatedValue(cameraRotateYName);
           mCameraTranslateX = (float) animation.getAnimatedValue(canvasTranslateXName);
           mCameraTranslateX = (float) animation.getAnimatedValue(canvasTranslateYName);
       }
   });
   mValueAnimator.start();
}


总结


到此指南针的View就完事了,可能是因为绘制量比较大,View时间长了会出现卡顿现象,以后会慢慢完善。


大家如果有什么感兴趣的自定义View可以在下方留言,我抽空会做一些我也感兴趣的。


项目源码链接:


仿小米指南针和时钟

https://github.com/ChaosOctopus/ChaosCompass


如果您觉得喜欢,请给我一个Star。



推荐阅读

Android 混淆查缺补漏

解决一位群友问题  Android上的隔空取物



最后推荐一下我做的网站,玩Androidwanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!



如果你想要跟大家分享你的文章,欢迎投稿~



    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存