自定义 View 之给你的控件加上状态

2023-08-11

这是一篇非常基础的内容,但是在工作中却发现很多开发者甚至工作了好几年的却从来没用过它。

Checkbox 大家应该都用过,是一个带状态的控件,可以选中和取消选中,它其实就是通过改变 drawableState 来实现状态的切换。

在日常开发中,我们也会经常遇到类似的设计。例如有个切换模式的 icon,默认是列表模式,点击后变成 grid 模式,再次点击又变成列表模式,这样的 icon 我们认为它是有状态的;再比如有个控制时间排序的控件,默认状态是默认排序,点击一次切换成升序,且向上箭头高亮,再点击一次切换成降序,且向下箭头高亮,再点击一次切换成默认排序,如此往复循环。

screen

带状态的控件,我们都可以通过自定义 View 或 ViewGroup,将状态的管理以及 UI 的变化放在控件的内部,对外暴露设置/获取/切换状态以及状态变化的监听。这样外部就可以专注业务的逻辑,而非大量的更新 UI 的操作。

下面我们就来自定义一个时间排序的控件:

首先,这个控件有默认,升序,降序三种状态,并且这三种状态是循环切换的,所以我们定义一个这三种状态的枚举,并提供切换到下一状态的方法:

1
2
3
4
5
6
7
8
enum class SortMode {
NONE, ASC, DSC;

fun next(): SortMode {
val values = values()
return values[(ordinal + 1) % values.size]
}
}

并且在 values 目录下的 attrs.xml 的 resources 标签中添加三个状态的属性:

1
2
3
4
5
6
<!-- why not enum: https://stackoverflow.com/questions/13147360/android-is-it-possible-to-use-string-enum-in-drawable-selector -->
<declare-styleable name="SortableState">
<attr name="state_sort_none" format="boolean" />
<attr name="state_sort_asc" format="boolean" />
<attr name="state_sort_dsc" format="boolean" />
</declare-styleable>

其次,我们想让这个控件对外暴露的能力有,设置控件状态,获取控件状态,切换控件状态,所以我们抽象出一个 Sortable 接口:

1
2
3
4
5
interface Sortable {
fun setSortMode(mode: SortMode)
fun getSortMode(): SortMode
fun toggle()
}

由于我们还想监听控件的状态变化,所以,再提供一个 OnSortChangedListener 接口:

1
2
3
fun interface OnSortChangedListener {
fun onSortChanged(view: SortableTextView, mode: SortMode)
}

然后就是自定义 TextView 实现 Sortable 接口,最重要的是重写 onCreateDrawableState 方法,处理不同状态下的 drawableState 返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class SortableTextView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AppCompatTextView(context, attr, defStyleAttr), Sortable {

private var mode = SortMode.NONE

private var onSortChangedListener: OnSortChangedListener? = null

init {
isClickable = true
}

override fun setSortMode(mode: SortMode) {
if (this.mode != mode) {
toggle()
}
}

override fun getSortMode(): SortMode {
return mode
}

override fun toggle() {
if (isEnabled) {
mode = mode.next()
refreshDrawableState()
onSortChangedListener?.onSortChanged(this, mode)
}
}

override fun onCreateDrawableState(extraSpace: Int): IntArray {
val drawableState = super.onCreateDrawableState(extraSpace + 3)
if (mode == SortMode.NONE) {
mergeDrawableStates(drawableState, SORT_NONE_SET)
}
if (mode == SortMode.ASC) {
mergeDrawableStates(drawableState, SORT_ASC_SET)
}
if (mode == SortMode.DSC) {
mergeDrawableStates(drawableState, SORT_DSC_SET)
}
return drawableState
}

override fun performClick(): Boolean {
toggle()
return super.performClick()
}

fun setOnSortChangedListener(listener: OnSortChangedListener) {
onSortChangedListener = listener
}

companion object {
private val SORT_NONE_SET = intArrayOf(R.attr.state_sort_none)
private val SORT_ASC_SET = intArrayOf(R.attr.state_sort_asc)
private val SORT_DSC_SET = intArrayOf(R.attr.state_sort_dsc)
}

}

使用的时候给控件设置 selector drawable,当触发 toggle 时会调用 refreshDrawableState 去刷新 drawableState 来实现 UI 上的状态切换

1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:drawable="@drawable/ic_sort_asc" app:state_sort_asc="true" />
<item android:drawable="@drawable/ic_sort_dsc" app:state_sort_dsc="true" />
<item android:drawable="@drawable/ic_sort_default" app:state_sort_none="true" />
<item android:drawable="@drawable/ic_sort_default" />
</selector>