Android撸一个转盘抽奖

6,586 阅读14分钟
原文链接: www.jianshu.com

前言

最近在学习的时候想做个积分转盘抽奖的功能,以前项目中使用过,但是是用的H5写的,但是我现在还不是太会写网页,就想算了,用Android写个吧!因为我这边的业务逻辑是:点击中间的GO按钮后,会先去请求后台数据,后台数据告诉我当前的抽奖结果,比如5QB,然后我这里再转几圈转盘,最终将结果指在5QB那儿,然后弹个窗告诉用户抽奖结果。所以现有情况下的功能都是根据这个应用场景来实现的。下面是DEMO效果图。

DEMO效果

具体实现思路

其实我的实现思路很简单,转盘抽奖,无非就是上面一个指针不动,下面一个圆盘可以转动。

第一种傻瓜式思路

整个视图由两个ImageView构成,放在RelativeLayout或者FrameLayout里面,第一个圆盘的ImageView我们称之为ImageRoot,放在下面,第二个开始旋转的按钮我们称之为ImageGo,放在ImageRoot的上面,当点击ImageRoot的时候,用属性动画让ImageRoot旋转我们计算好的角度就OK了。就像下面一样。是不是很简单,但是这样就算完了,也就没什么意思了~手动黑线脸

傻瓜式操作

第二种可扩展思路

上面的实现在应急情况下是可以使用的,前提是UI给你做好那个圆盘,直接拼上就能使用。但是这样可扩展性并不好,比如总会有需求是换掉分类上面的图片或者文字描述,换个字体大小和颜色,增加一种分类,以前是7个现在要8个等等。。。那么就需要另外一种实现了。

其实最终的思路也是两个VIew的叠加,下面那个VIew负责转,上面的不动。上面的好说就是一个图片,重要的是下面的那个VIew,我们姑且先叫它WheelSurfPanView。

WheelSurfPanView的实现

先来分析下它需要做哪些事情:

  • 分区和绘制背景
    这里的分区指的是将圆盘等额分成多少个扇形
  • 绘制文字
    扇形分好之后,我们需要在每个扇形上面绘制文字描述
  • 绘制图片
    扇形分好之后,我们还需要在每个扇形上面绘制图片说明
  • 盖上最外层的圈圈
    扇形部分处理好了之后,我们需要在最外层盖上一个圆环,这样才叫转盘
  • 旋转自己
    绘制完一切之后,它还需要等待指令旋转自己

重点,敲黑板

下面,我们来一步步分析做法

一、分区
这里的分区的本质就是绘制扇形,用360除以分区的数量得到每一个分区扇形的角度,再循环绘制分区数量的扇形,在每次绘制之前设置好画笔的颜色,这样就可以绘制出不同背景的扇形。这里要说明一下

float startAngle = - mAngle / 2 - 90;

因为在绘制的时候Android的0度在x轴的正方向,顺时针为正,所以正常情况下绘制一个90度的扇形正好绘制在第四象限,但是转盘么,正常我们习惯是指针指向y轴正方向,所以我们少个90度之后再少一个扇形大小的一半,这样的话,第一个扇形的正中间正好绘制在y轴正方向上,完美。

    // 计算初始角度
    // 从最上面开始绘制扇形会好看一点 mAngle指的是每一个扇形的角度
    float startAngle = - mAngle / 2 - 90;
    for ( int i = 0; i < mTypeNum; i++ ) {
        //设置绘制时画笔的颜色
        mPaint.setColor(mColors[i]);
        //画一个扇形 指定范围 范围就是整个圆盘的大小
        RectF rect = new RectF(mCenter - mRadius, mCenter - mRadius, mCenter
                        + mRadius, mCenter + mRadius);
        //第一个参数 oval 参数的作用是:定义的圆弧的形状和大小的范围
        //第二个参数:float startAngle 这个参数的作用是设置圆弧是从哪个角度来顺时针绘画的
        //第三个参数:float sweepAngle 这个参数的作用是设置圆弧扫过的角度
        //第四个参数:boolean useCenter 这个参数的作用是设置我们的圆弧在绘画的时候,是否经过圆形值得注意的是,这个参数在我们的 mPaint.setStyle(Paint.Style.STROKE); 设置为描边属性的时候,是看不出效果的。
        //第五个参数:Paint paint 画笔
        canvas.drawArc(rect, startAngle, mAngle, true, mPaint);
        //重置开始角度
        startAngle = startAngle + mAngle;
    }

效果如下:

分区效果

二、绘制文字

绘制文字也很简单,主要是算好角度和水平垂直偏移量,不然不好看,我们的目标是将文字绘制在每个扇形居中的位置上,然后离圆心稍微近一点,至于近多少看自己喜欢了。代码中mDeses是传进来的文字描述。

    float startAngle = -mAngle / 2 - 90;

    for ( int i = 0; i < mTypeNum; i++ ) {
        drawText(startAngle, mDeses[i], mRadius, mTextPaint, canvas);

        //重置开始角度
        startAngle = startAngle + mAngle;
    }
    
    //绘制文字 
    private void drawText(float startAngle, String string, int radius, Paint textPaint, Canvas canvas) {
        //创建绘制路径
        Path circlePath = new Path();
        //范围也是整个圆盘
        RectF rect = new RectF(mCenter - radius, mCenter - radius, mCenter
                + radius, mCenter + radius);
        //给定扇形的范围
        circlePath.addArc(rect, startAngle, mAngle);

        //圆弧的水平偏移  与路径起始点的水平偏移距离
        float textWidth = textPaint.measureText(string);
        //圆弧的垂直偏移  与路径中心的垂直偏移量
        float hOffset = ( float ) (Math.sin(mAngle / 2 / 180 * Math.PI) * radius) - textWidth / 2;

        //绘制文字
        canvas.drawTextOnPath(string, circlePath, hOffset, radius / 4, textPaint);
    } 

效果如下:

绘制文字

三、 绘制图片

在绘制图片之前,我们要考虑一个问题就是在每个扇形里面的图片都不是很听话的立正站那儿的,他在不同的扇形里面都是有一定的角度的歪着的,除了最上面那个,如下图所示

图片旋转后的效果

那么问题来了,每一个扇形里面的图片的旋转效果我们怎么实现?其实我们仔细想,图片竖直方向的中线肯定是经过圆心的对吧,然后它其实是绕着圆心在旋转,就跟扇形的旋转一样样的,那么我们就吧图片本身以自己图片的圆心来旋转一定的角度不就好了么?真是天才。那么具体的旋转角度以及怎么旋转呢?那么我告诉你,Bitmap其实是可以旋转的,旋转角度的话,其实也很简单,我们只要找到每个扇形的中线,他跟Y轴的夹角就是我们需要旋转图片的角度,具体你自己拿个笔比划比划。

//加载分类图片 存放图片的集合
     mListBitmap = new ArrayList<>();
     for ( int i = 0; i < mTypeNum; i++ ) {
           Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), mIcons[i]);
           int ww = bitmap.getWidth();
           int hh = bitmap.getHeight();
           // 定义矩阵对象
           Matrix matrix = new Matrix();
           // 缩放原图
           matrix.postScale(1f, 1f);
           // 向左旋转mAngle度,参数为正则向右旋转
           matrix.postRotate(mAngle * i);
           //bmp.getWidth(), 500分别表示重绘后的位图宽高
           Bitmap dstbmp = Bitmap.createBitmap(bitmap, 0, 0, ww, hh,matrix, true);
           mListBitmap.add(dstbmp);
      }

图片已经旋转好了,现在集合中存储的图片就是一张张歪七扭八的图片,这样我们在往扇形里面绘制图片的时候就不用考虑旋转的问题了,只需要直接找到地方绘制就行了。那么为了绘制好看呢,我选择的是将图片绘制在扇形的中点位置,因为扇形的最上面要绘制文字,扇形的靠近圆心的位置要留一部分给“开始旋转”的按钮,所以正中间的位置差不多刚刚好。那么首先要找到扇形的中点,然后计算绘制图片的位置范围,再绘制就好了,至于中点怎么计算,这个没有什么好说的,自己拿个尺子画一画比一比,初中数学就够用了。看下面就是我在做的时候画的。害羞脸。。

草稿

代码如下:

   float startAngle = -mAngle / 2 - 90;
   //这些值是为了精确图片的位置
   final int paddingLeft = getPaddingLeft();
   final int paddingRight = getPaddingRight();
   final int paddingTop = getPaddingTop();
   final int paddingBottom = getPaddingBottom();
   int width = getWidth() - paddingLeft - paddingRight;
   int height = getHeight() - paddingTop - paddingBottom;

   for ( int i = 0; i < mTypeNum; i++ ) {
        int imgWidth = mRadius / 4;
        float angle = ( float ) Math.toRadians(startAngle + mAngle / 2);
        //确定图片在圆弧中 中心点的位置
        float x = ( float ) (width / 2 + (mRadius / 2 + mRadius / 12) * Math.cos(angle));
        float y = ( float ) (height / 2 + (mRadius / 2 + mRadius / 12) * Math.sin(angle));
        // 确定绘制图片的位置
        RectF rect1 = new RectF(x - imgWidth / 2, y - imgWidth / 2, x + imgWidth / 2, y + imgWidth / 2);
        canvas.drawBitmap(mListBitmap.get(i), null, rect1, null);
        //重置开始角度
        startAngle = startAngle + mAngle;
    }

效果图如下:

绘制图片后

恩恩,相当完美。。喝茶去。

哎哎哎,等等。。不对呀 ,你特么的没发现就最上面的那个手机最大么?其他手机都是比较小么?

好像是哦!!!

为什么呢?

然后我在绘制图片的时候,顺便把图片的绘制范围绘制出来了,一看才知道问题所在

 mTextPaint.setColor(Color.WHITE);
 canvas.drawRect(rect1, mTextPaint);

效果图:

带边框的

哦哦哦!我明白了,绘制图片的框框都是一样大的,但是图片在框框里面要显示完自己,就必须要缩放自己,才能放下自己,所以自己就变小了,所以最上面那么是最大的,因为他正好是占满所有的区域。

图片实际大小

那如果要正常显示大小,就要把装他的框框弄大一点,就是图中绿色的范围。

实际需要的大小

至于怎么算,自己比比划划就出来了。代码如下

  // 从最上面开始绘制扇形会好看一点
  float startAngle = -mAngle / 2 - 90;
  //这些值是为了精确图片的位置
  final int paddingLeft = getPaddingLeft();
  final int paddingRight = getPaddingRight();
  final int paddingTop = getPaddingTop();
  final int paddingBottom = getPaddingBottom();
  int width = getWidth() - paddingLeft - paddingRight;
  int height = getHeight() - paddingTop - paddingBottom;
  for ( int i = 0; i < mTypeNum; i++ ) {
        //设置绘制时画笔的颜色
        mPaint.setColor(mColors[i]);
        //画一个扇形
        RectF rect = new RectF(mCenter - mRadius, mCenter - mRadius, mCenter
                        + mRadius, mCenter + mRadius);
        canvas.drawArc(rect, startAngle, mAngle, true, mPaint);
        mTextPaint.setColor(mTextColor);
        drawText(startAngle, mDeses[i], mRadius, mTextPaint, canvas);

        int imgWidth = mRadius / 3;
        //计算实际宽高
        int w = ( int ) (Math.abs(Math.cos(Math.toRadians(Math.abs(180 - mAngle * i)))) *
                        imgWidth + imgWidth * Math.abs(Math.sin(Math.toRadians(Math.abs(180 - mAngle * i)))));
        int h = ( int ) (Math.abs(Math.sin(Math.toRadians(Math.abs(180 - mAngle * i)))) *
                        imgWidth + imgWidth * Math.abs(Math.cos(Math.toRadians(Math.abs(180 - mAngle * i)))));

        float angle = ( float ) Math.toRadians(startAngle + mAngle / 2);

        //确定图片在圆弧中 中心点的位置
        float x = ( float ) (width / 2 + (mRadius / 2 + mRadius / 12) * Math.cos(angle));
        float y = ( float ) (height / 2 + (mRadius / 2 + mRadius / 12) * Math.sin(angle));
        // 确定绘制图片的位置
        RectF rect1 = new RectF(x - w / 2, y - h / 2, x + w / 2, y + h / 2);
        canvas.drawBitmap(mListBitmap.get(i), null, rect1, null);

        //重置开始角度
        startAngle = startAngle + mAngle;
  }

处理之后效果如下:

jjjjjjjjjjjj.png

四、盖上最外层的圈圈

这样毕竟不好看,在最外面再加一个圈圈会好看点,这个比较简单,就是按照这个圈的最大范围盖上一个中间透明的圈就好了。

代码如下:

   //最后绘制圆环
   Rect mDestRect = new Rect(0, 0, mWidth, mWidth);
   canvas.drawBitmap(mYuanHuan, null, mDestRect, mPaint);

注意一点,一定要是中间透明的,不然里面的内容就看不到了!效果如下:

加上圆圈

是不是好看多了!

五、旋转自己

这一步比较简单,就是用个属性动画旋转自己。不过有一个点需要注意,这个旋转自己的方法是外部调用的,那么,调用者会告诉我,我最终应该停在哪个扇形里面,所以我们旋转的角度需要根据传入的值来判断。实现不难,具体逻辑看Demo里面的代码吧!下面是部分代码:

/**
     * 开始转动
     * pos 位置 1 开始 这里的位置上是按照逆时针递增的 比如当前指的那个选项是第一个  那么他左边的那个是第二个 以此类推
     */
    public void startRotate(final int pos) {
        //最低圈数是mMinTimes圈 
        int newAngle = ( int ) (360 * mMinTimes + (pos - 1) * mAngle + currAngle - (lastPosition == 0 ? 0 : ((lastPosition - 1) * mAngle)));
        //计算目前的角度划过的扇形份数
        int num = ( int ) ((newAngle - currAngle) / mAngle);
        ObjectAnimator anim = ObjectAnimator.ofFloat(WheelSurfPanView.this, "rotation", currAngle, newAngle);
        currAngle = newAngle;
        lastPosition = pos;
        // 动画的持续时间,执行多久?
        anim.setDuration(num * mVarTime);
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //将动画的过程态回调给调用者
                if ( rotateListener != null )
                    rotateListener.rotating(animation);
            }
        });
        anim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                //当旋转结束的时候回调给调用者当前所选择的内容
                if ( rotateListener != null ) {
                    if ( mType == 1 ) {
                        //去空格和前后空格后输出
                        String des = mDeses[(mTypeNum - pos + 1) %
                                mTypeNum].trim().replaceAll(" ", "");
                        rotateListener.rotateEnd(pos, des);
                    } else {
                        rotateListener.rotateEnd(pos, "");
                    }
                }
            }
        });
        // 正式开始启动执行动画
        anim.start();
    }

效果如下:

旋转自己

到此,WheelSurfPanView的实现就算基本完成了!


WheelSurfView的实现

这个就是最终给用户使用的自定义View,他所做的事情就是包含了上面那个功能性的WheelSurfPanView,然后再引进一个开始按钮,最后做一些逻辑上的处理。

代码如下

package com.cretin.www.wheelsurfdemo.view;

import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.ImageView;
import android.widget.RelativeLayout;

import com.cretin.www.wheelsurfdemo.R;
import com.cretin.www.wheelsurfdemo.RotateListener;

/**
 * Created by cretin on 2017/12/26.
 */

public class WheelSurfView extends RelativeLayout {
    //当前的圆盘VIew
    private WheelSurfPanView mWheelSurfPanView;
    //Context
    private Context mContext;
    //开始按钮
    private ImageView mStart;
    //动画回调监听
    private RotateListener rotateListener;

    public void setRotateListener(RotateListener rotateListener) {
        mWheelSurfPanView.setRotateListener(rotateListener);
        this.rotateListener = rotateListener;
    }

    public WheelSurfView(Context context) {
        super(context);
        init(context, null);
    }

    public WheelSurfView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs);
    }

    public WheelSurfView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    //开始抽奖的图标
    private Integer mGoImgRes;

    private void init(Context context, AttributeSet attrs) {
        mContext = context;
        if ( attrs != null ) {
            //获得这个控件对应的属性。
            TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.wheelSurfView);
            try {
                mGoImgRes = typedArray.getResourceId(R.styleable.wheelSurfView_goImg, 0);
            } finally { //回收这个对象
                typedArray.recycle();
            }
        }

        //添加圆盘视图
        mWheelSurfPanView = new WheelSurfPanView(mContext, attrs);
        RelativeLayout.LayoutParams layoutParams =
                new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT);
        mWheelSurfPanView.setLayoutParams(layoutParams);
        addView(mWheelSurfPanView);

        //添加开始按钮
        mStart = new ImageView(mContext);
        //如果用户没有设置自定义的图标就使用默认的
        if ( mGoImgRes == 0 ) {
            mStart.setImageResource(R.mipmap.node);
        } else {
            mStart.setImageResource(mGoImgRes);
        }
        //给图片设置LayoutParams
        RelativeLayout.LayoutParams llStart =
                new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        llStart.addRule(RelativeLayout.CENTER_IN_PARENT);
        mStart.setLayoutParams(llStart);
        addView(mStart);

        mStart.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                //调用此方法是将主动权交个调用者 由调用者调用开始旋转的方法
                rotateListener.rotateBefore(( ImageView ) v);
            }
        });
    }

    /**
     * 开始旋转
     *
     * @param pisition 旋转最终的位置 注意 从1 开始 而且是逆时针递增
     */
    public void startRotate(int pisition) {
        if ( mWheelSurfPanView != null ) {
            mWheelSurfPanView.startRotate(pisition);
        }
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //视图是个正方形的 所以有宽就足够了 默认值是500 也就是WRAP_CONTENT的时候
        setMeasuredDimension(getDefaultSize(0, widthMeasureSpec), getDefaultSize(0, heightMeasureSpec));
        // Children are just made to fill our space.
        final int childWidthSize = getMeasuredWidth();
        //高度和宽度一样
        heightMeasureSpec = widthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidthSize, MeasureSpec.EXACTLY);

        //onMeasure调用获取到当前视图大小之后,
        // 手动按照一定的比例计算出中间开始按钮的大小,
        // 再设置给那个按钮,免得造成用户传的图片不合适之后显示贼难看
        // 只设置一次
        if ( isFirst ) {
            isFirst = !isFirst;
            //获取中间按钮的大小
            ViewTreeObserver vto = mStart.getViewTreeObserver();
            vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
                @TargetApi( Build.VERSION_CODES.KITKAT )
                @Override
                public void onGlobalLayout() {
                    mStart.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                    float w = mStart.getMeasuredWidth();
                    float h = mStart.getMeasuredHeight();
                    //计算新的大小 默认为整个大小最大值的0.17 至于为什么是0.17  我只想说我乐意。。。。
                    int newW = ( int ) ((( float ) childWidthSize) * 0.17);
                    int newH = ( int ) ((( float ) childWidthSize) * 0.17 * h / w);
                    ViewGroup.LayoutParams layoutParams = mStart.getLayoutParams();
                    layoutParams.width = newW;
                    layoutParams.height = newH;
                    mStart.setLayoutParams(layoutParams);
                }
            });
        }

        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    //记录当前是否是第一次回调onMeasure
    private boolean isFirst = true;
}

最终效果:

最终效果

结语

最终的版本实现的功能如下:

  • 直接给一张
直接给的圆底盘

就能转,这是最简单的方式,不过这个图一定要圆啊,不圆肯定丑

  • 使用第二种方式,给定分区数量,给定每个分区的文字描述和图标资源,给定每个分区的背景颜色,给定最外面大圈的素材,给定最中间的按钮素材,设置每个分区的旋转时间,设置最低的旋转圈数等等,最大化给调用者自定义的空间。

最后的最后

我是Cretin,一个臭不要脸的小男孩儿

代码已经上传到Github,欢迎star哦!具体怎么引用以及参数的配置说明等信息请关注Github的README

Github地址 CSDN博客地址

最后打个广告:本人自主开发了一个小应用,名字叫《段子乐》,现在在华为应用市场,百度手机助手,豌豆荚,小米,酷安市场,魅族等应用市场上已经上线,其他市场正在跟进,主要是一款看段子的小应用,无毒无公害,喜欢看段子的小伙伴可以下载下来尝试一下哦!我不会告诉你,今天做的这个抽奖就是给他用的,哈哈哈