从 Android 源码角度分析 View 的状态改变如何影响 Drawable 的表现

在 Android 开发中,View 和 Drawable 之间关系十分紧密,例如我们经常用 Drawable 作为一个 View 的背景。View 常常会有状态的改变,例如被按下、例如禁用,而不同的状态下 Drawable 也常有不同的表现。今天要探索的问题是 View 的状态改变是如何影响 Drawable 的表现的。
以下将简单介绍我们平时如何在 View 上使用 Drawable,做到在不同状态下表现不一样。接着分析系统源码探索其中的原理。最后以系统的控件和自定义控件 2 个例子来验证和实践在 View 中自定义状态的做法。

注:

  1. 本文的源码分析基于 Android API Level 23,并省略掉部分与本文关系不大的代码。
  2. 在代码中加入了个人对源码的理解,以注释形式呈现。
  3. 本文最后的 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 的状态。简单总结一下:

  1. View 的 pressed 状态改变会调用 setPressed 方法。
  2. setPressed 方法会调用 refreshDrawableState 方法。
  3. refreshDrawableState 中会调用 drawableStateChanged,去更新 drawable 的状态,其中就包括 backgroundDrawable。
  4. drawableStateChanged 方法中,通过 getDrawableState 方法得到 DrawableState 并设置为 backgroundDrawable,那么 Drawable 就会自己更新状态并通知 View 重新绘制。
  5. getDrawableState 方法是通过 onCreateDrawableState(int extraSpace) 方法来得到 DrawableState 的。
    所以,View 的 backgroundDrawable 状态其实是由 onCreateDrawableState(int extraSpace) 方法决定的,而 setPressed 方法只是作为状态改变的整个流程的起点。

看完了源码,我们应该可以解决上面提出的几个问题:

  1. Button(View)的不同状态是如何和 Drawable 关联起来的?
    View 在状态改变时调用 refreshDrawableState 去刷新 Drawable 的状态,而这些状态最终由 onCreateDrawableState(int extraSpace) 方法返回。

  2. 除了上面说的 pressed 和 enabled 状态,我们可以设置的状态还有哪些?
    View#onCreateDrawableState(int extraSpace) 方法,其中检查了 pressed、enabled、focused、selected、window_focused、activated、hardware_accelerated、hovered、drag_can_accept、drag_hovered 状态。所以,对于 View,我们可以控制这些状态。

  3. 如果系统提供的状态不够用,我们能否自己定义状态?
    当然可以,不可以的话我怎么会在这篇文章提出这个问题?其实 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 状态。总结一下自定义状态需要做的几件事:

  1. 提供一个改变 View 状态的方法,并在状态改变时调用 refreshDrawableState() 方法。
  2. drawableStateChanged() 方法中,调用自己维护的 Drawable 的 setState 方法,传入 getDrawableState() 返回的值,从而更新 Drawable 的状态。
  3. 定义一个 int 数组,存放自定义的状态。
  4. 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。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352

推荐阅读更多精彩内容