Android群英传:控件架构与自定义控件

1. Android控件架构

Android控件树
UI界面架构

Android中每一个Activity都包含一个Window对象。
Window对象通常由PhoneWindow。
PhoneWindow将一个 DecorView作为整个应用窗口的根View。
DecorView将要显示的内容呈现在PhoneWindow上。在显示上,它将屏幕分成两部分:TitleView和ContentView。
ContentView是一个id为content的FrameLayout,其中activity_main.xml就是设置到这个FrameLayout中的。


Android标准视图树

如图所示的第二层封装了一个LinerLayout作为ViewGroup,如果用户通过requestWindowFeature(Window.FEATURE_NO_TITLE)来设置全屏显示,视图树中的布局就只有content了,这也就解释了为什么调用requestWindowFeature()方法一定要在setContentView()方法之前调用。

在代码中,当程序在onCreate()方法中调用了setContentView()方法之后,AMS会调用onResume()方法,此时系统 才会把整个DecorView添加到PhoneWindow中,并让其显示出来,从而最终完成界面的绘制。

2. View的测量和绘制

View 的测量

现实生活中,绘制一个图形,我们必须要先知道绘制的大小和位置,这同样也适用于Android。

在Android中,绘制View之前,必须对View做测量,告知系统需要绘制的View的大小。这一过程在onMeasure()方法中来完成。
MeasureSpec类:用于View的测量。它是一个32位的int值,其中高2位为测量的模式,低30位为测量的大小。

EXACTLY:精确模式。当控件的大小是具体的值或者指定为match_parent时,系统使用的是EXACTLY模式。
AT_MOST:最大值模式。当控件的宽高属性指定为wrap_content时,控件的大小一般会随着控件内容或者子控件的变化而变化。此时控件的尺寸只要不超过父控件的大小即可。
UNSPECIFIED:不指定测量大小的模式。View想多大就多大,通常情况下,在绘制自定义View时才会使用。

View类默认的onMeasure()方法只支持EXACTLY模式,所以如果在自定义控件的时候不重写onMeasure()方法的话,就只能使用EXACTLY模式。此时,控件可以相应你指定的具体宽高值或者match_parent属性。而如果要让自定义View支持wrap_content属性,那么就必须重写onMeasure()方法来制定wrap_content时的大小。

View 的绘制

测量好一个View之后,就可以简单的通过重写onDraw()方法,在Canvas对象上绘制出所需要的图形。

绘制View的关键:Paint、Canvas

Canvas对象的创建需要传入一个bitmap对象,这个过程叫过装载画布,传入的bitmap对象用来存储所有绘制在Canvas上的像素信息。Canvas canvas = new Canvas(bitmap)。当用这种方式创建了Canvas对象之后,后面调用所有的Canvas.drawXXX方法都将发生在这个bitmap对象上。

       /*
         * Draw traversal performs several drawing steps which must be executed
         * in the appropriate order:
         *
         *      1. Draw the background
         *      2. If necessary, save the canvas' layers to prepare for fading
         *      3. Draw view's content
         *      4. Draw children
         *      5. If necessary, draw the fading edges and restore layers
         *      6. Draw decorations (scrollbars for instance)
         */

onDraw()方法的调用时机是在步骤3中,即绘制view的content。

3. ViewGroup的测量和绘制

ViewGroup具有管理其子View的职责。

ViewGroup的测量

如果ViewGroup的大小为wrap_content,就需要遍历所有的子View,以便获得所有子View的大小,从而来决定自己的大小。
其他模式下,ViewGroup会通过具体的指定值来设置自身大小。

ViewGroup的绘制

ViewGroup 通常情况下不需要绘制,因为其本身没有需要绘制的东西,但是ViewGroup会使用dispatchDraw()方法来绘制子View,过程是遍历所有子View并调用子View的绘制方法来完成工作。

4. 自定义控件的三种方式

  • 对现有控件进行扩展
  • 通过组合来实现新的控件
  • 重写View来实现全新的控件

在View中通常有以下一些比较重要的回调方法:

  • onFinishInflate():从XML加载组件后回调
  • onSizeChanged():组件大小改变时回调
  • onMeasure():回调该方法来进行测量
  • onLayout():回调该方法来确定显示的位置
  • onTouchEvent():监听到触摸事件时回调

下面对自定义控件实现的三种情况逐个展开来描述。

对现有控件的扩展

这是一个很重要的自定义View的方法,它可以在原生控件的基础上进行拓展,增加新的功能、修改显示的UI等。通常我们可以在onDraw()方法中对原生控件进行扩展。

    @Override
    protected void onDraw(Canvas canvas) {
        // 在回调父类方法前,实现自己的逻辑
        super.onDraw(canvas);
        // 在回调父类方法后,实现自己的逻辑
    }

比如说我们自定义一个TextView。在回调方法前或者回调方法后实现自己的逻辑,会有不同的效果。


在回调方法前实现绘制背景边框的逻辑

在回调方法后实现绘制背景边框的逻辑

可见,Android的绘制时一层层叠加的,有点类似于Photoshop中的图层。

通过组合来实现新的控件

创建复合控件可以很好地创建出具有重用功能的控件集合。这种方式通常需要一个合适的ViewGroup,再给它添加指定功能的控件,从而组合成新的复合控件。
通过组合来实现新的控件通常会包含以下几点内容:

  • 定义属性
  • 组合控件
  • 暴露接口给调用者
  • 实现接口回调

下面,我们就以实现一个topbar为例,来看一下通过组合来实现新控件的思路。

  1. 定义属性
    在res/values/目录下创建一个attrs.xml的属性文件。
res/values/attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="TopBar">
        <attr name="title" format="string" />
        <attr name="titleTextSize" format="dimension" />
        <attr name="titleTextColor" format="color" />
        <attr name="leftTextColor" format="color" />
        <attr name="leftBackground" format="reference|color" />
        <attr name="leftText" format="string" />
        <attr name="rightTextColor" format="color" />
        <attr name="rightBackground" format="reference|color" />
        <attr name="rightText" format="string" />
    </declare-styleable>

</resources>

declare-styleable标签用来声明使用自定义属性,attr用来声明具体的自定义属性。

我们可以通过下面的方式来获取XML布局文件中自定义的那些属性。

TypedArray ta = context.obtainStyledAttributes(attrs,  R.styleable.TopBar);

系统提供了TypeArray这样的数据结构来获取自定义属性集。

        // 从TypedArray中取出对应的值来为要设置的属性赋值
        mLeftTextColor = ta.getColor(R.styleable.TopBar_leftTextColor, 0);
        mLeftBackground = ta.getDrawable(R.styleable.TopBar_leftBackground);
        mLeftText = ta.getString(R.styleable.TopBar_leftText);

        mRightTextColor = ta.getColor(R.styleable.TopBar_rightTextColor, 0);
        mRightBackground = ta.getDrawable(R.styleable.TopBar_rightBackground);
        mRightText = ta.getString(R.styleable.TopBar_rightText);

        mTitleTextSize = ta.getDimension(R.styleable.TopBar_titleTextSize, 10);
        mTitleTextColor = ta.getColor(R.styleable.TopBar_titleTextColor, 0);
        mTitle = ta.getString(R.styleable.TopBar_title);

        // 获取完TypedArray的值后,一般要调用recyle方法来避免重新创建的时候的错误
        ta.recycle();

获取完所有的属性后,需要调用TypeArray的recycle()方法来完成资源的回收。

  1. 组合控件
    UI模板TopBar由三个控件组成,左边的点击按钮,右边的点击按钮和中间的的标题栏。通过动态添加控件,使用addView()方法将这三个控件添加到自定义的TopBar模板中,并给他们设置我们前面所获取到的具体属性值。比如,标题的颜色 、大小等。
        mLeftButton = new Button(context);
        mRightButton = new Button(context);
        mTitleView = new TextView(context);

        // 为创建的组件元素赋值
        // 值就来源于我们在引用的xml文件中给对应属性的赋值
        mLeftButton.setTextColor(mLeftTextColor);
        mLeftButton.setBackground(mLeftBackground);
        mLeftButton.setText(mLeftText);

        mRightButton.setTextColor(mRightTextColor);
        mRightButton.setBackground(mRightBackground);
        mRightButton.setText(mRightText);

        mTitleView.setText(mTitle);
        mTitleView.setTextColor(mTitleTextColor);
        mTitleView.setTextSize(mTitleTextSize);
        mTitleView.setGravity(Gravity.CENTER);

        // 为组件元素设置相应的布局元素
        mLeftParams = new LayoutParams(
                LayoutParams.WRAP_CONTENT,
                LayoutParams.MATCH_PARENT);
        mLeftParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT, TRUE);
        // 添加到ViewGroup
        addView(mLeftButton, mLeftParams);

        mRightParams = new LayoutParams(
                LayoutParams.WRAP_CONTENT,
                LayoutParams.MATCH_PARENT);
        mRightParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT, TRUE);
        addView(mRightButton, mRightParams);

        mTitlepParams = new LayoutParams(
                LayoutParams.WRAP_CONTENT,
                LayoutParams.MATCH_PARENT);
        mTitlepParams.addRule(RelativeLayout.CENTER_IN_PARENT, TRUE);
        addView(mTitleView, mTitlepParams);
  1. 暴露接口给调用者
      // 按钮的点击事件,不需要具体的实现,
        // 只需调用接口的方法,回调的时候,会有具体的实现
        mRightButton.setOnClickListener(new OnClickListener() {

            @Override
            public void onClick(View v) {
                mListener.rightClick();
            }
        });

        mLeftButton.setOnClickListener(new OnClickListener() {

            @Override
            public void onClick(View v) {
                mListener.leftClick();
            }
        });

    // 暴露一个方法给调用者来注册接口回调
    // 通过接口来获得回调者对接口方法的实现
    public void setOnTopbarClickListener(topbarClickListener mListener) {
        this.mListener = mListener;
    }

    // 接口对象,实现回调机制,在回调方法中
    // 通过映射的接口对象调用接口中的方法
    // 而不用去考虑如何实现,具体的实现由调用者去创建
    public interface topbarClickListener {
        // 左按钮点击事件
        void leftClick();
        // 右按钮点击事件
        void rightClick();
    }
  1. 实现接口回调

在自己的业务代码中可以实现基于接口的回调

        // 为topbar注册监听事件,传入定义的接口
        // 并以匿名类的方式实现接口内的方法
        mTopbar.setOnTopbarClickListener(
                new TopBar.topbarClickListener() {

                    @Override
                    public void rightClick() {
                        Toast.makeText(TopBarTest.this,
                                "right", Toast.LENGTH_SHORT)
                                .show();
                    }

                    @Override
                    public void leftClick() {
                        Toast.makeText(TopBarTest.this,
                                "left", Toast.LENGTH_SHORT)
                                .show();
                    }
                });

重写View来实现全新的控件

当安卓系统的原生控件无法满足我们的需求时,我们就需要完全创建一个新的自定义View来实现需要的功能。
创建一个自定义View的难点在于:绘制控件和实现交互
通常需要继承View类,并重写它的onMeasure() 、onDraw()等方法来实现绘制逻辑,同时重写onTouchEvent()等触控事件来实现交互逻辑。当然,也可以引入自定义属性来丰富自定义View的可定制性。

5. 自定义ViewGroup

ViewGroup存在的目的就是为了对其子View进行管理,为其子View添加显示、响应的规则。因此,自定义ViewGroup通常需要重写onMeasure()方法来对子View进行测量,重写onLayout()方法来确定子View的位置,重写onTouchEvent()方法来增加响应事件。

相关代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec,
            int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; ++i) {
            View childView = getChildAt(i);
            measureChild(childView,
                    widthMeasureSpec, heightMeasureSpec);
        }
    }

....

    @Override
    protected void onLayout(boolean changed,
            int l, int t, int r, int b) {
        int childCount = getChildCount();
        // 设置ViewGroup的高度
        MarginLayoutParams mlp = (MarginLayoutParams) getLayoutParams();
        mlp.height = mScreenHeight * childCount;
        setLayoutParams(mlp);
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() != View.GONE) {
                child.layout(l, i * mScreenHeight, r, (i + 1) * mScreenHeight);
            }
        }
    }

......

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastY = y;
                mStart = getScrollY();
                break;
            case MotionEvent.ACTION_MOVE:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                int dy = mLastY - y;
                if (getScrollY() < 0) {
                    dy = 0;
                }
                if (getScrollY() > getHeight() - mScreenHeight) {
                    dy = 0;
                }
                scrollBy(0, dy);
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:
                int dScrollY = checkAlignment();
                if (dScrollY > 0) {
                    if (dScrollY < mScreenHeight / 3) {
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, -dScrollY);
                    } else {
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, mScreenHeight - dScrollY);
                    }
                } else {
                    if (-dScrollY < mScreenHeight / 3) {
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, -dScrollY);
                    } else {
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, -mScreenHeight - dScrollY);
                    }
                }
                break;
            default:
                break;
        }
        invalidate();
        return true;
    }

实现滑动代码

    mScroller.startScroll(int startX, int startY, int dx, int dy);
    invalidate();

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            // 实现自己的滚动业务, 比如:scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            ......
            postInvalidate();
        }
    }

6. 事件分发机制

触摸事件:捕获触摸屏幕后产生的事件。当点击一个按钮时,通常会产生三个事件——按钮按下,这是事件一;如果不小心滑一点,这是事件二;当手抬起,这是事件三。

7. 事件拦截机制

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

推荐阅读更多精彩内容

  • 6、View的绘制 (1)当测量好一个View之后,我们就可以简单的重写 onDraw()方法,并在 Canvas...
    b5e7a6386c84阅读 1,875评论 0 3
  • 【Android 自定义View】 [TOC] 自定义View基础 接触到一个类,你不太了解他,如果贸然翻阅源码只...
    Rtia阅读 3,934评论 1 14
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,418评论 25 707
  • 今天朋友问我 你喜欢了那么久的他现在还有感觉吗 我想了很久 没有说那句差点脱口而出的没有 我说 我举个例子吧 你有...
    很怪阅读 269评论 0 0
  • 互相尊重才受人尊敬 家乡有个叔,亲情关系一般。前几年在外做生意,通过十几年的努力,在家里,北京都买了房子,每年回来...
    飘渺_d65f阅读 1,365评论 0 0