制作一款HSV格式的颜色选择器

常用的颜色格式分为RGB格式和HSV格式,RGB顾名思义是通过红绿蓝三色来表达一个颜色的值,而真实的数据存储格式也是选择这种模式,将三色的值通过高地位来分别保存。RGB格式虽然存储起来方便,对计算机更有好,但是对于感官来说并不友好,而HSV格式则相对于RGB更加直接。
颜色选择器的色谱大部分都会选择使用预制图片来进行实现,有些low,本篇记录如何通过单纯的绘制来实现。
鉴于gif录制对色彩的展现不友好,这里的效果图改用H5的视频播发器,隐藏掉了控制栏,循环播放:

上面效果中的选择器分为两部分: 第一部分选择器的主体,占用了组件大部分我区域。 第二部分是一个条形的色相选择器。

首先来理一下这个选择器和HSB格式的关系。 HSB模式中,H(hues)表示色相,S(saturation)表示饱和度,V(value)表示明度 在主体面板中,横向表示饱和度,越往右饱和度越高,反之则越小;纵向表示明度的值,越往下亮度值越小,反之则越大。 在色相选择条中,左右偏移调整主体部分的色相

在Android中,HSV的三个值表达范围分别为: H [0-360] float S [0-1] float V [0-1] float

关于组件的设计,要分为几个步骤。 首先是拆分,选择器主体和色相选择条可以分开来实现,这样能够更加灵活的来进行布局,比如把色相选择条放到主体其他方位,比如上侧左右侧等。 考虑到性能问题,仍然需要进一步的拆分,色谱的绘制会消耗掉一定的性能,而选择焦点的绘制和色谱的绘制放在同一个view中可能会引起不必要的开销,因为在触摸移动过程中,会有大量的event涌入,从而造成大量的canvas绘制,而这时候色谱自身是没有变化的,不必要的重绘开销显而易见。 所以这里选择将选择焦点的view和底层色谱的view分开来实现,将焦点层放在色谱层上面,从而避免了色谱的重绘。

本篇使用kotlin来进行设计,节省篇幅,代码精简。

主色谱的绘制

主色谱是有两个方向的过渡色交叉而成,纵向是由白到黑,横向是由白到一个满饱和度满明度的颜色。 关于过渡色,最常用的莫过于线性过渡色,而这种向两个方向过渡的效果是不常用的,乍一看一脸懵逼,其实这种过渡效果Android种也根本没有提供现成的方法。不过却可以通过相对不是那么直接的方式来实现。 既然是两个方向的交叉过渡,索性拆为两个过渡色,那么如果将两个方向的过渡色进行融合呢,Paint提供了一个叫做``,这个类可以根据两个不通的shader进行融合,从而作为一个新的shader使用,而我们只需要提供一个横向的shader和一个纵向的shader即可。

class ColorMainCanvas @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {
    val paintBg = Paint()

    init {
        paintBg.isAntiAlias = true
        paintBg.color = Color.RED

        //ComposeShader默认不能使用两个相同类型的shader进行合并,若要支持这个特性需要关闭硬件加速。
        //见https://stackoverflow.com/questions/12445583/issue-with-composeshader-on-android-4-1-1
        setLayerType(View.LAYER_TYPE_SOFTWARE, null)
    }

    var color = Color.RED
        set(value) {
            field = value
            postInvalidate()
        }

    override fun onDraw(canvas: Canvas) {
        val vertical = LinearGradient(0f, 0f, 0f, measuredHeight.toFloat(), Color.WHITE, Color.BLACK, Shader.TileMode.CLAMP)
        val horizontal = LinearGradient(0f, 0f, measuredWidth.toFloat(), 0f, Color.WHITE, color, Shader.TileMode.CLAMP)
        val composeShader = ComposeShader(horizontal, vertical, PorterDuff.Mode.MULTIPLY)
        paintBg.shader = composeShader
        canvas.drawRect(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat(), paintBg);
    }
}

色相条的绘制

关于色相条,不少实现都是采用一个预制的图片,这种方案虽然可行,但除了感觉low之外还存在着很多弊端,一是增加了应用体积,其次是如果在大分辨率的场景中进行使用的话会失真。 这里就严格按照色相的值域来进行绘制,这样不但不会因为图片文件造成体积增大,而且在任何分辨率下都能够非常准确的展现出色彩效果。 色相值域是从0到360度,所以可以定义一个有36个或者360个色彩锚点的过渡色shader,经过测验36个和360个锚点绘制出的效果相差不大,当然可以选择其他数量,锚点越多越准确。

class HueCanvas @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {

    var colors: IntArray
    var positions: FloatArray
    var paintBg = Paint()

    init {
        paintBg.isAntiAlias = true;
        colors = (0..360).map { Color.HSVToColor(floatArrayOf(it * 1f, 100f, 100f)) }.toIntArray()
        positions = (0..360).map { it / 360f }.toFloatArray()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        paintBg.shader = LinearGradient(0f, 0f, measuredWidth.toFloat(), 0f, colors, positions, Shader.TileMode.CLAMP)
        canvas.drawRect(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat(), paintBg)
    }
}

主色谱选择器

选择器焦点的设计就分很多种了,可以通过canvas绘制几何形状,也可以绘制bitmap,或者通过移动view,也可以通过复写onlayout方法。这里采用简单的绘制几何图形来实现。逻辑相对也简单,只要监听处理掉onTouch事件,拿到最近的触摸点,甚至连事件的类型都不用理会。触摸事件通过postInvalidate方法通知view重绘,更新界面上焦点的最新位置。

class ColorMainPicker @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {

    var paintFocus = Paint()
    val strokeWith = dp2px(2f)
    val circleRadius1 = dp2px(8f)
    val circleRadius2 = dp2px(10f)

    var onChoose: ((Float, Float) -> Unit)? = null

    var progressX = 0f
    var progressY = 0f

    private fun dp2px(dp: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.resources.displayMetrics)

    init {
        paintFocus.isAntiAlias = true
        paintFocus.style = Paint.Style.STROKE
        paintFocus.strokeWidth = strokeWith
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        paintFocus.color = Color.BLACK
        val cx = progressX * measuredWidth
        val cy = progressY * measuredHeight
        canvas.drawCircle(cx, cy, circleRadius1, paintFocus)
        paintFocus.color = Color.WHITE
        canvas.drawCircle(cx, cy, circleRadius2, paintFocus)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        val currentX = Math.max(0f, Math.min(measuredWidth.toFloat(), event.x))
        val currentY = Math.max(0f, Math.min(measuredHeight.toFloat(), event.y))
        progressX = currentX / measuredWidth
        progressY = currentY / measuredHeight
        onChoose?.invoke(progressX, 1 - progressY)
        postInvalidate()
        return true
    }

    fun choose(progressX: Float, progressY: Float) {
        this.progressX = progressX
        this.progressY = 1 - progressY
        postInvalidate()
    }

}

色相条选择器

与主选择器类似,这个不画圆,而画细高的矩形,移动方向只使用横向。 条形焦点的绘制过程其实就一根竖线和一个包裹他的矩形组成。

class HuePicker @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {

    var paintFocus = Paint()
    val strokeWith = dp2px(2f)
    var progress = 0f
    var onChoose: ((Float) -> Unit)? = null

    private fun dp2px(dp: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.resources.displayMetrics)

    init {
        paintFocus.isAntiAlias = true
        paintFocus.style = Paint.Style.STROKE
        paintFocus.strokeWidth = strokeWith
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        paintFocus.color = Color.BLACK
        val x = progress * measuredWidth
        canvas.drawLine(x, 0f, x, measuredHeight.toFloat(), paintFocus)
        paintFocus.color = Color.WHITE

        val left = x - strokeWith
        val top = 0f
        val right = x + strokeWith
        val bottom = measuredHeight.toFloat()
        canvas.drawRect(left, top, right, bottom, paintFocus)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        val currentX = Math.max(0f, Math.min(measuredWidth.toFloat(), event.x))
        progress = currentX / measuredWidth
        onChoose?.invoke(progress)
        postInvalidate()
        return true
    }

    fun choose(progress: Float) {
        this.progress = progress
        postInvalidate()
    }
}

组装

上面的四个组件仍旧是零散的,不能作为一个成熟的控件来使用,如何来组装呢,其实很简单,使用最初级的自定义ViewGroup即可完成。 定义一个布局文件,将上面做好的四个组件按照设想的模式进行排列,当然canvas和picker需要是同样大小而且是上下重叠的,这样才能让picker组件接收到touch事件的同时能够正确的计算出触摸事件在view中的相对位置。 然后自定义一个ViewGroup,不必要直接继承自ViewGroup,我们选择RelativeLayout即可。 将定义好的布局文件在组件初始化时初始化并作为唯一的子View添加进来,补全四个组件间的交互:

class ColorPicker @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) {

    var currentColor = floatArrayOf(0f, 1f, 1f)
        private set

    fun setColor(color: Int) {
        Color.colorToHSV(color, currentColor)
        main_canvas.color = Color.HSVToColor(floatArrayOf(currentColor[0], 1f, 1f))
        main_picker.choose(currentColor[1], currentColor[2])
        hue_picker.choose(currentColor[0] / 360)
        calcColor()
    }

    init {
        val view = LayoutInflater.from(context).inflate(R.layout.color_picker, this, false)
        addView(view)
        val layoutParams = view.layoutParams
        layoutParams.height = RelativeLayout.LayoutParams.MATCH_PARENT
        layoutParams.width = RelativeLayout.LayoutParams.MATCH_PARENT
        view.hue_picker.onChoose = { progress ->
            view.main_canvas.color = Color.HSVToColor(floatArrayOf(progress * 360, 1f, 1f))
            currentColor[0] = progress * 360
            calcColor()
        }
        view.main_picker.onChoose = { px, py ->
            currentColor[1] = px
            currentColor[2] = py
            calcColor()
        }
        calcColor()
    }

    fun calcColor() {
        val color = Color.HSVToColor(currentColor)
        onChoose?.invoke(color)
    }

    var onChoose: ((Int) -> Unit)? = null
}

在明白绘制技巧的前提下,制作一个颜色选择器并不难,甚至还可以做得更加酷炫。 本篇在使用kotlin的情况下节省了很多不必要的interface和findview操作,同时在定义组件时通过kotlin参数默认值的特性将三个构造函合为一个,大幅缩减了代码量。


完整代码地址:https://github.com/NightFarmer/ColorPicker

文章目录
,