一次吃饭,突然听到有一个前端朋友有个需求是做一个抽奖的转盘,然后我就思考了下用Android原生的话要怎么实现这个转盘,于是我就自己花时间做个Demo试试。因为时间毕竟是用下班的时间弄的,所以可以说只是个半成品,但是核心功能是能实现的。
一.开发步骤
要开发这样的一个转盘,我随便先在网上找张图看看大概要实现的效果
差不多是这样子的一个效果,然后我觉得主要可以分为以下几步去开发
(1)把转盘给画出来
(2)让转盘转动
(3)把转盘其他零件装上
(4)设置其它的一些属性供特殊的需求使用
二.画转盘
按照上面的步骤,我们先要把转盘给画出来。注意,我这里说的转盘指的是途中只包含圆盘的部分,也就是会转的部分,像开始抽奖的按钮和边框是不包含的,至于为什么我要这样设计,我只能告诉你为了复用。
1.最终实现的效果
先来看看我自己做的最终实现的一个效果(懒得重新截图,那个提示直接无视就行)
做得比较简单,主要的就是在没一个扇形中加一张图和一段文字。
2.思路
思路其实很简单,就是把这个轮盘用自定义view给画出来,那么要画出来,肯定最主要的是用Paint + Canvas在onDraw中把图给画出来。所以说要先熟悉Paint 和Canvas 的基本使用方法。
好,看完了Paint 和Canvas的用法之后,你肯定知道可以画出一个扇形的效果,和Canvas可以旋转画布。我的思路是:我要做的就是在某一个起始的地方画出一个扇形,然后给扇形中添加图片和文字。然后再转动画布扇形的角度,这样就相当于回到起始的地方,这样做的好处就是不用频繁的去计算你要画的坐标。就像你写字,你可以选择移动手和移动纸,而我选择移动纸。
3.代码解析
先测量
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
widthMode = MeasureSpec.getMode(widthMeasureSpec);
widthSize = MeasureSpec.getSize(widthMeasureSpec);
heightMode = MeasureSpec.getMode(heightMeasureSpec);
heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (radius != 0) {
setMeasuredDimension(2 * radius, 2 *radius);
} else {
setMeasuredDimension(widthSize, heightSize);
}
}
我这里定义了一个半径radius 的属性来决定控件的大小。
测量后我们把圆盘给画出来
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (builder != null) {
// 判断是否有扇形
if (builder.disks == null || builder.disks.size() <= 0){
return;
}
// 设置扇形画笔
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(2);
mPaint.setAntiAlias(true);
// 设置分割线画笔
if (builder.division > 0) {
divisionPaint.setStyle(Paint.Style.FILL);
divisionPaint.setStrokeWidth(builder.division);
divisionPaint.setColor(builder.divisionColor);
}
RectF f = new RectF(0, 0, 2 * builder.radius, 2 * builder.radius);
for (int i = 0; i < builder.disks.size(); i++) {
//设置每个扇形的文字
textPaint.setColor(builder.disks.get(i).textColor);
textPaint.setTextSize(builder.disks.get(i).textSize);
// 设置每个扇形的颜色
if (builder.disks.get(i).bgColor != -1){
mPaint.setColor(builder.disks.get(i).bgColor);
}else {
mPaint.setColor(Color.WHITE);
}
// 设置绘制图片的区域
Rect rect = null;
if (builder.picLocations != null && builder.picLocations.length > 0){
rect = new Rect(builder.picLocations[0],builder.picLocations[1],builder.picLocations[2],builder.picLocations[3]);
}else {
// 设为默认位置
// todo 计算默认方位有错,自行修改
rect = new Rect((int) (cX + builder.radius / 3),
(int)(cY + Math.sin(180/builder.disks.size() * Math.PI / 180) + 50),
(int) (cX + builder.radius / 3 + 150),
(int)(cY + Math.sin(180/builder.disks.size() * Math.PI / 180) + 200));
}
// 设置每个扇形的角度
if (builder.angles != null && builder.angles.length > 0){
// 绘制扇形区域
canvas.drawArc(f, 0, builder.angles[i], true, mPaint);
// 绘制分割线
if (builder.division > 0) {
canvas.drawLine(cX, cY, cX + builder.radius, cY, divisionPaint);
}
// 绘制图标
if (builder.disks.get(i).iconBitmap != null) {
canvas.drawBitmap(builder.disks.get(i).iconBitmap, null, rect, null);
}
// 绘制文字
canvas.drawText(builder.disks.get(i).textContent,
(float) (cX + Math.cos(builder.angles[i]/2 * Math.PI / 180) * (builder.radius-36)),
(float) (cY + Math.sin(builder.angles[i]/2 * Math.PI / 180) * (builder.radius-36)),
textPaint);
//旋转画布
canvas.rotate(builder.angles[i], cX, cY);
}else {
canvas.drawArc(f, 0, 360/builder.disks.size(), true, mPaint);
if (builder.division > 0) {
canvas.drawLine(cX, cY, cX + builder.radius, cY, divisionPaint);
}
if (builder.disks.get(i).iconBitmap != null) {
canvas.drawBitmap(builder.disks.get(i).iconBitmap, null, rect, null);
}
canvas.drawText(builder.disks.get(i).textContent,
(float) (cX + Math.cos(180/builder.disks.size() * Math.PI / 180) * (builder.radius-36)),
(float) (cY + Math.sin(180/builder.disks.size() * Math.PI / 180) * (builder.radius-36)),
textPaint);
canvas.rotate(360/builder.disks.size(), cX, cY);
}
}
}
}
因为我的代码是已经全部写出来了,时间关系我也没办法再删回去一步一步说,所以这里就整体的一起说这个流程。
(1)首先我这里因为供很多属性给用户设置,所以在自定义VIew中用了builde模式,如果不知道builde模式建议先去了解。所以第一步先判断builde是不是空
if (builder != null) {
(2)然后第二步,我这里画的扇形是用一个扇形对象来决定的,也就是说我这个类要画出怎样的扇形的效果,是从外部传进来一个扇形数组来决定的,我定义扇形的对象叫做DiskEntity,可以先看看这个类(注释里面也写得很清楚哪个属性代表什么)
public class DiskEntity {
// 背景颜色
public int bgColor = -1;
// 文字内容
public String textContent;
// Icon
public Bitmap iconBitmap;
// 字体颜色
public int textColor = Color.BLACK;
// 字体尺寸
public int textSize = 36;
}
所以才要先判断传进来的扇形数组,也就是List<DiskEntity>是否至少有一个扇形,要是没有的话就没必要画了。
(3)第三步设置画笔
// 设置扇形画笔
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(2);
mPaint.setAntiAlias(true);
// 设置分割线画笔
if (builder.division > 0) {
divisionPaint.setStyle(Paint.Style.FILL);
divisionPaint.setStrokeWidth(builder.division);
divisionPaint.setColor(builder.divisionColor);
}
这个应该不难理解,上面的mPaint是画扇形的画笔,下面的divisionPaint是画分割线的画笔,我这个View里面暂时定义了三个画笔
这里还需要注意的是setAntiAlias一定要写,不然你的圆会画得有锯齿的效果。
(4)设置扇形的区域
RectF f = new RectF(0, 0, 2 * builder.radius, 2 * builder.radius);
要是你看不懂这一句的话,说明你有必要好好学学Paint 和Canvas的用法,所以这句也没什么好解释的。
(5)开始循环画扇形
for (int i = 0; i < builder.disks.size(); i++) {
可以看出我的循环次数是builder.disks.size(),也就是扇形的次数。然后中间也加了注释,没必要多解释,之后我有一个判断
if (builder.angles != null && builder.angles.length > 0){
这是什么意思呢,angles是我的一个从外面传进来的角度数组。为了就是实现扇形不等分圆的情况,所以这个判断的意思是这个圆是否是等分成多个扇形,还是每个扇形的角度都不一定一样。所以里面的代码基本都一样,我们就看其中一份就好
// 绘制扇形区域
canvas.drawArc(f, 0, builder.angles[i], true, mPaint);
// 绘制分割线
if (builder.division > 0) {
canvas.drawLine(cX, cY, cX + builder.radius, cY, divisionPaint);
}
// 绘制图标
if (builder.disks.get(i).iconBitmap != null) {
canvas.drawBitmap(builder.disks.get(i).iconBitmap, null, rect, null);
}
// 绘制文字
canvas.drawText(builder.disks.get(i).textContent,
(float) (cX + Math.cos(builder.angles[i]/2 * Math.PI / 180) * (builder.radius-36)),
(float) (cY + Math.sin(builder.angles[i]/2 * Math.PI / 180) * (builder.radius-36)),
textPaint);
//旋转画布
canvas.rotate(builder.angles[i], cX, cY);
可以看出我还是很良心的加了注释,先看扇形区域,然后判断是不是需要分割线,如果需要分割线就画出来,然后判断是否有图片,有就画出来,然后画文字。最后一步旋转画布
从这里的代码就可以看出我用旋转画布的方法来画每个扇形的好处,就是不用每个扇形都去设置它的位置,我这样就很灵活。
上面的代码也不是很多,但是这样做就能灵活的把一个圆盘给画出来,看不懂不要紧,我最后会把所有代码和项目地址给贴出来。
4.builde模式
我上面说了为了让这个圆盘更加灵活,所以我用了Builde模式,看看里面包含哪些属性
public static class DiskViewBuilder{
private int radius = 0;
private List<DiskEntity> disks = new ArrayList<>();
private int[] angles;
private int[] probabilities;
private int[] picLocations;
private int division = 0;
private int divisionColor = -1;
/**
* 设置半径
*/
public DiskViewBuilder setRadius(int radius){
this.radius = radius;
return this;
}
/**
* 设置扇形对象数组
*/
public DiskViewBuilder setDiskList(List<DiskEntity> disks){
this.disks = disks;
return this;
}
/**
* 设置角度的数组
*/
public DiskViewBuilder setAngles(int[] angles){
this.angles = angles;
return this;
}
/**
* 设置概率数组
*/
public DiskViewBuilder setProbabilities(int[] probabilities){
this.probabilities = probabilities;
return this;
}
/**
* 设置图片的距离
*/
public DiskViewBuilder setPicLocations(int[] picLocations){
this.picLocations = picLocations;
return this;
}
/**
* 设置分割线
*/
public DiskViewBuilder setDivision(int division,int divisionColor){
this.division = division;
this.divisionColor = divisionColor;
return this;
}
public DiskView builder(Context context){
DiskView diskView = new DiskView(context);
diskView.setBuilder(this);
return diskView;
}
}
我干脆把全部代码贴出来算了,免得贴一部分有人看不懂,能看懂的可以直接跳过下面的源码
public class DiskView extends View{
private int widthMode;
private int widthSize;
private int heightMode;
private int heightSize;
private float cX;
private float cY;
private DiskViewBuilder builder;
private Paint mPaint;//饼状画笔
private Paint textPaint;// 文字画笔
private Paint divisionPaint; //分割线画笔
public DiskView(Context context) {
super(context);
init();
}
public DiskView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public DiskView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setBuilder(DiskViewBuilder builder){
this.builder = builder;
cX = builder.radius;
cY = builder.radius;
}
private void init(){
mPaint = new Paint();
textPaint = new Paint();
divisionPaint = new Paint();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
widthMode = MeasureSpec.getMode(widthMeasureSpec);
widthSize = MeasureSpec.getSize(widthMeasureSpec);
heightMode = MeasureSpec.getMode(heightMeasureSpec);
heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (builder != null) {
if (builder.radius != 0) {
setMeasuredDimension(2 * builder.radius, 2 * builder.radius);
} else {
setMeasuredDimension(widthSize, heightSize);
}
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (builder != null) {
// 判断是否有扇形
if (builder.disks == null || builder.disks.size() <= 0){
return;
}
// 设置扇形画笔
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(2);
mPaint.setAntiAlias(true);
// 设置分割线画笔
if (builder.division > 0) {
divisionPaint.setStyle(Paint.Style.FILL);
divisionPaint.setStrokeWidth(builder.division);
divisionPaint.setColor(builder.divisionColor);
}
RectF f = new RectF(0, 0, 2 * builder.radius, 2 * builder.radius);
for (int i = 0; i < builder.disks.size(); i++) {
//设置每个扇形的文字
textPaint.setColor(builder.disks.get(i).textColor);
textPaint.setTextSize(builder.disks.get(i).textSize);
// 设置每个扇形的颜色
if (builder.disks.get(i).bgColor != -1){
mPaint.setColor(builder.disks.get(i).bgColor);
}else {
mPaint.setColor(Color.WHITE);
}
// 设置绘制图片的区域
Rect rect = null;
if (builder.picLocations != null && builder.picLocations.length > 0){
rect = new Rect(builder.picLocations[0],builder.picLocations[1],builder.picLocations[2],builder.picLocations[3]);
}else {
// 设为默认位置
// todo 计算默认方位有错,自行修改
rect = new Rect((int) (cX + builder.radius / 3),
(int)(cY + Math.sin(180/builder.disks.size() * Math.PI / 180) + 50),
(int) (cX + builder.radius / 3 + 150),
(int)(cY + Math.sin(180/builder.disks.size() * Math.PI / 180) + 200));
}
// 设置每个扇形的角度
if (builder.angles != null && builder.angles.length > 0){
// 绘制扇形区域
canvas.drawArc(f, 0, builder.angles[i], true, mPaint);
// 绘制分割线
if (builder.division > 0) {
canvas.drawLine(cX, cY, cX + builder.radius, cY, divisionPaint);
}
// 绘制图标
if (builder.disks.get(i).iconBitmap != null) {
canvas.drawBitmap(builder.disks.get(i).iconBitmap, null, rect, null);
}
// 绘制文字
canvas.drawText(builder.disks.get(i).textContent,
(float) (cX + Math.cos(builder.angles[i]/2 * Math.PI / 180) * (builder.radius-36)),
(float) (cY + Math.sin(builder.angles[i]/2 * Math.PI / 180) * (builder.radius-36)),
textPaint);
//旋转画布
canvas.rotate(builder.angles[i], cX, cY);
}else {
canvas.drawArc(f, 0, 360/builder.disks.size(), true, mPaint);
if (builder.division > 0) {
canvas.drawLine(cX, cY, cX + builder.radius, cY, divisionPaint);
}
if (builder.disks.get(i).iconBitmap != null) {
canvas.drawBitmap(builder.disks.get(i).iconBitmap, null, rect, null);
}
canvas.drawText(builder.disks.get(i).textContent,
(float) (cX + Math.cos(180/builder.disks.size() * Math.PI / 180) * (builder.radius-36)),
(float) (cY + Math.sin(180/builder.disks.size() * Math.PI / 180) * (builder.radius-36)),
textPaint);
canvas.rotate(360/builder.disks.size(), cX, cY);
}
}
}
}
public static class DiskViewBuilder{
private int radius = 0;
private List<DiskEntity> disks = new ArrayList<>();
private int[] angles;
private int[] probabilities;
private int[] picLocations;
private int division = 0;
private int divisionColor = -1;
/**
* 设置半径
*/
public DiskViewBuilder setRadius(int radius){
this.radius = radius;
return this;
}
/**
* 设置扇形对象数组
*/
public DiskViewBuilder setDiskList(List<DiskEntity> disks){
this.disks = disks;
return this;
}
/**
* 设置角度的数组
*/
public DiskViewBuilder setAngles(int[] angles){
this.angles = angles;
return this;
}
/**
* 设置概率数组
*/
public DiskViewBuilder setProbabilities(int[] probabilities){
this.probabilities = probabilities;
return this;
}
/**
* 设置图片的距离
*/
public DiskViewBuilder setPicLocations(int[] picLocations){
this.picLocations = picLocations;
return this;
}
/**
* 设置分割线
*/
public DiskViewBuilder setDivision(int division,int divisionColor){
this.division = division;
this.divisionColor = divisionColor;
return this;
}
public DiskView builder(Context context){
DiskView diskView = new DiskView(context);
diskView.setBuilder(this);
return diskView;
}
}
}
可以看出其实也没有多复杂,这个view就搭建完了。
5.创建圆盘控件
看了源码后我们来看看怎么去创建这个View,这里我命名DiskView而不是LotteryView是因为它只是一部分,而且其它功能也可以使用这个View,先不多说,先看看效果。
要实现上面图中的那种效果,可以在使用的地方调用
diskView = new DiskView.DiskViewBuilder()
.setRadius(400)
.setDiskList(disks)
.builder(this);
linearLayout.addView(diskView);
然后因为是builder模式嘛,我们还可以快速的实现其它效果
比如加个角度数组进去
diskView = new DiskView.DiskViewBuilder()
.setRadius(400)
.setDiskList(disks)
.setAngles(new int[]{30,30,120,60,30,90})
.builder(this);
可以看到实现角度不同的扇形。但是图片是乱的, 因为图片的位置算法我没写好,我自己是写死方位的,没办法,时间问题,但等下我会把图片要怎么显示才好的思路说一下。
再比如说我们加个分割线
diskView = new DiskView.DiskViewBuilder()
.setRadius(400)
.setDiskList(disks)
.setDivision(5,Color.WHITE)
.builder(this);
我加了白色的分割线,认真看还是可以看出的,请注意看,这个效果像什么,没错,加上上面的角度不同,这个两个效果是不是像饼状的统计图,这也就是我一开始这样设计这个类的原因,并不是只有转盘可以使用这个类。
三.旋转
从上面的操作中我们可以把这个图给画出来了,那我们要怎么让它旋转呢,很巧的是Android里面正好有一个东西能很好的实现这个效果,也很符合这个思想,叫属性动画。
做法也很简单,我就直接贴代码了
/**
* 动画帮助类
*/
public class AdminHelper {
/**
* 转盘动画
* 必定转3圈,然后再随机转0—4圈,第二个随机数表示最后停止的位置
*/
public static void showLottery(View view){
int n = RandomManager.getRandomI(4);
int k = RandomManager.getRandomI((int) (360f));
ObjectAnimator anim = ObjectAnimator.ofFloat(view, "rotation", 0f,k+(3+n)*360f);
anim.setInterpolator(new AccelerateDecelerateInterpolator());
anim.setDuration(5000);
anim.start();
}
}
/**
* 管理随机数
*/
public class RandomManager {
public static float getRandomF(){
Random random = new Random();
return random.nextFloat();
}
public static int getRandomI(){
Random random = new Random();
return random.nextInt();
}
public static int getRandomI(int k){
Random random = new Random();
return random.nextInt(k);
}
/**
* 设置概率返回所属范围
* @param probabilities 概率数组
* @return 返回属于哪个概率的下标
*/
public static int getIndexFormProbability(float[] probabilities){
//判断数组所有数累加书否等于1,如果总计不为100%,则返回-1
float count = 0;
for (int i = 0; i < probabilities.length; i++) {
count += probabilities[i];
}
if (count != (float) 1){
return -1;
}
//获取随机数
Random random = new Random();
float result = random.nextFloat();
// 把 1 分成很多段,然后判断最后的数会落在哪一段
count = 0;
for (int i = 0; i < probabilities.length; i++) {
count += probabilities[i];
if (result < count){
return i;
}
}
return -1;
}
}
然后我们现在假设点击一个地方让它转动
TextView textView = findViewById(R.id.tv);
textView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
AdminHelper.showLottery(diskView);
}
});
先看看效果我再解释代码
虽然gif有点卡,但是机子上面是没问题的。然后我现在见到讲解一下我的代码
首先动画的时候我先获取两个随机数
int n = RandomManager.getRandomI(4);
int k = RandomManager.getRandomI((int) (360f));
这是什么意思呢,我第一个随机数是设置圈数,也就是我要至少转3圈,转完3圈之后再随机转0-4圈,这也就是下面动画时写的3+n。第二个随机数是决定转到哪个选项,所以准确来说第一个转多少圈只是为了视觉效果,第二个才是决定最终会停在哪里。其他的都还挺简单的,然后注意下我设了这个属性
anim.setInterpolator(new AccelerateDecelerateInterpolator());
这个主要是模拟一个刚开始转会慢,然后速度变快,到最后快停下的时候再变慢,可能我gif演示得不够明显,自己可以试试,但是这个体验效果一定要,这也是Android5.0之后提出的思想。
最后说说随机数里面的一个方法getIndexFormProbability
这个方法是什么意思呢,按照我上面的做法,其实转到哪个地方的概率是相同的,你想想,抽奖的时候一般能让你概率相同吗?要是有一个iphoneX,你能让他和“谢谢惠顾”的概率是五五开吗,所以我这里就做了一个设置概率的办法,一般来说中iphoneX的概率大概有0.005以下,当然这是我猜的。
四.拼接轮盘与不足
首先说说拼接轮盘,我这里没拼接,因为时间不足,其实做法很简单,就是弄个viewgroup之类的,然后装背景,装我写的轮盘,装指针,很简单。我没写是因为我确实下班的时间不是很多,而且我也没指针这些素材。
然后就是不同概率的方法我写出来了,但是没有写这个切换的方法。
还有就是转盘的文字和图片的方向有点怪,那是因为我设置的原始角度是右下角,想要正常的话设成正下方就行,我就懒得重新再调了,毕竟计算那些坐标值有点烦。
还有一点就是我这里只设置了第一个构造方法,如果真要做出一个自定义view还是要设置一些属性供第二个构造方法比较好,这个也比较简单我就不重复写了。
可以看出我这里只是做出了个半成品,把核心的功能实现了,但是并没有完整的把这个控件搭出来,那是因为一般来说的抽奖页面都是用H5来做的,因为做起来也比原生的方便,而且功能会更灵活,基本都是不会使用原生来做,你想想,如果iphoneX抽完了,我H5可以换一个商品或者打把叉之类的,但是你原生做不到啊,还要更新,所以前端来做这个功能会更灵活,我这里主要是为了证明我们原生的也可以使用自己的方法实现这个功能,而且也不会很繁杂
五.项目地址
考虑到可能我表达会有问题,解释不清楚,所以干脆还是把源码给发出来好了
https://github.com/994866755/handsomeYe.LotteryDemo