Android 幸运转盘实现逻辑

一、前言

幸运转盘在很多app中都有,也有很多现实的例子,不过这个难度并不是如何让转盘转起来,真正的难度是如何统一个方向转动,且转到指定的目标区域(中奖概率从来不是随机的),当然还不能太假,需要有一定的位置偏移。

效果预览

二、逻辑实现

2.1 平分区域

由于圆周是360度,品分每个站preDegree,那么起点和终点

但是为了让X轴初始化时对准第一个区域的中心,我们做一下小偏移,逆时针旋转一下

java 复制代码
//圆点起始角度 ,可以理解为index=0的起始角度,我们以index=0位参考点
float zeroStartDegree = 0 + -perDegree / 2;
float endStartDegree = 0 + perDegree / 2;

那么每个区域的起始角度如下

java 复制代码
float startDegree = i* perDegree  - perDegree / 2;
float endDegree = i * perDegree + perDegree / 2 ;

2.2 画弧

很简单,不需要计算每个分区的大小,因为平分的是同一个大圆,因此Rect是大圆的范围,但也要记住 ,弧的起始角+绘制角度不能大于等于360,最大貌似是359.9998399

java 复制代码
canvas.drawArc(rectF, startDegree, endDegree - startDegree, true, mDrawerPaint);

2.3 文字绘制

由于Canvas.drawText不能设置角度,那么意味着不能直接绘制,需要做一定的角度转换,要么旋转Canvas坐标,要们旋转Path,这次我们选后者吧。

使用Path的原因是,他不仅具备矢量性质(不失真),而且还能转动文字的方向和从有到左绘制。

java 复制代码
           //计算出中心角度 
           float centerRadius = (float) Math.toRadians((startDegree + endDegree)/2F);

            float measuredTextWidth = mDrawerPaint.measureText(item.text);
            float measuredTextHeight = getTextHeight(mDrawerPaint,item.text);

            float innerRadius = maxRadius - 2* measuredTextHeight;

            float cx = (float) ((innerRadius - measuredTextHeight)   * Math.cos(centerRadius));
            float cy = (float) ((innerRadius - measuredTextHeight)  * Math.sin(centerRadius));

            double degreeOffset = Math.asin((measuredTextWidth/2F)/innerRadius);
            float startX= (float) (innerRadius * Math.cos(centerRadius - degreeOffset));
            float startY = (float) (innerRadius * Math.sin(centerRadius - degreeOffset));

            float endX= (float) ((innerRadius) * Math.cos(centerRadius + degreeOffset));
            float endY = (float) ((innerRadius) * Math.sin(centerRadius + degreeOffset));

            path.reset();
            path.moveTo(startX,startY);
            path.lineTo(endX,endY);
            //这里使用Path的原因是文本角度无法设置

            canvas.drawTextOnPath(item.text,path,0,0,mDrawerPaint);

2.4 核心逻辑

老板不会让中奖率随机的,万一你中大奖了,老板还得出钱或者花部门经费,因此,必须指定中奖物品,可以让你100%中奖,也能让你100%不中奖,要看老板心情,所以掘金的转盘你玩不玩都已经固定好你的胜率了。

计算出目标物品与初始角度的,注意时初始角度,而不是转过后的角度算起,为什么呢?原因是你按转过的角度计算复杂度就会提升,而从起始点计算,按照圆的三角函数定理,转一圈就能绕过你转动的角度 ,也就是 rotateDegree 最终会大于你当前的所停留的角度, 如果你在30度,那么要转到20度的位置,肯定不会倒转 需要 360 + 20,而360 +20大于30,所以,莫有必要从当前角度计算。

java 复制代码
//圆点起始角度 ,可以理解为index=0的起始角度,我们以index=0位参考点
float zeroStartDegree = 0 + -perDegree / 2;
float endStartDegree = 0 + perDegree / 2;
//从圆点计算,要旋转的角度
float targetDegree = (perDegree * (index - 1) + perDegree / 2);
float rotateDegree = zeroStartDegree - targetDegree;

算出来之后紧接着计算落点位置,这里需要随机一下,不然看着很假,样子还是要做的。

但是这里我们一气呵成:

【1】计算旋转速度,主要是防止逆时针旋转,其词转一下就到了,也不太真实,次数利用了三角函数定理

三角函数定理 n*360 + degree 和 degree三角函数值最终夹角是等价的

【2】旋转次数,这里我们用duration/speedTime,实际上还可以用圆周边长除以duration,也是可以的,当然也要加一定的倍数。

【3】计算出随机落点位置,不能骑线,也不能超过指定区域

java 复制代码
        //防止逆时针旋转 (三角函数定理  n*360 + degree 和 degree最终夹角是等价的 )
        while (rotateDegree < offsetDegree) {
            rotateDegree += 360;
        }
        if (speedTime == 0) {
            speedTime = 100L;
        }
        long count = duration / speedTime - 1;  //计算额外旋转圈数
        while (count >= 0) {
            rotateDegree += 360;  //三角函数定理  n*360 + degree 和 degree最终夹角是等价的
            count--; //算出转多少圈
        }

        float targetStartDegree = rotateDegree - perDegree / 2;
        float targetEndDegree = rotateDegree + perDegree / 2;

        float currentOffsetDegree = offsetDegree;
        // float targetOffsetDegree  = (targetStartDegree +  targetEndDegree)/2 ;
        //让指针指向有一定的随机性
        float targetOffsetDegree = (float) (targetStartDegree + (targetEndDegree - targetStartDegree) * Math.random());

2.5 全部代码

java 复制代码
public class LuckWheelView extends View {
    Path path  = new Path();
    private final DisplayMetrics mDM;
    private TextPaint mArcPaint;
    private TextPaint mDrawerPaint;
    private int maxRadius;
    private float perDegree;
    private long duration = 5000L;
    private List<Item> items = new ArrayList<>();
    private RectF rectF = new RectF();
    private float offsetDegree = 0;
    private TimeInterpolator timeInterpolator = new AccelerateDecelerateInterpolator();
    private long speedTime = 1000L; //旋转一圈需要多少时间
    private ValueAnimator animator = null;

    public LuckWheelView(Context context) {
        this(context, null);
    }

    public LuckWheelView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public LuckWheelView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDM = getResources().getDisplayMetrics();
        initPaint();

    }

    public void setRotateIndex(int index) {
        if (items == null || items.size() <= index) {
            return;
        }

        //圆点起始角度 ,可以理解为index=0的起始角度,我们以index=0位参考点
        float zeroStartDegree = 0 + -perDegree / 2;
        float endStartDegree = 0 + perDegree / 2;

        //从圆点计算,要旋转的角度
        float targetDegree = (perDegree * (index - 1) + perDegree / 2);
        float rotateDegree = zeroStartDegree - targetDegree;

        //防止逆时针旋转 (三角函数定理  n*360 + degree 和 degree最终夹角是等价的 )
        while (rotateDegree < offsetDegree) {
            rotateDegree += 360;
        }
        if (speedTime == 0) {
            speedTime = 100L;
        }
        long count = duration / speedTime - 1;  //计算额外旋转圈数
        while (count >= 0) {
            rotateDegree += 360;  //三角函数定理  n*360 + degree 和 degree最终夹角是等价的
            count--;
        }

        float targetStartDegree = rotateDegree - perDegree / 2;
        float targetEndDegree = rotateDegree + perDegree / 2;

        float currentOffsetDegree = offsetDegree;
        // float targetOffsetDegree  = (targetStartDegree +  targetEndDegree)/2 ;
        //让指针指向有一定的随机性
        float targetOffsetDegree = (float) (targetStartDegree + (targetEndDegree - targetStartDegree) * Math.random());

        if (animator != null) {
            animator.cancel();
        }
//起点肯定要从当前角度算起,不然会闪一下回到原点
        animator = ValueAnimator
                .ofFloat(currentOffsetDegree, targetOffsetDegree)
                .setDuration(duration);
        animator.setInterpolator(timeInterpolator);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                offsetDegree = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                offsetDegree = offsetDegree % 360;
            }
        });
        animator.start();
    }

    private void initPaint() {
        // 实例化画笔并打开抗锯齿
        mArcPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mArcPaint.setAntiAlias(true);
        mArcPaint.setStyle(Paint.Style.STROKE);
        mArcPaint.setStrokeCap(Paint.Cap.ROUND);

        mDrawerPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mDrawerPaint.setAntiAlias(true);
        mDrawerPaint.setStyle(Paint.Style.FILL);
        mDrawerPaint.setStrokeCap(Paint.Cap.ROUND);
        mDrawerPaint.setStrokeWidth(5);
        mDrawerPaint.setTextSize(spTopx(14));

    }

    private float spTopx(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, getResources().getDisplayMetrics());
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDM.widthPixels / 2;
        }

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();
        if (width == 0 || height == 0 || items == null || items.size() <= 0) {
            perDegree = 0;
            return;
        }

        maxRadius = Math.min(width / 2, height / 2);
        rectF.left = -maxRadius;
        rectF.top = -maxRadius;
        rectF.right = maxRadius;
        rectF.bottom = maxRadius;

        int size = items.size();

        int saveCount = canvas.save();
        canvas.translate(width * 1F / 2, height * 1F / 2); //平移坐标轴到view中心点
        canvas.rotate(-90); //逆时针旋转坐标轴 90度
        perDegree = 360 * 1F / size;

        // rangeDegree = start ->end
        // rangeDegree.start = perDegree/2 + (i-1) * perDegree;
        // rangeDegree.end = perDegree/2 + (i) * perDegree;


        for (int i = 0; i < size; i++) {

            //由于我们让第一个区域的中心点对准x轴了,所以(i-1)意味着从y轴负方向顺时针转动
            float startDegree = perDegree * (i - 1) + perDegree / 2 + offsetDegree;
            float endDegree = i * perDegree + perDegree / 2 + offsetDegree;

            Item item = items.get(i);
            mDrawerPaint.setColor(item.color);
//            double startDegreeRandians = Math.toRadians(startDegree); //x1
//            float x = (float) (maxRadius * Math.cos(startDegreeRandians));
//            float y = (float) (maxRadius * Math.sin(startDegreeRandians));
//            canvas.drawLine(0,0,x,y,mDrawerPaint);

            float centerRadius = (float) Math.toRadians((startDegree + endDegree)/2F);
            float measuredTextWidth = mDrawerPaint.measureText(item.text);
            float measuredTextHeight = getTextHeight(mDrawerPaint,item.text);

            float innerRadius = maxRadius - 2* measuredTextHeight;

            float cx = (float) ((innerRadius - measuredTextHeight)   * Math.cos(centerRadius));
            float cy = (float) ((innerRadius - measuredTextHeight)  * Math.sin(centerRadius));

            double degreeOffset = Math.asin((measuredTextWidth/2F)/innerRadius);
            float startX= (float) (innerRadius * Math.cos(centerRadius - degreeOffset));
            float startY = (float) (innerRadius * Math.sin(centerRadius - degreeOffset));

            float endX= (float) ((innerRadius) * Math.cos(centerRadius + degreeOffset));
            float endY = (float) ((innerRadius) * Math.sin(centerRadius + degreeOffset));

            path.reset();
            path.moveTo(startX,startY);
            path.lineTo(endX,endY);
            //这里使用Path的原因是文本角度无法设置
            canvas.drawArc(rectF, startDegree, endDegree - startDegree, true, mDrawerPaint);
            mDrawerPaint.setColor(Color.WHITE);
            canvas.drawCircle(cx,cy,5,mDrawerPaint);
            canvas.drawTextOnPath(item.text,path,0,0,mDrawerPaint);
        }

        canvas.drawLine(0, 0, maxRadius / 2F, 0, mDrawerPaint);

        canvas.restoreToCount(saveCount);

    }


    Rect textBounds = new Rect();

    //真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
    private  int getTextHeight(Paint paint,String text) {
        paint.getTextBounds(text,0,text.length(),textBounds);
        return textBounds.height();
    }


    public void setItems(List<Item> items) {
        this.items.clear();
        this.items.addAll(items);
        invalidate();
    }

    public static class Item {
        Object tag;
        int color = Color.TRANSPARENT;
        String text;

        public Item(int color, String text) {
            this.color = color;
            this.text = text;
        }
    }

}

2.6 使用方法

java 复制代码
List<LuckWheelView.Item> items = new ArrayList<>();
        items.add(new LuckWheelView.Item(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()), "金元宝"));
        items.add(new LuckWheelView.Item(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()), "皮卡丘"));
        items.add(new LuckWheelView.Item(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()), "1元红包"));
        items.add(new LuckWheelView.Item(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()), "全球旅行"));
        items.add(new LuckWheelView.Item(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()), "K歌会员卡"));
        items.add(new LuckWheelView.Item(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()), "双肩包"));
        loopView.setItems(items);
        loopView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                int index = (int) (Math.random() * items.size());
                Log.d("LuckWheelView", "setRotateIndex->" + items.get(index).text + ", index=" + index);
                loopView.setRotateIndex(index);
            }

        });

三、总结

本篇简单而快捷的实现了幸运转盘,难点主要是角度的转换,一定要分析出初始角度和目标位置的夹角这一个定性标准,其词作一些优化,就能实现幸运转盘效果。

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui