Android 自定义 View 实现转盘

667 阅读6分钟

先上效果图。

转盘效果

转盘绘制

通过观察,我们看到转盘主要由圆和扇形组成

我们只需按照我们需要的数量画出扇形即可。

  • 第一步:画大圆

    private fun drawLargeCircle(canvas: Canvas?) {
            val centerX = measuredWidth / 2F
            val centerY = measuredHeight / 2F
            val radius = measuredWidth / 2F
            mPaint.color = bgColor
            canvas?.drawCircle(centerX, centerY, radius - 10, mPaint)
            mPaint.color = centerStrokeColor
            mPaint.style = Paint.Style.STROKE
            for (i in 0 until 10) {
                mPaint.alpha = 5 * (i + 1)
                canvas?.drawCircle(centerX, centerY, radius - i, mPaint)
            }
        }
    
  • 第二步:画扇形(小圆只是为了确定扇形圆心的位置,并不用真正画出来)

    private fun drawSector(canvas: Canvas?) {
            val sectorRadius = (measuredWidth / 2) * sectorProportion
            val sectorCenterRadius = (measuredWidth / 2) * 0.07f
            val centerX = measuredWidth / 2F
            val centerY = measuredHeight / 2F
            mPaint.color = sectorColor
            mPaint.style = Paint.Style.FILL
            var angle = 360F / selectList.size
            for (i in 0 until selectList.size) {
                if ((i + 2) % selectList.size == selectedIndex) {
                    mPaint.color = startColor
                } else {
                    mPaint.color = sectorColor
                }
                val cx = centerX + cos(angleToRadian(angle * i)).toFloat() * sectorCenterRadius
                val cy = centerY + sin(angleToRadian(angle * i)).toFloat() * sectorCenterRadius
                val oval = RectF(cx - sectorRadius, cy - sectorRadius, cx + sectorRadius, cy + sectorRadius)
                canvas?.drawArc(oval, (angle * i) - (angle / 2), angle, true, mPaint)
            }
            mPaint.color = sectorColor
            mPaint.alpha = 50
            val littleCircleCount = selectList.size * 2
            angle = 360F / littleCircleCount
            for (i in 0 until littleCircleCount) {
                val littleCenterX = centerX + cos(angleToRadian(angle * i)).toFloat() * (((measuredWidth / 2) + sectorRadius + sectorCenterRadius) / 2F - 5)
                val littleCenterY = centerY + sin(angleToRadian(angle * i)).toFloat() * (((measuredWidth / 2) + sectorRadius + sectorCenterRadius) / 2F - 5)
                canvas?.drawCircle(littleCenterX, littleCenterY, (measuredWidth / 2) * littleCircleProportion, mPaint)
            }
    
  • 第三步:绘制其他元素

    private fun drawCenter(canvas: Canvas?) {
            val centerX = measuredWidth / 2F
            val centerY = measuredHeight / 2F
            mPaint.color = bgColor
            mPaint.alpha = 255
            canvas?.drawCircle(centerX, centerY, (measuredWidth / 2) * centerProportion, mPaint)
            mPaint.style = Paint.Style.STROKE
            mPaint.color = centerStrokeColor
            mPaint.strokeWidth = 1F
            for (i in 0..(centerX * centerDistanceProportion).toInt()) {
                mPaint.alpha = 5 * i
                canvas?.drawCircle(centerX, centerY, centerX * centerProportion - i, mPaint)
            }
            mPaint.color = bgColor
            mPaint.style = Paint.Style.FILL
            canvas?.drawCircle(centerX, centerY, (centerX * centerProportion) -  (centerX * centerDistanceProportion), mPaint)
            mPaint.color = centerBottomColor
            canvas?.drawCircle(centerX, centerY, (centerX * centerProportion) -  ((centerX * centerDistanceProportion) * 1.5F), mPaint)
            if (isClickable) {
                mPaint.color = startColor
            } else {
                mPaint.color = bgColor
            }
            canvas?.drawCircle(centerX, centerY, (centerX * centerProportion) -  ((centerX * centerDistanceProportion) * 2), mPaint)
        }
    ​
        private fun drawText(canvas: Canvas?) {
            val radius = measuredWidth / 3F
            val centerX = measuredWidth / 2F
            val centerY = measuredHeight / 2F
            val angle = 360F / selectList.size
            mPaint.color = sectorTextColor
            mPaint.textSize = centerX * sectorTextSizeProportion
            for (i in 0 until selectList.size) {
                if (selectedIndex == (i + 2) % selectList.size) {
                    mPaint.color = Color.WHITE
                } else {
                    mPaint.color = sectorTextColor
                }
                val cx = centerX + cos(angleToRadian(angle * i)).toFloat() * radius - ((measuredWidth / 2) * sectorTextSizeProportion * 0.42F)
                val cy = centerY + sin(angleToRadian(angle * i)).toFloat() * radius + ((measuredWidth / 2) * sectorTextSizeProportion * 0.42F)
                canvas?.drawText(selectList[(i + 2) % selectList.size], cx, cy, mPaint)
            }
            if (isClickable) {
                mPaint.color = startTextColor
                mPaint.textSize = centerX * sectorTextSizeProportion * 0.8f
                topText = "开始"
                canvas?.drawText(topText, centerX * (1 - 0.8F * sectorTextSizeProportion), centerY * (1 + 0.3F * sectorTextSizeProportion), mPaint)
            } else {
                mPaint.color = sectorTextColor
                mPaint.textSize = centerX * centerTextProportion
                canvas?.drawText(topText, centerX * (1 - 0.3F * centerTextProportion), centerY * (1 + 0.3F * centerTextProportion), mPaint)
            }
        }
    

让转盘动起来

这里的让转盘动起来的方式,是通过依次改变扇形的背景颜色,这个是产品需求,你也可以通过动画让他真正的转起来。

我们需要考虑的一点是,我们必须让转盘可控,即转盘的最终结果在开始转之前我们就已经知道,所以我们需要让转盘转到我们指定的结果。

我们可以给转盘一个初始速度和一个结束速度(即美妙转动的次数),理论上结束速度肯定是小于初始速度的,我们每一秒都使速度减小一次,我们通过控制中间减少多少来控制最终转盘转到哪个。

// 确保最后可以选中要选择的
    private fun computeSpeed() {
        if (selectedIndex < 0) {
            selectedIndex = 0
        }
        selectedIndex %= selectList.size
        var distance = if (willSelectIndex >= selectedIndex) {
            willSelectIndex  - selectedIndex
        } else {
            selectList.size - selectedIndex + willSelectIndex
        }
        distance %= selectList.size
        val speedDistance = (endSpeed - startSpeed) / (selectList.size - 2)
        speedList.clear()
        speedList.add(startSpeed)
        for (i in 1 until selectList.size - 1) {
            speedList.add(startSpeed + (i * speedDistance))
        }
        speedList.add(endSpeed)
        for (i in 0 until speedList.size) {
            var count = (1000 / speedList[i])
            if (count < selectList.size) {
                count = selectList.size
            } else {
                count -= (count % selectList.size)
            }
            speedList[i] = 1000 / count
        }
        for (i in 0 until distance) {
            val count = (1000 / speedList[i]) + 1
            speedList[i] = 1000 / count
        }
    }

完整代码

粗略记录,有问题可评论或私信,看到就回。

<declare-styleable name="TurntableView">
        <attr name="background_color" format="color" />
        <attr name="sector_color" format="color" />
        <attr name="center_stroke_color" format="color" />
        <attr name="center_bottom_color" format="color" />
        <attr name="sector_text_color" format="color" />
        <attr name="start_btn_color" format="color" />
        <attr name="start_text_color" format="color" />
    </declare-styleable>
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.core.content.ContextCompat
import com.baijiayun.liveuibase.R
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import java.util.concurrent.TimeUnit
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
​
/**
 * 转盘工具
 * @author 陈鹏伟
 * @date 2022/6/14
 */
class TurntableView: View {
    constructor(context: Context) : super(context) {
        init(null)
    }
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init(attrs)
    }
    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init(attrs)
    }
    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
        init(attrs)
    }
​
    // 默认尺寸
    private val defaultSize = 150
    // 扇形半径比例
    private val sectorProportion = 0.84F
    // 外圈小圆半径比例
    private val littleCircleProportion = 0.02F
    // 中心圆比例
    private val centerProportion = 0.35F
    // 中心圆间隔比例
    private val centerDistanceProportion = 0.05F
    // 转盘文字比例
    private val sectorTextSizeProportion = 0.2F
    // 中心文字比例
    private val centerTextProportion = 0.3F
​
    // 背景颜色(大圆)颜色
    private var bgColor = 0
    // 扇形颜色
    private var sectorColor = 0
    // 中心圆圈边框颜色
    private var centerStrokeColor = 0
    // 中心圆圈底层颜色
    private var centerBottomColor = 0
    // 开始按钮背景颜色 = 扇形选中背景颜色
    private var startColor = 0
    // 扇形文字颜色
    private var sectorTextColor = 0
    // 开始按钮文字颜色 = 扇形选中文字颜色
    private var startTextColor = 0
    // 选项列表
    private var selectList = arrayListOf("1", "2", "3", "4", "5", "6")
    // 当前选中的选项下标
    private var selectedIndex = -1
    // 即将选中的选项下标
    private var willSelectIndex = 4
    // 转盘转动的时间 seconds
    private var turnTime = 5
    // 转盘旋转
    private var disposableOfTurn: Disposable? = null
    // 转盘按钮文字
    private var topText = ""
    // 初始速度 40ms 一个
    private var startSpeed = 40
    // 结束速度 200ms 一个
    private var endSpeed = 200
    // 速度列表 1s 一个速度
    private var speedList = mutableListOf<Int>()
    // 当前速度
    private var currentSpeed = 1
    // 初始是否可点击
    private var initialClickable = false
​
    private val mPaint by lazy {
        Paint()
    }
​
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val width = getRealSize(widthMeasureSpec)
        val height = getRealSize(heightMeasureSpec)
        val final = min(width, height)
        setMeasuredDimension(final, final)
    }
​
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        mPaint.isAntiAlias = true
        drawLargeCircle(canvas)
        drawSector(canvas)
        drawCenter(canvas)
        drawText(canvas)
    }
​
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                if (isClickCenter(event.x, event.y)) {
                    performClick()
                }
                return isClickCenter(event.x, event.y)
            }
            MotionEvent.ACTION_MOVE -> {
                return false
            }
            MotionEvent.ACTION_UP -> {
                return false
            }
            else -> super.onTouchEvent(event)
        }
    }
​
    override fun setClickable(clickable: Boolean) {
        super.setClickable(clickable)
        invalidate()
    }
​
    override fun performClick(): Boolean {
        return super.performClick()
    }
​
    /**
     * 设置选项列表
     * @param list 选项列表,size >= 3
     */
    fun setSelectList(list: ArrayList<String>) {
        if (list.size < 3) {
            return
        }
        selectList = list
    }
​
    /**
     * 设置要选中的选项下标
     * @param index 选项下标
     */
    private fun setWillSelectIndex(index: Int) {
        if (willSelectIndex != index) {
            willSelectIndex = index
        }
        if (willSelectIndex >= selectList.size) {
            willSelectIndex = selectList.size - 1
        }
        if (willSelectIndex < 0) {
            willSelectIndex = 0
        }
        willSelectIndex = (willSelectIndex + 3) % selectList.size
    }
​
    /**
     * 设置转盘转动时间
     * @param time 转盘转动时间 s
     */
    private fun setTurnTime(time: Int) {
        turnTime = time
    }
​
    /**
     * 设置背景颜色
     */
    override fun setBackgroundColor(color: Int) {
        this.bgColor = color
        invalidate()
    }
​
    /**
     * 设置扇形颜色
     */
    fun setSectorColor(color: Int) {
        this.sectorColor = color
        invalidate()
    }
​
    /**
     * 设置阴影颜色
     */
    fun setStrokeColor(color: Int) {
        this.centerStrokeColor = color
        invalidate()
    }
​
    /**
     * 设置中心圆底层颜色
     */
    fun setCenterBottomColor(color: Int) {
        this.centerBottomColor = color
        invalidate()
    }
​
    /**
     * 设置开始按钮背景颜色和扇形选中颜色
     */
    fun setStartBackgroundColor(color: Int) {
        this.startColor = color
        invalidate()
    }
​
    /**
     * 设置扇形文字颜色
     */
    fun setSectorTextColor(color: Int) {
        this.sectorTextColor = color
        invalidate()
    }
​
    /**
     * 设置开始按钮文字颜色
     */
    fun setStartTextColor(color: Int) {
        this.startTextColor = color
        invalidate()
    }
​
    /**
     * 开始转动转盘
     * @param selectIndex 最终要选中的值
     * @param time 转盘转动时间
     */
    fun startTurn(selectIndex: String, time: Int) {
        setWillSelectIndex(selectList.indexOf(selectIndex))
        release()
        setTurnTime(time)
        topText = turnTime.toString()
        invalidate()
        computeSpeed()
        currentSpeed = speedList[0]
        initialClickable = isClickable
        isClickable = false
        disposableOfTurn = Observable.interval(0, 1, TimeUnit.MILLISECONDS)
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe {
                if (it >= currentSpeed && it % currentSpeed == 0L) {
                    selectedIndex ++
                    selectedIndex %= selectList.size
                    invalidate()
                }
                if (it >= 1000 && it % 1000 == 0L) {
                    currentSpeed = speedList[(it / 1000).toInt()]
                    topText = (turnTime - it / 1000).toString()
                    invalidate()
                }
                if (it == turnTime * 1000L + 500) {
                    if (initialClickable) {
                        isClickable = true
                        topText = "开始"
                    } else {
                        topText = ""
                    }
                    disposableOfTurn?.dispose()
                    invalidate()
                }
            }
    }
​
    /**
     * 转盘是否正在转动
     */
    fun isTurning() = disposableOfTurn != null && !disposableOfTurn!!.isDisposed
​
    /**
     * 释放资源,父容器销毁时须调用
     */
    fun release() {
        disposableOfTurn?.dispose()
    }
​
    private fun init(attrs: AttributeSet?) {
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TurntableView)
        bgColor = typedArray.getColor(R.styleable.TurntableView_background_color, ContextCompat.getColor(context, R.color.bjy_base_turntable_default_background_color))
        sectorColor = typedArray.getColor(R.styleable.TurntableView_sector_color, ContextCompat.getColor(context, R.color.bjy_base_turntable_default_sector_color))
        centerStrokeColor = typedArray.getColor(R.styleable.TurntableView_center_stroke_color, ContextCompat.getColor(context, R.color.bjy_base_turntable_default_center_stroke_color))
        centerBottomColor = typedArray.getColor(R.styleable.TurntableView_center_bottom_color, ContextCompat.getColor(context, R.color.bjy_base_turntable_default_center_bottom_color))
        sectorTextColor = typedArray.getColor(R.styleable.TurntableView_sector_text_color, ContextCompat.getColor(context, R.color.bjy_base_turntable_default_sector_text_color))
        startColor = typedArray.getColor(R.styleable.TurntableView_start_btn_color, ContextCompat.getColor(context, R.color.bjy_base_turntable_default_start_btn_color))
        startTextColor = typedArray.getColor(R.styleable.TurntableView_start_text_color, ContextCompat.getColor(context, R.color.bjy_base_turntable_default_start_text_color))
        typedArray.recycle()
    }
​
    private fun getRealSize(measureSpec: Int): Int {
        val mode = MeasureSpec.getMode(measureSpec)
        val size = MeasureSpec.getSize(measureSpec)
        return if (mode == MeasureSpec.EXACTLY) {
            size
        } else {
            defaultSize
        }
    }
​
    private fun drawLargeCircle(canvas: Canvas?) {
        val centerX = measuredWidth / 2F
        val centerY = measuredHeight / 2F
        val radius = measuredWidth / 2F
        mPaint.color = bgColor
        canvas?.drawCircle(centerX, centerY, radius - 10, mPaint)
        mPaint.color = centerStrokeColor
        mPaint.style = Paint.Style.STROKE
        for (i in 0 until 10) {
            mPaint.alpha = 5 * (i + 1)
            canvas?.drawCircle(centerX, centerY, radius - i, mPaint)
        }
    }
​
    private fun drawSector(canvas: Canvas?) {
        val sectorRadius = (measuredWidth / 2) * sectorProportion
        val sectorCenterRadius = (measuredWidth / 2) * 0.07f
        val centerX = measuredWidth / 2F
        val centerY = measuredHeight / 2F
        mPaint.color = sectorColor
        mPaint.style = Paint.Style.FILL
        var angle = 360F / selectList.size
        for (i in 0 until selectList.size) {
            if ((i + 2) % selectList.size == selectedIndex) {
                mPaint.color = startColor
            } else {
                mPaint.color = sectorColor
            }
            val cx = centerX + cos(angleToRadian(angle * i)).toFloat() * sectorCenterRadius
            val cy = centerY + sin(angleToRadian(angle * i)).toFloat() * sectorCenterRadius
            val oval = RectF(cx - sectorRadius, cy - sectorRadius, cx + sectorRadius, cy + sectorRadius)
            canvas?.drawArc(oval, (angle * i) - (angle / 2), angle, true, mPaint)
        }
        mPaint.color = sectorColor
        mPaint.alpha = 50
        val littleCircleCount = selectList.size * 2
        angle = 360F / littleCircleCount
        for (i in 0 until littleCircleCount) {
            val littleCenterX = centerX + cos(angleToRadian(angle * i)).toFloat() * (((measuredWidth / 2) + sectorRadius + sectorCenterRadius) / 2F - 5)
            val littleCenterY = centerY + sin(angleToRadian(angle * i)).toFloat() * (((measuredWidth / 2) + sectorRadius + sectorCenterRadius) / 2F - 5)
            canvas?.drawCircle(littleCenterX, littleCenterY, (measuredWidth / 2) * littleCircleProportion, mPaint)
        }
    }
​
    private fun drawCenter(canvas: Canvas?) {
        val centerX = measuredWidth / 2F
        val centerY = measuredHeight / 2F
        mPaint.color = bgColor
        mPaint.alpha = 255
        canvas?.drawCircle(centerX, centerY, (measuredWidth / 2) * centerProportion, mPaint)
        mPaint.style = Paint.Style.STROKE
        mPaint.color = centerStrokeColor
        mPaint.strokeWidth = 1F
        for (i in 0..(centerX * centerDistanceProportion).toInt()) {
            mPaint.alpha = 5 * i
            canvas?.drawCircle(centerX, centerY, centerX * centerProportion - i, mPaint)
        }
        mPaint.color = bgColor
        mPaint.style = Paint.Style.FILL
        canvas?.drawCircle(centerX, centerY, (centerX * centerProportion) -  (centerX * centerDistanceProportion), mPaint)
        mPaint.color = centerBottomColor
        canvas?.drawCircle(centerX, centerY, (centerX * centerProportion) -  ((centerX * centerDistanceProportion) * 1.5F), mPaint)
        if (isClickable) {
            mPaint.color = startColor
        } else {
            mPaint.color = bgColor
        }
        canvas?.drawCircle(centerX, centerY, (centerX * centerProportion) -  ((centerX * centerDistanceProportion) * 2), mPaint)
    }
​
    private fun drawText(canvas: Canvas?) {
        val radius = measuredWidth / 3F
        val centerX = measuredWidth / 2F
        val centerY = measuredHeight / 2F
        val angle = 360F / selectList.size
        mPaint.color = sectorTextColor
        mPaint.textSize = centerX * sectorTextSizeProportion
        for (i in 0 until selectList.size) {
            if (selectedIndex == (i + 2) % selectList.size) {
                mPaint.color = Color.WHITE
            } else {
                mPaint.color = sectorTextColor
            }
            val cx = centerX + cos(angleToRadian(angle * i)).toFloat() * radius - ((measuredWidth / 2) * sectorTextSizeProportion * 0.42F)
            val cy = centerY + sin(angleToRadian(angle * i)).toFloat() * radius + ((measuredWidth / 2) * sectorTextSizeProportion * 0.42F)
            canvas?.drawText(selectList[(i + 2) % selectList.size], cx, cy, mPaint)
        }
        if (isClickable) {
            mPaint.color = startTextColor
            mPaint.textSize = centerX * sectorTextSizeProportion * 0.8f
            topText = "开始"
            canvas?.drawText(topText, centerX * (1 - 0.8F * sectorTextSizeProportion), centerY * (1 + 0.3F * sectorTextSizeProportion), mPaint)
        } else {
            mPaint.color = sectorTextColor
            mPaint.textSize = centerX * centerTextProportion
            canvas?.drawText(topText, centerX * (1 - 0.3F * centerTextProportion), centerY * (1 + 0.3F * centerTextProportion), mPaint)
        }
    }
​
    private fun angleToRadian(angle: Float) = angle * PI / 180
​
    private fun isClickCenter(x: Float, y: Float): Boolean {
        val centerX = measuredWidth / 2
        val centerY = measuredHeight / 2
        val radius = (measuredWidth / 2) * centerProportion
        if (x < centerX - radius || x > centerX + radius) {
            return false
        }
        if (y > centerY + radius || y < centerY - radius) {
            return false
        }
        return true
    }
​
​
    // 确保最后可以选中要选择的
    private fun computeSpeed() {
        if (selectedIndex < 0) {
            selectedIndex = 0
        }
        selectedIndex %= selectList.size
        var distance = if (willSelectIndex >= selectedIndex) {
            willSelectIndex  - selectedIndex
        } else {
            selectList.size - selectedIndex + willSelectIndex
        }
        distance %= selectList.size
        val speedDistance = (endSpeed - startSpeed) / (selectList.size - 2)
        speedList.clear()
        speedList.add(startSpeed)
        for (i in 1 until selectList.size - 1) {
            speedList.add(startSpeed + (i * speedDistance))
        }
        speedList.add(endSpeed)
        for (i in 0 until speedList.size) {
            var count = (1000 / speedList[i])
            if (count < selectList.size) {
                count = selectList.size
            } else {
                count -= (count % selectList.size)
            }
            speedList[i] = 1000 / count
        }
        for (i in 0 until distance) {
            val count = (1000 / speedList[i]) + 1
            speedList[i] = 1000 / count
        }
    }
}

\