基于 SurfaceView 详解 android 幸运大转盘,附带实例app
首先说一下,幸运大转盘,以及SurfaceView是在看了也为大神的博客,才有了比较深刻的理解,当然这里附上这位大神的博客地址:博客地址,有兴趣的话你可以去看看,里面有很多的例子。至于我为什么要写这篇博客?,原因之一:加强自己的理解,原因之二:大神的博客就是大神的博客,跳转的太快,基础不好的,很难理解。还有就是一天在实验室太无聊了,没事写写东西。这里我再来更加基础的分析一下。写的不好,原谅。有什么写的不对的地方还望指出,谢谢。附上kensoon918@163.com.有意者交流交流。接下来切入正题,我们来详细解说一下这个大转盘。
1.首先附上效果图以及简单的分析
这个效果图,是不是很眼熟,当然这个就是最后的效果图,货真价实的,一点不偏差,还比这个流畅。因为这个实在虚拟机上面截取的,你也知道虚拟机的流畅度和真机是没有办法比的。
1.首先分析一下,做这个大转盘,都需要实现什么。
不难看出做这个大转盘需要两个控件,一个是自定义的SurfaceView,还有一个当然就是中间的永远不动的按钮指针,每次点击只是换一下图片就行了。
2.说一下实现这个的基本思路。
实现这个看起来是不是很难,当然对于一些大神级别的人物来说,这个就是小菜一碟,但是关键是大多数还不是。咋眼一看,不就是一个盘子在不停的转么。中间多了一个控制盘子的按钮。要实现盘子不停的旋转就得靠这个SurfaceView了,查阅官方ApiSurfaceView直接直接父类是View,SurfaceView与其他的View有一个重要的却别,那就是SurfaceView允许非UI线程修改,这个也上市SurfaceView的一大优点吧, 这样就不用用一个View每次还得通过非UI线程去通知UI线程修改视图,多麻烦啊。 所以通过,SurfaceView你就可以开一个线程在后台不断的更新UI,方便多了。、
2.SurfaceView的常用模板
虽然SurfaceView很方便做一个小游戏,但是也不随便就乱来,网上有个比较常用的模板,相信这样做既能打到你的目的又能事半功倍,何乐而不为啊。
public class SurfaceViewTemplate extends SurfaceView implements Callback,Runnable{ //定义一个SurfaceHolder 用于接受获取的SurfaceHolder private SurfaceHolder mHolder; //用于获取绑定mHOlder的Canvas private Canvas mCanvas; //用于不断绘图的线程 private Thread mThread; //用于控制线程的开关 private boolean isRunning; //这个时代三个参数的构造函数,一般自会在有使用自定义属性的时候才会调用这个构造函数 /** * @param context 上下文 * @param attrs xml文件定义的属性 * @param defStyle 自定义属性 */ public SurfaceViewTemplate(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } //当时用xml文件定义这个自定义View的时候,就会调用这个带两个参数的构造函数 public SurfaceViewTemplate(Context context, AttributeSet attrs) { super(context, attrs); //获取SurfaceHolder mHolder=getHolder(); //添加callback mHolder.addCallback(this); //设置一些属性,焦点,屏幕常亮 setFocusable(true); setFocusableInTouchMode(true); setKeepScreenOn(true); } //当是在代码里面显示的定义的时候就会调用这个带一个参数的构造函数 public SurfaceViewTemplate(Context context) { //在这里我们让他去调用带两个参数的构造函数,以便就算是在代码里面定义的也能完成一些初始化操作 this(context,null); } //主要,也是最核心的工作都是在run方法里面执行的,如draw() @Override public void run() { try{ //这里通过死循环,不断的进行绘图,给你一种盘在不断旋转的错觉 while (isRunning){ draw(); } }catch(Exception e){ e.printStackTrace(); } } @Override public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) { } //在这里做一些初始化的工作,开启线程。。。 @Override public void surfaceCreated(SurfaceHolder arg0) { //实例化线程,并设置isRunning isRunning=true; mThread=new Thread(this); mThread.start(); } //当SurfaceView执行destroy的时候关闭线程 @Override public void surfaceDestroyed(SurfaceHolder arg0) { //关闭线程只需设置isRunning isRunning=false; } //这里制定以下控件的宽高 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); }
3.接下来就根据大转盘,定义成员变量
不得不说,成员变量确实有点多,出去模板给的几个外,还得定义,文本,图片,图片地址,各种。。。有点记不住,附上代码
// surface private SurfaceHolder mHolder; // 与surface绑定在一起的Canvas private Canvas mCanvas; // 用于绘制的线程 private Thread mThread; // 线程的控制开关 private boolean isRunning; // 描述抽奖的文字 private String[] mName = new String[] { "单反相机", "IPAD", "手气不好", "IPHONE", "张杰一枚", "手气不好" }; // 每块的颜色 private int deepColor = 0xFFFFC300; private int lightColor = 0xFFF17E01; private int[] mColors = new int[] { deepColor, lightColor, deepColor, lightColor, deepColor, lightColor, }; // 与文字对应的图片 private int[] mImgs = new int[] { R.drawable.danfan, R.drawable.ipad, R.drawable.f040, R.drawable.iphone, R.drawable.meizi, R.drawable.f040 }; // 与文字对应的图片的数组 private Bitmap[] mImgsBitmap; // 盘块的个数 private final int mItemCount = 6; // 绘制盘块的范围 private RectF mRange = new RectF(); // 圆的直径 private int mRadius; // 绘制盘块的画笔 private Paint mArcPaint; // 绘制文字的画笔 private Paint mTextPaint; // 滚动的速度 private double mSpeed; private volatile float mStartAngle = 0; // 递减的加速度 private int aSpeed = 1; // 是否点击了停止 private boolean isShouldEnd; // 控件的中心位置 private int mCenter; // 控件的padding private int mPadding; // 背景图片 private Bitmap mBgBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bg2); // 文字的大小 private float mTextSize = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, 20, getResources().getDisplayMetrics()); public LuckyPadView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); }
这些基本上都是不可少的,当然你可以简化一些,例如,减速的家速度,以及你已经显示的知道了盘的块数就是6个,每次用的时候写六个就行。
4.编写构造方法
这个构造方法嘛,有多重编写方式,看自己的用途如果只是在xml文件里面定义,且不带自定义属性的时候,就还可以忽略一个和三个参数的构造函数直接编写两个参数的构造函数,当然为了兼容性,建议三个构造函数都支持,以免不必要的麻烦嘛,照例附上代码:
//做一些初始化的操作 public LuckyPadView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); // 获得holder,和与之关联的Canvans mHolder = getHolder(); mHolder.addCallback(this); // 设置可获得焦点,以及常亮 setFocusable(true); setFocusableInTouchMode(true); setKeepScreenOn(true); } //去调用三个参数的构造函数 public LuckyPadView(Context context, AttributeSet attrs) { this(context,null,0); } // 一个参数的构造函数去调用两个构造参数的构造函数 public LuckyPadView(Context context) { this(context, null); }
5.编写onMeasure 方法
写自定义控件的时候,不能忽略的一个方法,他指定了自己的大小,以及子控件最大的大小。。。不多说,有一点值得说一下,就是控件的大小是以宽高当中最小的为为基准这样做的目的是为了让控件能成一个正方形,方便以后绘制圆形,以确定半径,中心等,所以在xml文件里面定义的时候别忘了centerInParent。废话不多说,附上源码:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); // 获得宽高当中最小的 int width = getMeasuredWidth(); int height = getMeasuredHeight(); int min = width < height ? width : height; // 获得圆的直径 mRadius = min - getPaddingLeft() - getPaddingRight(); // 获得padding值,一paddingleft为基准 mPadding = getPaddingLeft(); // 设置中心点 mCenter = min / 2; setMeasuredDimension(min, min); }
6.编写 surfaceCreated 顺便附上 surfaceDestroyed
这两个方法的作用,见名知意。在create的时候我们需要实例化线程,并且将线程开启,以及初始化一些成员变量,如:mRange确定绘图区域,附上一张图就理解了。在destroy的时候我们必须把线程关闭不然就会造成一个严重的后果,内存泄露哦。附上代码:
// 做一些初始化的工作 @Override public void surfaceCreated(SurfaceHolder arg0) { // 初始化绘制圆弧的画笔,并设置锯齿之类的 mArcPaint = new Paint(); mArcPaint.setAntiAlias(true); mArcPaint.setDither(true); // 初始化绘制文字的画笔 mTextPaint = new Paint(); mTextPaint.setColor(0xFFffffff); mTextPaint.setTextSize(mTextSize); // 圆弧的绘制范围,绘制的范围刚好是一个正方形,这个我得做一个插图(1),不然理解不了 mRange = new RectF(mPadding, mPadding, mRadius + mPadding, mRadius + mPadding); // 初始化图片 mImgsBitmap = new Bitmap[mItemCount]; for (int i = 0; i < mItemCount; i++) { mImgsBitmap[i] = BitmapFactory.decodeResource(getResources(), mImgs[i]); } // 开启线程 isRunning = true; mThread = new Thread(this); mThread.start(); } // 主要是用来关闭线程的 @Override public void surfaceDestroyed(SurfaceHolder arg0) { // 通知线程关闭 isRunning = false; }
7.重头戏 run方法
基本上所有的操作都是在这个run方法里面执行的,当然了为了代码的可阅读行,我们打run方法里面的各种操作,打包成方法放在了外面。主要打包的方法draw(),绘图操作。通过一个死循环,不断的进行绘图。给用户一种转盘在不断的旋转的感觉。但是由于绘图是由计算机执行的,所以你是不知道他的执行时间是多少,而且计算机在某一时刻的性能也是不确定的,所以就会造成绘图的时间有差异,这样不就造成了假速度时快时慢这样可不行。所以有一个很巧妙的处理方法,就是记录你绘图的时间。然后你给一个绘图的标准时间值,当小于这个绘图时间的时候,就让线程休眠不足的时间,这样就很好的解决了。附上代码:
@Override public void run() { // 不断地进行绘图,这样就给你一个错觉,转盘在不停的转 while (isRunning) { // 这一次开始绘图的时间 long start = System.currentTimeMillis(); // 真正的绘图操作 draw(); // 这一次绘图的结束时间 long end = System.currentTimeMillis(); // 如果你的手机太快,绘图分分钟的事情,那也得让他把那个50等完 try { if (end - start < 50) { Thread.sleep(50 - (end - start)); } } catch (Exception e) { e.printStackTrace(); } } }
8.run() 方法里面的 draw()
在这个draw()方法里面工作就来了,你首先需要获得与mHolder绑定的Canvas,然后绘制背景,一次绘制每个块的背景,然后再绘制块上面的文字,每个快上面的图片。。。当然每个快的位置是根据mStartAngle和速度以及sweepAngle,tmpAngle计算出来的,还得判断用户是否摁下了停止按钮,如果是,得按一定的加速度减速。当然速度不能小于零所以最后还得判断一下,以便将速度回执到0。对了知道你有没有注意到,各种try{}catch(){},这个是为了防止不知什么时候来个异常之类的,我可不希望我的转盘半路crash了,附上代码:
// 绘图 private void draw() { try { mCanvas = mHolder.lockCanvas(); if (mCanvas != null) { // 首先绘制背景图 drawBg(); // 绘制每个弧形,以及每个弧形上的文字,以及每个弧形上的图片 float tmpAngle = mStartAngle; float sweepAngle = (float) (360 / mItemCount); for (int i = 0; i < mItemCount; i++) { // 这个就是传说中的,背景颜色 mArcPaint.setColor(mColors[i]); // 这里真的画了一个扇形出来,干脆我在这里也弄一个插图算了(4)详细可以参见 // http://blog.sina.com.cn/s/blog_783ede0301012im3.html // oval :指定圆弧的外轮廓矩形区域。 // startAngle: 圆弧起始角度,单位为度。 // sweepAngle: 圆弧扫过的角度,顺时针方向,单位为度。 // useCenter: 如果为True时,在绘制圆弧时将圆心包括在内,通常用来绘制扇形。 // paint: 绘制圆弧的画板属性,如颜色,是否填充等。 mCanvas.drawArc(mRange, tmpAngle, sweepAngle, true, mArcPaint); // 绘制文本 drawText(tmpAngle, sweepAngle, mName[i]); // 绘制Icon drawIcon(tmpAngle, i); // 转换角度,不能再一个地方一直绘制, tmpAngle += sweepAngle; } // 当mspeed不等于0时,相当于滚动 mStartAngle += mSpeed; // 当点击停止时,设置mspeed慢慢递减,而不是一下就停了下来 if (isShouldEnd) { mSpeed -= aSpeed; } // mspeed小于0的时候就该停止了 if (mSpeed < 0) { mSpeed = 0; isShouldEnd = false; } // 根据当前旋转的mStartAngle计算当前滚动的区域 callInExactArea(mStartAngle); } } catch (Exception e) { e.printStackTrace(); } finally { if (mCanvas != null) mHolder.unlockCanvasAndPost(mCanvas); } }
这里面有一个绘制每一个块的背景,插图理解一下
mCanvas.drawArc(mRange, tmpAngle, sweepAngle, true, mArcPaint);
9. draw()方法里面的drawBg()
首先得附上一张图片不然看不懂啊,通过这个图相信你能够非常清楚的看清楚接下来的代码了,无外乎就是在mRange的外面绘制了一圈,用于美观的背景。作用不大,不多说,附上代码:
// 绘制背景图 private void drawBg() { // 根据当前旋转的mStartAngle计算当前滚动到的区域 绘制背景,不重要,完全为了美观 mCanvas.drawColor(0xFFFFFFFF); // 这里这个绘图一般又看不懂了,得有个插图才行,这个貌似比圆弧的绘制范围大了那么一圈 mCanvas.drawBitmap(mBgBitmap, null, new Rect(mPadding / 2, mPadding / 2, getMeasuredWidth() - mPadding / 2, getMeasuredWidth() - mPadding / 2), null); }
10.draw()里的drawText()方法,用于绘制块里面的文本
首先上一张图,绘制这个文本确实有点麻烦,毕竟文本的位置有点特殊。
绘制这个文字我也是理解了很久,真是有点绕。绘制这个文字是通过path先确定一个Arc,为一个弧形,然后通过水平和垂直偏移量共同定位文字的最终位置,图中有标示。float hOffset = (float) (mRadius * Math.PI / mItemCount / 2 - textWidth / 2);解释一下这个公式首先得到弧长,然后减去文字宽度的一半就得到了水平偏移量,垂直偏移量就跟好理解了直接是float vOffset = (float) (mRadius / 2 / 6);也就是半径的1/6 附上代码:
/** * 绘制文本 * * @param startAngle * @param sweepAngle * @param mName2 */ private void drawText(float startAngle, float sweepAngle, String mName2) { // path Path path = new Path(); // 将写字区域加上去 path.addArc(mRange, startAngle, sweepAngle); // 文字的宽度 float textWidth = mTextPaint.measureText(mName2); // 利用水平偏移和垂直偏移让文字居中,是不是理解不了 ,我也是,画个插图,(3) float hOffset = (float) (mRadius * Math.PI / mItemCount / 2 - textWidth / 2); float vOffset = (float) (mRadius / 2 / 6); // 得把文字画上去了 mCanvas.drawTextOnPath(mName2, path, hOffset, vOffset, mTextPaint); }
11.draw()方法里面的drawIcon()
首先还是附上一张图片,便于理解,毕竟绘这个图也不是那么容易理解。 图片是不是很详细。要绘制这个图我们首先得确定一个Rect,而这个Rect就是插图阴影的部分,只要确定了这个阴影部分,绘图就很简单了。关键就是确定这个阴影部分。首先我们通过那个平分角和startAngle得到X和Y通过三角函数,别告诉我你忘了。然后通过mCenter加上和减去等等操作得到了,最后的阴影部分,最后附上代码:
/** * 绘制Icon * * @param startAngle * @param i */ private void drawIcon(float startAngle, int i) { // 设置图片的宽度,为直径的1/8,当然可以随便改 int imgWidth = mRadius / 8; // 换算成弧度 float angle = (float) ((30 + startAngle) * (Math.PI / 180)); // x,y ... 这个或许要一张图篇才能理解,(5) int x = (int) (mCenter + mRadius / 2 / 2 * Math.cos(angle)); int y = (int) (mCenter + mRadius / 2 / 2 * Math.sin(angle)); // 确定绘制图片的位置 Rect rect = new Rect(x - imgWidth / 2, y - imgWidth / 2, x + imgWidth / 2, y + imgWidth / 2); // 绘制 mCanvas.drawBitmap(mImgsBitmap[i], null, rect, null); }
12. luckyStart(index)方法
这个方法,就是显示的在转盘点击开始后,开始转之前根据计算结果设置转盘最后的结果。是不是很想骂一些电商,就知道玩弄我们的感情。结果早就知道了。当然有点难理解,很多公式,先附上一张图: 只要将组后的结果控制在这个210到270的角度范围就行了,至于怎么回事,有公式慢慢理解,附上代码:
/** * 现在总算看穿了,一切电商的阴谋,都是骗人的,电商可以显示的设置你转盘的结果 * * @param luckyIndex */ public void luckyStart(int luckyIndex) { // 每一项的角度大小 float angle = (float) (360 / mItemCount); // 中奖角度范围,因为指针是朝上的所以范围是在210-270,这里要一个插图才能明白啊(6) float from = 270 - (luckyIndex + 1) * angle; float to = from + angle; // 停下来是旋转的距离 float targetFrom = 4 * 360 + from; /* * * 这里有点绕,等细细评味 */ float v1 = (float) (Math.sqrt(1 * 1 + 8 * targetFrom) - 1) / 2; float targetTo = 4 * 360 + to; float v2 = (float) (Math.sqrt(1 * 1 + 8 * 1 * targetTo) - 1) / 2; mSpeed = (float) (v1 + Math.random() * (v2 - v1)); isShouldEnd = false; }
13.SurfaceView 辅助方法和MainActivity代码
这里没有什么贴别的就是对一些方法的调用,在这里我附上一些附加的方法,相信你一看就懂
public void luckyEnd() { mStartAngle = 0; isShouldEnd = true; } public boolean isStart() { return mSpeed != 0; } public boolean isShouldEnd() { return isShouldEnd; }
下面就是MainActivity的代码了
public class MainActivity extends Activity { private LuckyPadView id_luckypadview; private ImageView id_imageview; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initView(); setListener(); } private void setListener() { id_imageview.setOnClickListener(new OnClickListener() { @Override public void onClick(View arg0) { if (!id_luckypadview.isStart()){ id_imageview.setImageResource(R.drawable.stop); Random random=new Random(); id_luckypadview.luckyStart(random.nextInt()%6); }else { if (!id_luckypadview.isShouldEnd()){ id_imageview.setImageResource(R.drawable.start); id_luckypadview.luckyEnd(); } } } }); } private void initView() { id_luckypadview=(LuckyPadView)findViewById(R.id.id_luckypadview); id_imageview=(ImageView)findViewById(R.id.id_imageview); }
14.最后附上XML里面的代码,很简单,一看就懂
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#ffffff" > <com.fat246.view.LuckyPadView android:id="@+id/id_luckypadview" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_centerInParent="true" android:padding="30dp" /> <ImageView android:id="@+id/id_imageview" android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/start" android:layout_centerInParent="true" /> </RelativeLayout>
最后我把源码放到了网上,长期有效。
源码地址