来做一个3D效果的小米指南针
本文作者
作者:ChaosAlaska
链接:
https://www.jianshu.com/p/cee9b7c3abe8
本文由作者投稿发布。
吾幼时即嗜画,家贫无从至笔墨纸砚,遂从姑苏城外寒山寺搬得红枫叶两筐。未几,吾发现红枫叶蒸包子很不错,所以现在我包的包子很好吃。
我的测试机是小米,我发现小米的指南针和钟表挺好玩的,Android画画又不花钱,所以没事就画一画呗。
先看一下效果图:
仿小米指南针:
仿小米时钟:
此篇简书我会 一步一步实现 小米指南针(指南针知道了时钟就很简单了)
先放一张图,以我的思路剖析一下小米指南针,下文碰到的东西都可以对照这张图 帮助理解:
1、首先我们要OnMeasure确定View的大小
经过上图分析,View大小就是:指南针整体正方形上面加一个同宽度的字体矩形
代码如下:
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() {
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() {
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: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
如果你想要跟大家分享你的文章,欢迎投稿~