在 Android 开发中,View 和 Drawable 之间关系十分紧密,例如我们经常用 Drawable 作为一个 View 的背景。View 常常会有状态的改变,例如被按下、例如禁用,而不同的状态下 Drawable 也常有不同的表现。今天要探索的问题是 View 的状态改变是如何影响 Drawable 的表现的。
以下将简单介绍我们平时如何在 View 上使用 Drawable,做到在不同状态下表现不一样。接着分析系统源码探索其中的原理。最后以系统的控件和自定义控件 2 个例子来验证和实践在 View 中自定义状态的做法。
注:
- 本文的源码分析基于 Android API Level 23,并省略掉部分与本文关系不大的代码。
- 在代码中加入了个人对源码的理解,以注释形式呈现。
- 本文最后的 DEMO 项目源码托管到 Github 上。
如何给 View 在不同状态下设置不同背景色
可以对一个 View 设置 background 属性,传进去的是一个 Drawable。 如果该 Drawable 是一个 StateListDrawable
(对应的 xml 标签为 <selctor>
),那么它能在不同状态下显示不同的表现。例如一个 Button,可以在 normal、pressed、disabled 等状态下显示不同的背景色,像这样:
<!-- button_bg.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="#CCCCCC" android:state_enabled="false"/>
<item android:drawable="#666666" android:state_pressed="true"/>
<item android:drawable="#999999"/>
</selector>
<!-- layout.xml -->
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/button_bg"
android:text="Test Button"/>
这样即可实现使 Button 在不同状态下颜色不一样,normal 为 #999999,pressed 为 #666666,disabled 时为 #CCCCCC。
原理
以上是经常用来设置 Button 背景的用法,那么实际上 Button(View)的不同状态是如何和 Drawable 关联起来的?除了上面说的 pressed 和 enabled 状态,我们可以设置的状态还有哪些?如果系统提供的状态不够用,我们能否自己定义状态?带着这几个问题,我们来看 Android FrameWork 的源码。
// View.java
// 首先,按钮被按下的时候,setPressed(boolean pressed) 会被调用。
// 注1:这里以 pressed 状态改变为例,从 setPressed 方法为入口。
// 同理当 enabled 或其他状态改变时,可以看 setEnabled 方法或其他对应方法。
public void setPressed(boolean pressed) {
final boolean needsRefresh =
pressed != ((mPrivateFlags & PFLAG_PRESSED) == PFLAG_PRESSED);
if (pressed) {
mPrivateFlags |= PFLAG_PRESSED;
} else {
mPrivateFlags &= ~PFLAG_PRESSED;
}
// 调用 refreshDrawableState() 方法来刷新 View 的状态
if (needsRefresh) {
refreshDrawableState();
}
dispatchSetPressed(pressed);
}
// 接着看 refreshDrawableState() 方法。
// 该方法会使 View 更新它的 Drawable 的状态,并调用 drawableStateChanged() 方法。
public void refreshDrawableState() {
// 设置 PFLAG_DRAWABLE_STATE_DIRTY 标志位,后面会用到,并调用 drawableStateChanged() 方法
mPrivateFlags |= PFLAG_DRAWABLE_STATE_DIRTY;
drawableStateChanged();
ViewParent parent = mParent;
if (parent != null) {
parent.childDrawableStateChanged(this);
}
}
// 接着看 drawableStateChanged() 方法。
protected void drawableStateChanged() {
// 调用 getDrawableState() 方法得到当前 View 的状态合集,以一个 int 数组的形式存在。
final int[] state = getDrawableState();
// 将状态合集设置给 background,那么 Drawable 就会自己更新状态并通知 View 重新绘制它。
final Drawable bg = mBackground;
if (bg != null && bg.isStateful()) {
bg.setState(state);
}
// 此处省略其他无关源代码...
}
// 接着看 getDrawableState() 方法,它会返回一个 resource ID 数组来表示 View 的当前状态。
public final int[] getDrawableState() {
// 因为 PFLAG_DRAWABLE_STATE_DIRTY 标志位在上面 refreshDrawableState() 方法中已经被设置,
// 所以从 refreshDrawableState() 方法调用进来时肯定会进入下面的 else 分支,
// 从 onCreateDrawableState(0) 方法取得 drawableState
if ((mDrawableState != null) && ((mPrivateFlags & PFLAG_DRAWABLE_STATE_DIRTY) == 0)) {
return mDrawableState;
} else {
mDrawableState = onCreateDrawableState(0);
mPrivateFlags &= ~PFLAG_DRAWABLE_STATE_DIRTY;
return mDrawableState;
}
}
// 接着看 onCreateDrawableState(int extraSpace) 方法。它的作用是生成这个 View 的 Drawable State。
protected int[] onCreateDrawableState(int extraSpace) {
// 如果这个 View 设置了 DUPLICATE_PARENT_STATE 标志位(可通过 setDuplicateParentStateEnabled(boolean enabled)方法来设置),
// 则直接通过父View的状态获得state,并返回。一般的 View 都没有设置这个标志位,所以这个条件一般不满足。
if ((mViewFlags & DUPLICATE_PARENT_STATE) == DUPLICATE_PARENT_STATE &&
mParent instanceof View) {
return ((View) mParent).onCreateDrawableState(extraSpace);
}
int[] drawableState;
int privateFlags = mPrivateFlags;
// 检查这个 View 的 pressed、enabled、focuesed 等状态(系统提供的 View 的状态都会在这里被检查一遍),
// 通过位运算记录在 viewStateIndex 这个整型变量的各个位上
int viewStateIndex = 0;
if ((privateFlags & PFLAG_PRESSED) != 0) viewStateIndex |= StateSet.VIEW_STATE_PRESSED;
if ((mViewFlags & ENABLED_MASK) == ENABLED) viewStateIndex |= StateSet.VIEW_STATE_ENABLED;
if (isFocused()) viewStateIndex |= StateSet.VIEW_STATE_FOCUSED;
if ((privateFlags & PFLAG_SELECTED) != 0) viewStateIndex |= StateSet.VIEW_STATE_SELECTED;
if (hasWindowFocus()) viewStateIndex |= StateSet.VIEW_STATE_WINDOW_FOCUSED;
if ((privateFlags & PFLAG_ACTIVATED) != 0) viewStateIndex |= StateSet.VIEW_STATE_ACTIVATED;
if (mAttachInfo != null && mAttachInfo.mHardwareAccelerationRequested &&
HardwareRenderer.isAvailable()) {
// This is set if HW acceleration is requested, even if the current
// process doesn't allow it. This is just to allow app preview
// windows to better match their app.
viewStateIndex |= StateSet.VIEW_STATE_ACCELERATED;
}
if ((privateFlags & PFLAG_HOVERED) != 0) viewStateIndex |= StateSet.VIEW_STATE_HOVERED;
final int privateFlags2 = mPrivateFlags2;
if ((privateFlags2 & PFLAG2_DRAG_CAN_ACCEPT) != 0) {
viewStateIndex |= StateSet.VIEW_STATE_DRAG_CAN_ACCEPT;
}
if ((privateFlags2 & PFLAG2_DRAG_HOVERED) != 0) {
viewStateIndex |= StateSet.VIEW_STATE_DRAG_HOVERED;
}
// 将 viewStateIndex 变量中记录的各个状态转化为一个数组,具体如何转化可以看 StateSet.get 方法,这里不做延伸讨论。
drawableState = StateSet.get(viewStateIndex);
// 如果参数 extraSpace 为 0,那么这个数组就是最终要返回的数组了。
if (extraSpace == 0) {
return drawableState;
}
// 如果 extraSpace 不为 0,那么会将 drawableState 数组的长度扩大 extraSpace 后返回。
final int[] fullState;
if (drawableState != null) {
fullState = new int[drawableState.length + extraSpace];
System.arraycopy(drawableState, 0, fullState, 0, drawableState.length);
} else {
fullState = new int[extraSpace];
}
return fullState;
}
到此,我们从 View 的 pressed 状态改变开始,根据源码看完了 View 内部如何改变 backgroundDrawable 的状态。简单总结一下:
- View 的 pressed 状态改变会调用
setPressed
方法。 -
setPressed
方法会调用refreshDrawableState
方法。 - 在
refreshDrawableState
中会调用drawableStateChanged
,去更新 drawable 的状态,其中就包括 backgroundDrawable。 - 在
drawableStateChanged
方法中,通过getDrawableState
方法得到 DrawableState 并设置为 backgroundDrawable,那么 Drawable 就会自己更新状态并通知 View 重新绘制。 - 而
getDrawableState
方法是通过onCreateDrawableState(int extraSpace)
方法来得到 DrawableState 的。
所以,View 的 backgroundDrawable 状态其实是由onCreateDrawableState(int extraSpace)
方法决定的,而setPressed
方法只是作为状态改变的整个流程的起点。
看完了源码,我们应该可以解决上面提出的几个问题:
Button(View)的不同状态是如何和 Drawable 关联起来的?
View 在状态改变时调用refreshDrawableState
去刷新 Drawable 的状态,而这些状态最终由onCreateDrawableState(int extraSpace)
方法返回。除了上面说的 pressed 和 enabled 状态,我们可以设置的状态还有哪些?
见View#onCreateDrawableState(int extraSpace)
方法,其中检查了 pressed、enabled、focused、selected、window_focused、activated、hardware_accelerated、hovered、drag_can_accept、drag_hovered 状态。所以,对于 View,我们可以控制这些状态。如果系统提供的状态不够用,我们能否自己定义状态?
当然可以,不可以的话我怎么会在这篇文章提出这个问题?其实 View 提供的状态很有限,而很多时候更底层的控件都需要定义更多状态栏满足特定的需求。接下来我们看自定义状态。
自定义状态在系统控件中的使用
我们先来看看系统控件自定义状态的做法。以 CheckBox 为例,CheckBox 是 View 的间接子类(两者中间还有好几层继承关系),提供了一个可勾选框的功能,它可以被 setChecked(boolean checked),并在 checked 为 true/false 时有不同的表现,那么 CheckBox 是如何在 View 的基础上实现 checked 状态的?
搜一下 CheckBox 的 setChecked
方法,实际上这个方法在其父类 CompoundButton 实现。
public void setChecked(boolean checked) {
if (mChecked != checked) {
mChecked = checked;
refreshDrawableState();
// 此处省略其他无关源代码...
}
}
// 该方法同样调用了 refreshDrawableState() 方法,且在这个类中没有重写 refreshDrawableState() 方法,说明接下来的代码流程会与上述流程一样。
// 但是这个类重写了 drawableStateChanged() 方法和 onCreateDrawableState(int extraSpace) 方法。
@Override
protected void drawableStateChanged() {
super.drawableStateChanged();
// 除了调用 super 的方法,还更新了自己持有的 mButtonDrawable 的状态
if (mButtonDrawable != null) {
int[] myDrawableState = getDrawableState();
// Set the state of the Drawable
mButtonDrawable.setState(myDrawableState);
invalidate();
}
}
// 用数组保存了要自定义的状态的 resource ID,这里自定义了 *checked* 状态
private static final int[] CHECKED_STATE_SET = {
R.attr.state_checked
};
@Override
protected int[] onCreateDrawableState(int extraSpace) {
// 调用 super 的方法时,extraSpace 参数加了 1,
// 实际上这个 1 就是 CHECKED_STATE_SET.length,即自定义的状态的个数
final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
if (isChecked()) {
// 如果当前状态是 checked,则把 super 返回的 drawableState 数组与 CHECKED_STATE_SET 数组合并,
// 合并的结果是在 super 返回的 drawableState 数组的基础上,往数组后面追加了 CHECKED_STATE_SET 数组的内容。
// 最后将数组返回。
mergeDrawableStates(drawableState, CHECKED_STATE_SET);
}
return drawableState;
}
至此,就完成了对 checked 状态的自定义,并能通过 setChecked(boolean checked)
方法来改变 checked 状态。总结一下自定义状态需要做的几件事:
- 提供一个改变 View 状态的方法,并在状态改变时调用
refreshDrawableState()
方法。 - 在
drawableStateChanged()
方法中,调用自己维护的 Drawable 的setState
方法,传入getDrawableState()
返回的值,从而更新 Drawable 的状态。 - 定义一个 int 数组,存放自定义的状态。
- 在
onCreateDrawableState(int extraSpace)
方法中,调用super.onCreateDrawableState(int)
,传入 extraSpace 加上上述 int 数组的长度,并将 super 返回的结果与上述 int 数组用mergeDrawableStates()
方法合并,最终返回合并后的结果。
实践
看完原理和系统控件的例子,我们也可以来自定义View的状态了。假设我们要实现这样一个需求:有一个 ListView,它的每个 Item 左侧有一个 CheckBox 可对整个列表进行多选操作。
这种情况可以使用自定义状态来完成,Item 是否被 checked 将影响 Drawable 的表现,以下以 Item 的最外层 View 为 LinearLayout 为例,自定义一个 CheckableLinearLayout。
public class CheckableLinearLayout extends LinearLayout implements Checkable {
private boolean mIsChecked = false;
private Drawable mCheckboxDrawable;
private static final int[] CHECKED_STATE_SET = {
android.R.attr.state_checked
};
public CheckableLinearLayout(Context context) {
super(context);
init();
}
public CheckableLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public CheckableLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mCheckboxDrawable = getResources().getDrawable(R.drawable.qmui_s_dialog_check_mark);
// 恢复 ViewGroup 的 draw 功能(默认关闭),使 onDraw 方法会被调用
setWillNotDraw(false);
}
@Override
protected void drawableStateChanged() {
super.drawableStateChanged();
// 将 getDrawableState 返回的状态数组设置给 mCheckboxDrawable,并触发重绘
if (mCheckboxDrawable != null) {
int[] drawableState = getDrawableState();
mCheckboxDrawable.setState(drawableState);
invalidate();
}
}
@Override
protected int[] onCreateDrawableState(int extraSpace) {
// 调用 super 时参数加上状态集的长度
final int[] drawableState = super.onCreateDrawableState(extraSpace + CHECKED_STATE_SET.length);
if (isChecked()) {
// 被 checked 状态下,在 super 返回的数组上追加自己的状态集合
mergeDrawableStates(drawableState, CHECKED_STATE_SET);
}
return drawableState;
}
@Override
public void setChecked(boolean checked) {
if (mIsChecked != checked) {
mIsChecked = checked;
// checked 状态改变时调用 refreshDrawableState()
refreshDrawableState();
}
}
@Override
public boolean isChecked() {
return mIsChecked;
}
@Override
public void toggle() {
setChecked(!isChecked());
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 将 mCheckboxDrawable 画到 Canvas 上
if (mCheckboxDrawable != null) {
int left = QMUIDisplayHelper.dpToPx(5);
mCheckboxDrawable.setBounds(left, getPaddingTop(),
left + mCheckboxDrawable.getIntrinsicWidth(),
getPaddingTop() + mCheckboxDrawable.getIntrinsicHeight());
mCheckboxDrawable.draw(canvas);
}
}
}
以下是 dialog_check_mark.xml
文件的内容,设置了 normal 情况和 checked 情况下的不同表现。
<!-- dialog_check_mark.xml -->
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/checkbox_checked" android:state_checked="true" />
<item android:drawable="@drawable/checkbox_normal" />
</selector>
到此,就完成了对 LinearLayout 加上 Checked 状态管理的功能,在被调用 setCheck(boolean checked) 方法时,Drawable 的表现会随之改变。
总结
我们从 Button 被 pressed 时的源码入手,分析了 Button(View)和 Drawable 如何关联起来,状态改变时如何通知 Drawable 改变。接着分析了系统控件 CompoundButton 的状态管理。最后自定义了一个包含 Checked 状态的 LinearLayout。