SurfaceView实战打造农药钻石夺宝

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/sdfsdfdfa/article/details/79862917

1.概述

    SurfaceView是Android系统中的View的一种。然而,它又区别与普通的View。一般的View只能在UI线程中绘制,而SurfaceView却能在非UI线程中绘制,这样的结果是即使SurfaceView频繁的刷新重绘也不会阻塞主线程导致卡顿甚至ANR。
    SurfaceView在日常的开发使用中也很常见,例如VideoView和Android的游戏。这些场景均需要高频率的重绘以达到流畅的用户体验。
    本文重点不在于解析SurfaceView的原理,而是SurfaceView的实战。通过打造王者荣耀坑钱夺宝抽奖来讲解SurfaceView绘制的技巧。其中涉及一些简单的算法、动画还有几何布局。希望对读者有所帮助。控件源码地址在文章最后。

2.SurfaceView的模板化使用

    正所谓无规矩不成方圆,有了规矩就不用自己抓破头皮想规矩了。哈哈。SurfaceView也一样。对于一般SurfaceView的绘制也有一套规矩。

public class LuckyBoard extends SurfaceView implements SurfaceHolder.Callback, Runnable {

    private SurfaceHolder mHolder;
    private Canvas mCanvas;

    private boolean isDrawing;
    private Thread drawThread;
    public LuckyBoard(Context context) {
        this(context, null);
    }

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

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

     private void init() {
        mHolder = getHolder();
        mHolder.addCallback(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        isDrawing = true;
        drawThread = new Thread(this);
        drawThread.start();
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        isDrawing = false;
        mHolder.removeCallback(this);
        mHolder = null;
        mCanvas = null;
    }

    @Override
    public void run() {
        while (isDrawing) {
          try{
            mCanvas = mHolder.lockCanvas();
            draw();
          }catch(Exception e){
            e.printStackTrace();
          }finally{
            mHolder.unlockCanvasAndPost(mCanvas);
          }
        }
    }

    void draw(){
    //绘制的内容
    }
}

    代码很简单,在surfaceCreated方法里面创建绘制线程,isDrawing设置为true,此时当isDrawing为true时将不断刷新绘制。在surfaceDestroyed方法中将isDrawing设为false,让绘制线程终止,SurfaceView将不再刷新重绘。

3.效果图

    一般讲到这里,很多读者就会很不耐烦。心想:说那么多干嘛,快上效果图看看让我有没有读下去的动力。好的马上奉上。
12规模抽奖 8规模抽奖
    程序员容易得颈椎病。我不会告诉你其实我是想治好你们的颈椎病才这样的哈哈。知道怎么旋转图片的同学可以评论告诉我一下。gif是10fps,实际效果更流畅。
    上图分别是12奖品规模和8奖品规模的效果图。奖品规模是根据用户输入的奖品参数自适应的。后面将有讲解。

4.使用方法

①添加控件到layout布局

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context="com.luozm.luckyboarddemo.MainActivity">

   <com.luozm.luckyboard.LuckyBoard
       android:id="@+id/luckyboard"
       android:layout_gravity="center"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"/>

</LinearLayout>

②Java代码初始化

public class MainActivity extends AppCompatActivity {

    LuckyBoard luckyBoard;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        luckyBoard = (LuckyBoard) findViewById(R.id.luckyboard);
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
        Bitmap ake = BitmapFactory.decodeResource(getResources(), R.mipmap.ake);
        Bitmap bailishouyue = BitmapFactory.decodeResource(getResources(), R.mipmap.bailishouyue);
        Bitmap caocao = BitmapFactory.decodeResource(getResources(), R.mipmap.caocao);
        Bitmap huangzhong = BitmapFactory.decodeResource(getResources(), R.mipmap.huangzhong);
        Bitmap liubei = BitmapFactory.decodeResource(getResources(), R.mipmap.liubei);
        Bitmap xiahoudun = BitmapFactory.decodeResource(getResources(), R.mipmap.xiahoudun);
        Bitmap zhangfei = BitmapFactory.decodeResource(getResources(), R.mipmap.zhangfei);
        Bitmap zhaoyun = BitmapFactory.decodeResource(getResources(), R.mipmap.zhaoyun);
        List<LuckyAward> awards = new ArrayList<>();
        awards.add(new LuckyAward("阿珂", ake, 0.1f));
        awards.add(new LuckyAward("百里守约", bailishouyue, 0.1f));
        awards.add(new LuckyAward("曹操", caocao, 0.1f));
        awards.add(new LuckyAward("黄忠", huangzhong, 0.1f));
        awards.add(new LuckyAward("刘备", liubei, 0.1f));
        awards.add(new LuckyAward("夏侯惇", xiahoudun, 0.1f));
        awards.add(new LuckyAward("张飞", zhangfei, 0.1f));
        awards.add(new LuckyAward("赵云", zhaoyun, 0.1f));
        luckyBoard.setAvAward(new LuckyAward("谢谢惠顾", bitmap, 0f));
        luckyBoard.setAwards(awards);
        luckyBoard.setResultCallback(new LuckyBoard.ResultCallback() {
            @Override
            public void result(LuckyAward award) {
                Toast.makeText(MainActivity.this, award.getName(), Toast.LENGTH_SHORT).show();
            }
        });
    }


}

    这其中关注的方法有LuckyBoard的setAvAward、setAwrads、setResultCallback方法,它们分别的功能是设置安慰奖(不要想歪)、设置实际奖品和设置抽奖结果回调。
    以上就是使用LuckyBoard的基本操作。接下来将讲解怎么打造这个LuckyBoard。

5.打造LuckyBoard

①分析控件的属性

    抽象化是自定义View的第一步,分析提取控件中哪些是必须的。抽象化一般从效果图中着手。当然刚打造这控件时是没效果图的,那么只能从脑子里想象如果控件做好之后是什么样子的,从而分析提取控件的属性。如今有效果图了,那么就对照效果图分析提取控件的属性吧。
    从效果图可以看到,LuckyBoard是由一个大的矩形有序分布着小的矩形,中间有一个圆形的按钮,大致是这样的几何分布:
这里写图片描述
    图以8奖品规模为例。从上图可以抽象出一个blockSize的属性,这个blockSize可是很重要的。它指的是小矩形的宽度,小矩形的宽高比例是4:3。这样小矩形的尺寸就有了。那么大矩形也是根据小矩形的数量规模决定尺寸的。那么我们就可以重写onMeasure来定义SurfaceView的尺寸了。
     LuckyBoard抽奖轮盘中奖品是必须的,这样就抽象出一个awards的属性。它代表奖品池中的奖品列表,是一个实体类的集合。这个实体类叫LuckyAward,封装着奖品的名称、图片和获奖概率。

public class LuckyAward implements Cloneable{
    private String name;
    private Bitmap bitmap;
    private float rate;

    public LuckyAward(String name, Bitmap bitmap, float rate) {
        this.name = name;
        this.bitmap = bitmap;
        this.rate = rate;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Bitmap getBitmap() {
        return bitmap;
    }

    public void setBitmap(Bitmap bitmap) {
        this.bitmap = bitmap;
    }

    public float getRate() {
        return rate;
    }

    public void setRate(float rate) {
        this.rate = rate;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

    最后,我们从效果图可以看到,初始时各个小矩形并没有蒙上一层阴影。点击中间的按钮后,出现了阴影并且在小矩形之间循环走动。再点下按钮,小矩形开始减速并最终停留在一个奖品中。在这里我们可以分析出控件有个mState的属性,它指的是控件当前状态,以此来决定控件行为。控件的状态有3种,分别是空闲状态(STATE_IDEL)、循环滚动状态(STATE_RUNNING)和结果产生状态(STATE_RESULT)。有了这三种状态,我们就可以在draw()这个方法中决定画什么。当然,阴影在哪个位置,我们提取一个currentPosition的属性。

②onMeasure

    重写onMeasure方法是自定义View的基本操作,LuckyBoard控件的大小是根据blockSize和奖品规模决定的。代码如下:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //奖品规模为0时,设置宽高为0
        if (awards == null || awards.size() == 0) {
            setMeasuredDimension(0, 0);
        } else {
            int awardsSize = awards.size();
            int width;
            int height;
            if (awardsSize <= 8) {//奖品规模为8
                width = 2 * horizontalPadding + blockSize * 3;
                height = (int) (2 * verticalPadding + blockSize * 3 * 0.75);
            } else if (awardsSize <= 12) {//奖品规模为12
                width = 2 * horizontalPadding + blockSize * 4;
                height = (int) (2 * verticalPadding + blockSize * 4 * 0.75);
            } else if (awardsSize <= 20) {//奖品规模为20
                width = 2 * horizontalPadding + blockSize * 5;
                height = (int) (2 * verticalPadding + blockSize * 5 * 0.75);
            } else {
                throw new IllegalStateException("Awards Size must not be above of 20");
            }
            setMeasuredDimension(width, height);
        }
    }

    这样控件的大小就设置了。

③setAvAward方法和setAwards方法

    在使用方法那部分LuckyBoard控件的设置调用了这两个方法。依次设置安慰奖和正式奖。它们是这样的:

public void setAvAward(LuckyAward avAward) {
        this.avAward = avAward;
    }

    /**
     * Set awards.
     * @param awards
     */
    public void setAwards(List<LuckyAward> awards) {
        if (awards != null && awards.size() > 0) {
            checkAwardsValid(awards);
            this.awards = awards;
            try {
                fillAwards();
                generateBlockArea();
            } catch (CloneNotSupportedException e) {
                e.printStackTrace();
            }
        }
    }

    其中setAvAward方法很简单,仅仅是为成员赋值。而setAwards方法就复杂些,它调用各种内部方法。而这些方法则是为后面正式绘制打好基础的。
    在setAwards方法里面,首先对参数有效性进行判断。然后调用checkAwardsValid方法。checkAwardsValid方法如下:

//检查奖品是否合理
    private void checkAwardsValid(List<LuckyAward> awards) {
        float totalRate = 0;
        for (LuckyAward award : awards) {
            totalRate += award.getRate();
        }
        if (totalRate > 1) {
            throw new IllegalStateException("Awards' total rate must below 1");
        }
    }

    可以看出,checkAwardsValid的作用是对awards里奖品总和进行判断,如果总概率大于1,则抛出异常,程序终止。如果小于1就继续setAwards的流程。
    checkAwardsValid方法正常运行过后,awards形参将赋值给成员属性awards。至此成员属性awards当中仅包含正式奖品,至于是否填充安慰奖,将调用fillAwards方法:

//补充安慰奖
    private void fillAwards() throws CloneNotSupportedException {
        int awardSize = awards.size();
        //1.若正式奖品刚好等于8/12/20时且总概率小于1的话,升级到下一个抽奖规模
        float totalRate = 0;
        for (LuckyAward award : awards) {
            totalRate += award.getRate();
        }
        if (totalRate < 1) {
            if (awardSize == 8) {
                awardSize = 12;
            } else if (awardSize == 12) {
                awardSize = 20;
            }
        }
        //2.计算安慰奖概率并填充到奖品池(安慰奖的位置是随机插入到正是奖品列表中)
        Random random = new Random();
        float rate = computeAvAwardRate(awardSize);
        if (awardSize <= 8) {
            while (awards.size() != 8) {  //填充安慰奖至规模8
                int insertIndex = random.nextInt(awards.size());
                LuckyAward award = (LuckyAward) avAward.clone();
                award.setRate(rate);
                awards.add(insertIndex, award);
            }
            totalSize = 8;
        } else if (awardSize <= 12) { //填充安慰奖至规模12
            while (awards.size() != 12) {
                int insertIndex = random.nextInt(awards.size());
                LuckyAward award = (LuckyAward) avAward.clone();
                award.setRate(rate);
                awards.add(insertIndex, award);
            }
            totalSize = 12;
        } else if (awardSize <= 20) { //填充安慰奖至规模20
            while (awards.size() != 12) {
                int insertIndex = random.nextInt(awards.size());
                LuckyAward award = (LuckyAward) avAward.clone();
                award.setRate(rate);
                awards.add(insertIndex, award);
            }
            totalSize = 20;
        }
    }

    fillAwards方法很清晰,分两步是否升级下一个规模和填充安慰奖。是否升级到下一个规模主要考虑到当正式奖品规模刚好达到8/12而总概率小于1,此时自动升级到下一个奖品规模并填充安慰奖。安慰奖的概率由computeAvAwardRate方法计算得出。其代码如下:

//根据奖品调整安慰奖概率
//计算安慰奖概率公式:安慰奖概率=(1-正式奖总概率)/(奖品规模-正式奖品规模)
    private float computeAvAwardRate(int awardSize) {
        float totalRate = 0;
        float resultRate = 0;
        for (LuckyAward award : awards) {
            totalRate += award.getRate();
        }
        if (awardSize <= 8) {
            resultRate = (1 - totalRate) / (8 - awards.size());
        } else if (awardSize <= 12) {
            resultRate = (1 - totalRate) / (12 - awards.size());
        } else if (awardSize <= 20) {
            resultRate = (1 - totalRate) / (20 - awards.size());
        }
        return resultRate;
    }

    fillAwards方法执行完之后,成员awards已包含正式奖和安慰奖。
    fillAwards方法执行后,调用generateBlockArea方法。这方法主要是产生每个奖品的区域RectF,以方便后续绘制。

//矩阵外圈顺时针遍历算法
    private void generateBlockArea() {
        blocksArea = new ArrayList<>();
        int endX = 0;
        int endY = 0;
        switch (awards.size()) {
            case 8:
                endX = 2;
                endY = 2;
                break;
            case 12:
                endX = 3;
                endY = 3;
                break;
            case 20:
                endX = 4;
                endY = 4;
                break;
        }
        for (int i = 0; i <= endX; i++) {//从左到右
            RectF rect = new RectF(i * blockSize, 0, (i + 1) * blockSize, blockSize * 0.75f);
            blocksArea.add(rect);
        }
        if (endY > 0) {//从上到下
            for (int i = 1; i <= endY; i++) {
                RectF rect = new RectF(endX * blockSize, i * blockSize * 0.75f, (endX + 1) * blockSize, (i + 1) * blockSize * 0.75f);
                blocksArea.add(rect);
            }
        }
        if (endX > 0 && endY > 0)   //从右至左打印一行
        {
            for (int i = endX - 1; i >= 0; i--) {
                RectF rect = new RectF(i * blockSize, endY * blockSize * 0.75f, (i + 1) * blockSize, (endY + 1) * blockSize * 0.75f);
                blocksArea.add(rect);
            }
        }
        if (endX > 0 && endY > 1)   //从下至上打印一列
        {
            for (int i = endY - 1; i > 0; i--) {
                RectF rect = new RectF(0, i * blockSize * 0.75f, blockSize, (i + 1) * blockSize * 0.75f);
                blocksArea.add(rect);
            }
        }
    }

    方法主要是顺时针生成奖品区域RectF,这样的目的是让轮转顺时针轮转而不是一行一行从左到右轮转。这里参考了顺时针打印矩阵的算法
    至此,绘制的基础已经全部打好,接下来就是绘制流程。

④绘制LuckyBoard

    SurfaceView的绘制是在非UI线程中绘制的,也就是在新线程的run中。先来看看run方法来了解下绘制流程:

@Override
    public void run() {
        while (isDrawing) {
            //为了在空闲时候不重绘采用下面代码
            //这里要说明一下死循环会导致用户线程CPU高使用率,解决方法是在死循环加sleep(1)。CPU占用从
            //25%降到0%
            while (mState == STATE_IDEL && hasDrawn) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mCanvas = mHolder.lockCanvas();
            drawBackground();
            drawPanel();
            drawGoButton();
            drawAwards();
            if (currentPosition != -1) {
                drawRunning();
            }
            hasDrawn = true;
            mHolder.unlockCanvasAndPost(mCanvas);
        }
    }

    LuckyBoard的绘制是根据mState状态来决定绘制行为的。当为空闲状态,只绘制一次,不再刷新。当为非空闲状态,将会不断刷新绘制以达到流畅的体验。在空闲状态,会执行下面一段代码:

 while (mState == STATE_IDEL && hasDrawn) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

    这段代码目的是限制空闲状态的刷新绘制,以降低CPU的使用。而这段代码中又调用了Thread.sleep(10)这句代码。这里有必要说明下调用Thread.sleep(10)这句代码的。
    在注释掉 Thread.sleep(10)之后,会发现在空闲状态下有如下现象:
这里写图片描述
    User的CPU使用率达到25%,这样就算暂停绘制,什么都不动,手机也会发烫得厉害。
    当我们重新加上Thread.sleep(10)后,会发现在空闲状态下是这样的:
这里写图片描述
    而在非空闲状态下是这样的:
这里写图片描述
    不断刷新绘制的CPU使用率也就12%。因此,我在这里提醒广大开发者不要写出空操作死循环。如果要写请在里面sleep方法。至于为什么?可以参考为什么死循环占用CPU高
    接下来正式对绘制流程进行讲解。

1、drawBackground

    drawBackground方法是绘制LuckyBoard的背景。

private void drawBackground() {
        if (mBg != null) {
            Rect src = new Rect(0, 0, mBg.getWidth(), mBg.getHeight());
            Rect dst = new Rect(0, 0, mCanvas.getWidth(), mCanvas.getHeight());
            mCanvas.drawBitmap(mBg, src, dst, mBlockBgPaint);
        }
    }

    drawBackground还是中规中矩,直接调用drawBitmap方法绘制mBg。画完之后是这样的:
这里写图片描述

2、drawPanel

    drawPanel用以绘制每个奖品区域并绘制背景。

private void drawPanel() {
        mCanvas.save();
        mCanvas.translate(horizontalPadding, verticalPadding);
        for (RectF rect : blocksArea) {
            //着色器
            mCanvas.drawRoundRect(rect, 20, 20, mBlockBgPaint);
            mCanvas.drawRoundRect(rect, 20, 20, mBorderPaint);
        }
        mCanvas.restore();
    }

    绘制奖品区域主要根据之前generateBlockArea生成的blocksArea来绘制区域,并用各自的Paint绘制。Paint的设置如下:

        mBorderPaint = new Paint();
        mBorderPaint.setStrokeWidth(3);
        mBorderPaint.setColor(Color.WHITE);
        mBorderPaint.setStyle(Paint.Style.STROKE);

        mBlockBg = adaptBlockBgSize();
        mBlockBgPaint = new Paint();
        mBlockBgPaint.setShader(new BitmapShader(mBlockBg, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT));

    mBoarderPaint用于绘制边框,而mBlockBgPaint用于绘制背景。
    绘制完之后是这样的:
这里写图片描述

3、drawGoButton

    drawGoButton绘制中间的启动按钮。

private void drawGoButton() {
        mCanvas.save();
        if (!enable) {
            mGoButtonPaint.setColor(Color.GRAY);
        } else {
            mGoButtonPaint.setColor(Color.RED);
        }
        mGoButtonPaint.setStyle(Paint.Style.FILL);
        mCanvas.drawPath(mButtonPath, mGoButtonPaint);
        mGoButtonPaint.setColor(Color.WHITE);
        mGoButtonPaint.setStrokeWidth(5);
        mGoButtonPaint.setTextAlign(Paint.Align.CENTER);
        mGoButtonPaint.setTextSize(textSize);
        //文字垂直居中
        Paint.FontMetrics fontMetrics = mGoButtonPaint.getFontMetrics();
        float top = fontMetrics.top;
        float bottom = fontMetrics.bottom;
        float baseY = getHeight() / 2 - top / 2 - bottom / 2;
        if (mState == STATE_RUNNING) {
            mCanvas.drawText("STOP", getWidth() / 2, baseY, mGoButtonPaint);
        } else {
            mCanvas.drawText("GO", getWidth() / 2, baseY, mGoButtonPaint);
        }
        mCanvas.restore();
    }

    drawGoButton仅仅根据状态判断绘制GO还是STOP,用于后面的交互逻辑。其效果如下:
这里写图片描述

4.drawAwards

    drawAwards方法是绘制奖品信息(图片、文字)。代码如下:

 private void drawAwards() {
        mCanvas.save();
        mCanvas.translate(horizontalPadding, verticalPadding);
        int size = awards.size();
        for (int i = 0; i < size; i++) {
            //画奖品名字
            LuckyAward award = awards.get(i);
            RectF rectF = blocksArea.get(i);
            String name = award.getName();
            mAwardPaint.setColor(textColor);
            mAwardPaint.setTextSize(textSize);
            mAwardPaint.setTextAlign(Paint.Align.CENTER);
            //文字垂直居中
            Paint.FontMetrics fontMetrics = mGoButtonPaint.getFontMetrics();
            float top = fontMetrics.top;
            float bottom = fontMetrics.bottom;
            float textAreaTop = rectF.bottom - textSize * 2f;
            float textAreaCenterY = (rectF.bottom + textAreaTop) / 2;

            float baseY = textAreaCenterY - top / 2 - bottom / 2;
            float x = rectF.centerX();
            mCanvas.drawText(name, x, baseY, mAwardPaint);

            //画奖品图片
            float picTop = rectF.top + Util.dp2px(getContext(), 5);
            float picHeight = (rectF.bottom - rectF.top) - Util.dp2px(getContext(), 24);
            float picLeft = rectF.left + ((rectF.right - rectF.left) - picHeight) / 2;
            Rect src = new Rect(0, 0, award.getBitmap().getWidth(), award.getBitmap().getHeight());
            Rect dst = new Rect((int) picLeft, (int) picTop, (int) (picLeft + picHeight), (int) (picTop + picHeight));
            mCanvas.drawBitmap(award.getBitmap(), src, dst, mAwardPaint);
        }
        mCanvas.restore();
    }

    drawAwards主要难点是计算有点复杂。其主要逻辑是在各个奖品区域RectF中绘制图片和文字。居中,居中,居中,重点的话说3次。其效果如下:
这里写图片描述

5.drawRunning

    最后drawRunning高亮选中的奖品。其代码如下:

   private void drawRunning() {
        mCanvas.save();
        mCanvas.translate(horizontalPadding, verticalPadding);
        if (currentPosition == awards.size()) {
            currentPosition -= 1;
        }
        RectF rect = blocksArea.get(currentPosition);
        mCanvas.drawRoundRect(rect, 20, 20, mAwardHoverPaint);
        mCanvas.restore();
    }

    此方法是根据currentPosition的奖品区域在遮罩一层半透明的阴影。其效果如下:
这里写图片描述

⑤交互逻辑

    最后,加上交互逻辑,LuckyBoard就完成了。交互逻辑主要是通过处理点击中间按钮进行状态切换改变绘制行为并产生抽奖结果。

1、按钮点击事件处理

    按钮点击跟其他自定义View一样都是通过重写onTouchEvent方法对事件进行处理。onTouchEvent方法如下:

@Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (mButtonRegion.contains(x, y)) {
                    isButtonDown = true;
                }
                break;
            case MotionEvent.ACTION_UP:
                if (mButtonRegion.contains(x, y) && isButtonDown && enable) {
                    if (mState == STATE_IDEL) {//启动轮转
                        onGoButtonClick();
                    } else if (mState == STATE_RUNNING) {//产生结果
                        onResultButtonClick();
                    }
                    isButtonDown = false;
                }
                break;
        }
        return true;
    }

    方法很简单,根据当前状态mState判断执行启动轮转onGoButtonClick还是产生结果onResultButtonClick。

2、启动轮转

    启动轮转执行onGoButtonClick,其内部调用luckyGo方法。luckyGo方法如下:

private void luckyGo() {
        currentPosition = 0;
        mState = STATE_RUNNING;
        //由于属性动画中,当达到最终值会立刻跳到下一次循环,所以需要补1
        mRunningAnimator = ObjectAnimator.ofInt(this, "currentPosition", 0, awards.size());
        mRunningAnimator.setRepeatMode(ValueAnimator.RESTART);
        mRunningAnimator.setRepeatCount(ValueAnimator.INFINITE);
        mRunningAnimator.setDuration(600);
        mRunningAnimator.setInterpolator(new LinearInterpolator());
        mRunningAnimator.start();
    }

    方法启动一个属性动画,通过匀速改变currentPosition来切换当前选中的奖品。

3、产生抽奖结果

    在LuckyBoard轮转时再按下按钮执行onResultButtonClick。其内部调用luckyResult执行结果产生逻辑。其代码如下:

//从当前位置到最终位置的循环,卡在这里
    private void luckyResult() {
        /*
        1.取消正在执行的循环轮转动画
        2.开始补偿动画
        3.补偿动画结束后开始产生及过滚动轮盘动画
        */
        mRunningAnimator.cancel();
        mState = STATE_RESULT;
        final int result = generateResult();
        //最终值不是result是为了让插值器的周期能覆盖整个动画(多转2圈)
        mResultingAnimator = ValueAnimator.ofInt(0, awards.size() * 2 + result);
        mResultingAnimator.setInterpolator(new DecelerateInterpolator());
        int duration = (int) (1200 + (float) result / awards.size() * 600);
        mResultingAnimator.setDuration(duration);
        mResultingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                LuckyBoard.this.currentPosition = (int) animation.getAnimatedValue() % awards.size();
            }
        });
        mResultingAnimator.addListener(new SimpleAnimatorListener() {
            @Override
            public void onAnimationEnd(Animator animation) {
                mState = STATE_IDEL;
                if (mResultCallback != null) {
                    mResultCallback.result(awards.get(result));
                }
            }
        });
        //补偿动画达到由运行动画到产生结果动画的过渡
        ObjectAnimator tempAnimator = ObjectAnimator.ofInt(this, "currentPosition", this.currentPosition, awards.size());
        float tempDuration = (float) (awards.size() - currentPosition) / awards.size() * 600;
        tempAnimator.setDuration((long) tempDuration);
        tempAnimator.setInterpolator(new LinearInterpolator());
        tempAnimator.addListener(new SimpleAnimatorListener() {
            @Override
            public void onAnimationEnd(Animator animation) {
                //补偿动画结束时开始结果动画
                mResultingAnimator.start();
            }
        });
        tempAnimator.start();
    }

    步骤很清晰,取消循环轮转动画、开始补偿动画、补偿动画结束后开始结果产生动画。这里,用补偿动画的原因是为了以同样的轮转速度走完一圈到0的位置,从而让结果产生动画可以从0循环。此外,可以注意到,结果产生是通过generateResult方法产生的。其代码如下:

private int generateResult() {
        //产生抽奖结果
        Random random = new Random();
        float r = random.nextFloat();
        float total = 0;
        float lastTotal = 0;
        int size = awards.size();
        for (int i = 0; i < size; i++) {
            total += awards.get(i).getRate();
            if (r >= lastTotal && r <= total) {
                return i;
            }
            lastTotal = total;
        }
        return -1;
    }

    结果产生的算法是将奖品概率通过累加的方式分布在0到1的横轴上,产生0到1的随机数。当奖品概率在其横轴的概率范围内,即选中。
    到这里大家应该感受得到,结果其实是在产生结果动画前就已经产生,跟你用什么方式按是完全没有关系的,不然运营商早就破产啦。

6.总结

    通过LuckyBoard的打造相信大家多多少少已经对自定义SurfaceView有所感悟。其实自定义SurfaceView跟自定义View区别真的不大,区别在于需不需要invalidate通知重绘。通过为SurfaceView设置一个状态标识,通过改变状态标识来改变绘制行为是自定义SurfaceView很好的方法。最后附上LuckyBoard的github源码。非常感谢大家的阅读。
LuckyBoard
    LuckyBoard很没有到达通用的阶段,并没上传到jcenter。可能需要用到的朋友可以导入源码根据需求修改使用,欢迎有兴趣的朋友给意见以让LuckyBoard能通用化。

阅读更多
换一批

没有更多推荐了,返回首页