Hướng dẫn tạo vòng quay Roulette trong Android

Mở đầu

Hiện tại các ứng dụng về quay trúng thưởng khá phổ biến hiện nay. Song có thể tạo và custom view 1 cái roulette cũng gây không ít khó khăn cho các lập trình viên Do vậy trong bài viết này mình sẽ hướng dẫn mọi người tạo 1 Roulette cho Android Sản phẩm sẽ có như hình dưới đây:

Tạo ứng dụng

Ý tưởng để tạo ra dựa vào việc custome 1 app có sẵn trên github: https://github.com/LukeDeighton/WheelView Mọi người có thể download source code về và nghiên cứu chỉnh sửa theo ý. Ở đây mình sẽ hướng dẫn các bước để custome ra sản phẩm giống với phần mục Mở đầu đã nêu. Cấu trúc project của mình như sau:

ItemDrawable

Tư tưởng của bài toán là mình sẽ tạo ra các ItemView, 1 adapter, sau đó thay vì set vào ListView hay RecyclerView thì mình sẽ set vào 1 customeView là WheelView extends từ View Đây là phần quan trọng, vì nó sẽ quyết định roulette của bạn hình dạng ra sao, vì vậy bạn cần biết chút ít về Canvas và Paint

package com.manhnd.demo.wheelview;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.drawable.Drawable;
import android.support.annotation.NonNull;

import com.manhnd.demo.AppUtil;

/**
 * Created by Nguyen Duc Manh on 01/8/17.
 */


public class ItemDrawable extends Drawable {
    private static final String TAG = ItemDrawable.class.getSimpleName();
    private Context mContext;
    private String mTextDescription;
    private Paint mDescriptionPaint;

    private Paint mBackgroundPaint;
    AppUtil appUtil;

    public ItemDrawable(Context context, String text, int color) {

        this.mContext = context;
        this.mTextDescription = text;

        appUtil = AppUtil.getInstance();
        appUtil.initial(context);

        mDescriptionPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mDescriptionPaint.setColor(Color.WHITE);
        mDescriptionPaint.setTextSize(appUtil.spToPixel(17f));
        mDescriptionPaint.setFakeBoldText(true);
        mDescriptionPaint.setStyle(Paint.Style.FILL);

        mBackgroundPaint = new Paint();
        mBackgroundPaint.setColor(color);
        mBackgroundPaint.setStyle(Paint.Style.FILL);
        mBackgroundPaint.setStrokeCap(Paint.Cap.ROUND);

    }

    @Override
    public void draw(@NonNull Canvas canvas) {
        Rect bounds = getBounds();
        canvas.drawArc(new RectF(bounds.left, bounds.top, bounds.right, bounds.bottom),
                244.5f, 60f, true, mBackgroundPaint);
        canvas.rotate(-90);
        canvas.drawText(mTextDescription, -(bounds.centerY() - 100),
                bounds.centerX() + mDescriptionPaint.descent() * 2, mDescriptionPaint);
    }

    @Override
    public void setAlpha(int alpha) {
        mDescriptionPaint.setAlpha(alpha);
    }

    @Override
    public void setColorFilter(ColorFilter cf) {
        mDescriptionPaint.setColorFilter(cf);
    }

    @Override
    public int getOpacity() {
        return PixelFormat.TRANSLUCENT;
    }

}

Ở đây mình tạo 1 itemView có tên là ItemDrawable extends từ Drawable, và được vẽ bằng canvas Ở đây mọi người chú ý. Mình hiện đang chỉ làm 6 item, do vậy góc quét của mỗi item sẽ là 60f. Do vậy tùy thuộc vào số lượng item mà bạn cần chọn giá trị cho phù hợp

canvas.drawArc(new RectF(bounds.left, bounds.top, bounds.right, bounds.bottom),
                244.5f, 60f, true, mBackgroundPaint);

WheelArrayAdapter

WheelArrayAdapter và WheelAdapter được giữ nguyên theo sample tải về từ trước (https://github.com/LukeDeighton/WheelView)

public interface WheelAdapter {

    /**
     * @param position the adapter position, between 0 and {@link #getCount()}.
     * @return the drawable to be drawn on the wheel at this adapter position.
     */
    Drawable getDrawable(int position);

    /**
     * @return the number of items in the adapter.
     */
    int getCount();
}
public abstract class WheelArrayAdapter<T> implements WheelAdapter {

    private List<T> mItems;

    public WheelArrayAdapter(List<T> items) {
        mItems = items;
    }

    public T getItem(int position) {
        return mItems.get(position);
    }

    @Override
    public int getCount() {
        return mItems.size();
    }
   
}

WheelView

Do code của class này khá là dài nên mình chỉ hướng dẫn và giải thích 1 số cần custom Đầu tiên là khi bạn tải sample từ github trước đó về, khi chạm vào roulette, nó sẽ tự quay ko dừng cho đến khi bạn chạm vào giữ nó lại Do vậy, nếu bạn ko muốn nó tự quay hoặc muốn tự custom lại thì bạn có thể sửa ở method

  @Override
    public boolean onTouchEvent(@NonNull MotionEvent event) {
    ...
    }

Tiếp theo là phần update lại vận tốc quay của roulette

public void update(float deltaTime) {
        float vel = mAngularVelocity;
        float velSqr = vel * vel;
        if (vel > 0f) {
            //TODO the damping is not based on time
            vel -= velSqr * VELOCITY_FRICTION_COEFFICIENT
                    + CONSTANT_FRICTION_COEFFICIENT;
            if (vel < 0f) vel = 0f;
        } else if (vel < 0f) {
            vel -= velSqr * -VELOCITY_FRICTION_COEFFICIENT
                    - CONSTANT_FRICTION_COEFFICIENT;
            if (vel > 0f) vel = 0f;
        }

        if (vel != 0f) {
            mAngularVelocity = vel;
            addAngle(mSpeed * mAngularVelocity * deltaTime);
        } else {
            int selectedPosition = getSelectedPosition();
            if (selectedPosition == myPosition
                    || myPosition < 0 || myPosition >= mAdapterItemCount) {
                mRequiresUpdate = false;
                //Finish rotate roulette
                mOnItemClickListener.onWheelItemClick(this, selectedPosition, true);
            } else {
                addAngle(SPEED_INERTIA * mAngularVelocity * deltaTime);
            }
        }
    }

Và tiếp theo phần khá quan trọng là vẽ các item lên WheelView

private void drawWheelItems(Canvas canvas) {
        double angleInRadians = Math.toRadians(mAngle);
        double cosAngle = Math.cos(angleInRadians);
        double sinAngle = Math.sin(angleInRadians);
        float centerX = mWheelBounds.mCenterX;
        float centerY = mWheelBounds.mCenterY;

        int wheelItemOffset = mItemCount / 2;
        int offset = mSelectedPosition - wheelItemOffset;
        int length = mItemCount + offset;
        for (int i = offset; i < length; i++) {
            int adapterPosition = rawPositionToAdapterPosition(i);
            int wheelItemPosition = rawPositionToWheelPosition(i, adapterPosition);

            Circle itemBounds = mWheelItemBounds.get(wheelItemPosition);
            float radius = itemBounds.mRadius;

            //translate before rotating so that origin is at the wheel's center
            float x = itemBounds.mCenterX - centerX;
            float y = itemBounds.mCenterY - centerY;

            //rotate
            float x1 = (float) (x * cosAngle - y * sinAngle);
            float y1 = (float) (x * sinAngle + y * cosAngle);

            //translate back after rotation
            x1 += centerX;
            y1 += centerY;

            ItemState itemState = mItemStates.get(wheelItemPosition);
            updateItemState(itemState, adapterPosition, x1, y1, radius);
            mItemTransformer.transform(itemState, sTempRect);

            //Empty positions can only occur from having "non repeatable" items
            CacheItem cacheItem = getCacheItem(adapterPosition);

            //don't draw if outside of the view bounds
            if (Rect.intersects(sTempRect, mViewBounds)) {
                canvas.save();
                canvas.rotate(-itemState.getAngleFromSelection(), mViewBounds.centerX(),
                        mViewBounds.bottom / 2);
                if (cacheItem.mDirty && !cacheItem.mIsEmpty) {
                    cacheItem.mDrawable = mAdapter.getDrawable(adapterPosition);
                    cacheItem.mDirty = false;
                }

                if (!cacheItem.mIsVisible) {
                    cacheItem.mIsVisible = true;
                    if (mOnItemVisibilityChangeListener != null) {
                        mOnItemVisibilityChangeListener.onItemVisibilityChange(mAdapter,
                                adapterPosition, true);
                    }
                }

                Drawable drawable = cacheItem.mDrawable;
                if (drawable != null) {

                    int left = mViewBounds.centerX()- mWheelRadius;
                    int right = mViewBounds.centerX() + mWheelRadius;
                    int top = 0;
                    int bottom = (right - left);
                    Rect bound = new Rect(left, top, right, bottom);
                    drawable.setBounds(bound);
                    drawable.draw(canvas);
                }
                canvas.restore();
            } else {
                if (cacheItem != null && cacheItem.mIsVisible) {
                    cacheItem.mIsVisible = false;
                    if (mOnItemVisibilityChangeListener != null) {
                        mOnItemVisibilityChangeListener.onItemVisibilityChange(mAdapter,
                                adapterPosition, false);
                    }
                }
            }
        }
    }

Tạo layout xml

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.manhnd.demo.MainActivity">

    <ImageView
        android:id="@+id/imageView2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_marginTop="10dp"
        android:rotation="180"
        app:srcCompat="?android:attr/textSelectHandle" />

    <com.manhnd.demo.wheelview.WheelView
        android:id="@+id/wheel_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@+id/imageView2"
        app:emptyItemColor="@color/green_900"
        app:repeatItems="true"
        app:rotatableWheelDrawable="false"
        app:selectionColor="@color/teal_900"
        app:wheelColor="@color/grey_400"
        app:wheelDrawable="@drawable/bg_roulette"
        app:wheelItemCount="6"
        app:wheelItemRadius="@dimen/wheel_item_radius"
        app:wheelRadius="@dimen/wheel_radius" />

    <Button
        android:id="@+id/btnStart"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@+id/wheel_view"
        android:layout_margin="@dimen/margin_20dp"
        android:text="START" />
</RelativeLayout>

bg_roulette.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item>
        <shape android:shape="oval">
            <stroke
                android:width="@dimen/margin_2dp"
                android:color="#BABABA" />
            <solid android:color="@android:color/transparent" />
            <size
                android:width="100dp"
                android:height="100dp" />
        </shape>
    </item>
</layer-list>

dimens.xml

    ...
   <dimen name="wheel_item_radius">28dp</dimen>
    <dimen name="wheel_radius">150dp</dimen>
    <dimen name="wheel_padding">8dp</dimen>
    <dimen name="wheel_padding_inside">6dp</dimen>
    <dimen name="wheel_offset">8dp</dimen>
    ...

MainActivity

package com.manhnd.demo;

import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;

import com.manhnd.demo.wheelview.ItemDrawable;
import com.manhnd.demo.wheelview.WheelView;
import com.manhnd.demo.wheelview.adapter.WheelArrayAdapter;

import java.util.ArrayList;
import java.util.List;

/**
 * Created by Nguyen Duc Manh on 01/8/17.
 */


public class MainActivity extends AppCompatActivity {

    private WheelView wheelView;
    private Button btnStart;
    private Runnable mRunnable;
    private Handler mHandler = new Handler();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        wheelView = (WheelView) findViewById(R.id.wheel_view);
        btnStart = (Button) findViewById(R.id.btnStart);
        setupData();

    }

    private void setupData() {
        List<String> listStrings = new ArrayList<>();
        listStrings.add("20.000 VND");
        listStrings.add("1 chỉ vàng");
        listStrings.add("1 xe máy SH");
        listStrings.add("50.000 VND");
        listStrings.add("1 Iphone 7");
        listStrings.add("100.000 VND");

        wheelView.setAdapter(new RouletteAdapter(listStrings, this));

        wheelView.setOnWheelItemSelectedListener(new WheelView.OnWheelItemSelectListener() {
            @Override
            public void onWheelItemSelected(WheelView parent, Drawable itemDrawable, int position) {

            }
        });
        wheelView.setOnWheelItemClickListener(new WheelView.OnWheelItemClickListener() {
            @Override
            public void onWheelItemClick(WheelView parent, int position, boolean isSelected) {
                if (!isSelected) {
                    return;
                }
                if (mRunnable == null) {
                    mRunnable = new Runnable() {
                        @Override
                        public void run() {
                            //Xu ly sau khi quay xong
                        }
                    };
                }
                if (mHandler == null) mHandler = new Handler();
                mHandler.postDelayed(mRunnable, 1000);
            }
        });
        btnStart.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                wheelView.startRoulette(4);
            }
        });

    }

    private static class RouletteAdapter extends WheelArrayAdapter<String> {

        private List<String> list;
        private Context mContext;

        RouletteAdapter(List<String> items, Context context) {
            super(items);
            this.list = items;
            this.mContext = context;
        }

        @Override
        public Drawable getDrawable(int position) {
            int color;
            String text = list.get(position);

            switch (position) {
                case 0:
                    color = mContext.getResources().getColor(R.color.bg_coupon_basic);
                    break;
                case 1:
                    color = mContext.getResources().getColor(R.color.bg_coupon_data);
                    break;
                case 2:
                    color = mContext.getResources().getColor(R.color.bg_coupon_prize);
                    break;
                case 3:
                    color = mContext.getResources().getColor(R.color.bg_coupon_vas);
                    break;
                case 4:
                    color = mContext.getResources().getColor(R.color.bg_coupon_miss);
                    break;
                case 5:
                    color = mContext.getResources().getColor(R.color.blue_grey_900);
                    break;
                case 6:
                    color = mContext.getResources().getColor(R.color.deep_orange_a700);
                    break;
                case 7:
                    color = mContext.getResources().getColor(R.color.green_800);
                    break;
                case 8:
                    color = mContext.getResources().getColor(R.color.cyan_a700);
                    break;
                case 9:
                    color = mContext.getResources().getColor(R.color.light_blue_a700);
                    break;
                case 10:
                    color = mContext.getResources().getColor(R.color.indigo_a700);
                    break;
                default:
                    color = mContext.getResources().getColor(R.color.purple_a700);
                    break;
            }

            return new ItemDrawable(mContext, text, color);
        }
    }
}

Chú ý: wheelView.startRoulette(4); chính là xác định vị trí trúng thưởng trong list

Kết luận

Hiện ứng dụng chỉ dạng sample đơn giản, và có thể chưa thật sự tối ưu và đơn giản, mong mọi người nhiệt tình góp ý Link project đầy đủ tại đây: https://github.com/HikaruNguyen/RouletteExample