Android View 从测量布局到触摸反馈

自定义View

在Android开发中,自定义 View 最关键的有三个点:绘制、布局和触摸反馈(绘制内容多而简单,查看手册即用即学,这里不记录了)

布局

  • 测量阶段 :从上到下递归地调用每个 View 或者 ViewGroupmeasure()方法,测量他们的尺寸并计算它们的位置
  • 布局阶段:从上到下递归地调用每个 View 或者 ViewGrouplayout()方法,把测得的它们的尺寸和位置赋值给它们
测量阶段
  • measure() 方法被父View调用,在 measure()中做一些准备和优化工作后,调用 onMeasure()来进行实际的自我测量
  • View:ViewonMeasure() 中会计算出自己的尺寸然后保存
  • ViewGroup:ViewGrouponMeasure()中会调用所有子 Viewmeasure()让它们进行自我测量,并根据子 View 计算出的期望尺寸来计算出它们的实际尺寸和位置然后保存。同时,它也会根据子 View尺寸位置来计算出自己的尺寸然后保存
    注:保存会调用setMeasuredDimension(int,int),可通过getMeasuredWidth()getMeasuredHeight()获取保存的值
布局阶段
  • layout()方法被父 View 调用,在layout() 中它会保存父 View 传进来的自己的位置和尺寸,并且调用 onLayout() 来进行实际的内部布局
  • View:由于没有子 View,所以 ViewonLayout()什么也不做
  • ViewGroup:ViewGrouponLayout()中会调用自己的所有子 Viewlayout()方法,把它们的尺寸和位置传给它们(layout方法中会有参数,即实际让子View布局的尺寸参数),让它们完成自我的内部布局
    下面给出示意图:
    View布局过程.png

    ViewGroup布局过程.png

了解上面的测量布局过程之后,我们很容易地想到以下3种自定义布局过程

自定义布局过程

  • 重写 onMeasure() 来修改已有的 View 的尺寸(先调用super.onMeasure())
  • 重写 onMeasure() 来全新定制自定义 View 的尺寸(不用super.onMeasure()
  • 重写 onMeasure()onLayout() 来全新定制自定义 ViewGroup 的内部布局
第一种(修改已有尺寸)

1.重写 onMeasure()方法,并在里面调用 super.onMeasure(),触发原有的自我测量
2.super.onMeasure()的下面用getMeasuredWidth()getMeasuredHeight()来获取到之前的测量结果(宽和高),并加上自己的代码,根据测量结果计算出新的结果

getMeasuredWidth()getMeasuredHeight() 是测得的尺寸(即ViewonMeasure中调用setMeasureDimension()保存下来的数据)未必与之后父View调用layout()时传递进来的的尺寸参数相等,具体值是由父View决定!

3.使用setMeasureDimension()保存自定义测量的尺寸值

第二种(完全自己计算尺寸)

1.重写onMeasure(),计算尺寸(自己计算图、文字等等的长宽作加法运算)
2.把计算结果用resolveSize()修正一下
3.使用setMeasureDimension()保存自定义测量的尺寸值(也可以自己实现方法来满足父View的限制)

onMeasure()中有两个参数widthMeasureSpecheightMeasureSpec,它们是父View子View的测量尺寸的限制,来源于xml中以layout_打头的属性参数,这两个属性各自可以被MeasureSpec.getModeMeasureSpec.getSize拆分为ModeSIZE
Mode是限制的类型,包含3种:无限制UNSPECIFIED、限制上限AT_MOST、限制固定值EXACTLY
View为我们提供了resolveSize()方法用来便捷地对应这种限制


第三种(ViewGroup自定义测量以及布局过程)

1.重写onMeasure()来计算内部布局

  • 调用每个子View的measure, 让子View自我测量
  • 根据子View给出的尺寸,得到子View的位置,并保存它们的位置和尺寸
  • 根据子View的位置和尺寸计算出自己的尺寸并用setMeasuredDimension()保存

2.重写onLayout()来摆放子View

  • onMeasure()中,需要根据ViewGroup自身的 可用空间 结合子Viewlayout_打头的属性去测量每个子View的尺寸,并且用 MeasureSpec.makeMeasureSpec() 压缩成MeasureSpec(子View的可用空间)并保存
  • layout_打头的属性:这类属性是子View提供给父View测量时用的,在Java代码中可以通过view.getLayoutParam()获得。全新自定义ViewGroup时只有 layout_widthlayout_height ,开发者可以继续自定义这类属性例如layout_gravity,在自定义测量过程时将其考虑进去即可
  • 可用空间:对于ViewGroup本身来说最初的可用空间是onMeasure(int widthMeasureSpec, int heightMeasureSpec)的参数,而在往子View分配可用空间时,我们可以自己制定规则,可以将widthMeasureSpecheightMeasureSpec直接作为第一个子View可用空间,也可以自己做一些删减。当第一个子View的测量完成,继续测量第二个子View的时候,需要在widthMeasureSpec或者heightMeasureSpec基础上将第一个子View已用空间 减去,就得到了第二个子View的可用空间,以此类推
  • 可用空间判断方法(通用方式,有特例):

可用空间判断

首先根据子View在的xml布局声明的layout_widthlayout_height(lp.width、lp.height)分两种情况

  • MATCH_PARENT:
    • ViewGroup的限制为EXACTLYAT_MOST:由于子View依赖父View父View需要告诉子View其可用宽度,并且ViewGroup本身可用空间可以确定,所以应当给予子View的限制属性是一个具体值,mode为EXACTLY;此处给予 子View 的宽度是可用宽度,不管 父View 是 AT_MOST 还是EXACTLY,两种的原则都是这块空间子View随便用,so 子View的可用空间就是当前ViewGroup的初始可用空间(onMeasure()传来的widthMeasureSpec)减去已用空间
    • ViewGroup的限制为UNSPECIFIED子View依赖父View,但ViewGroup本身是UNSPECIFIED无限制大小的(这个地方说大小不是很合适,可用空间可能更佳),于是无法计算出子View的可用空间,所以直接将子Viewmode也写为UNSPECIFIED, 不限制其可用空间大小。size直接给0,因为在modeUNSPECIFIED情况下size无意义,实际在高版本Android有意义,这里不做解释
  • WARP_CONTENT:
    • ViewGroup的限制为EXACTLYAT_MOST:虽然子View是warp_content,子 View自我测量,但却不能直接将UNSPECIFIED子 View,因为wrap_content有个隐藏条件是不超过父View,so这里给子View的modeAT_MOST来限制它的最大尺寸;由于ViewGroupmodeEXACTLYAT_MOST,我们就可以得到可用空间大小,将其减去已用空间传给子View的可用空间即可(与match_parent时做法类似)
    • ViewGroup的限制为UNSPECIFIED:同上,子View需要自我测量,隐藏条件不超过父View应当被满足,但由于ViewGroup的限制为UNSPECIFIED,无法给出具体的可用空间大小,于是无法满足开发者在xml中给子Viewwrap_content属性,无奈只能传入UNSPECIFIED不对其进行限制,size依旧是0即可
  • 指定值(sp、dp):直接给子View指定一个值,ViewGroup什么都不用做,直接将值下发给子View的可用空间,modeEXACTLY即可

布局过程基本结束,接下来是触摸反馈过程

触摸反馈

触摸反馈的本质就是把一系列的触摸事件解读为对应的操作,比如按下、弹起、滑动等等,开发者再根据解读出来的操作进行反馈

对于触摸事件,有两点需要注意

  • 触摸事件 不相互独立,它们是成 序列 (成组)出现的
  • 每组事件由 DOWN 开头,由 UPCANCEL 结尾

大家都知道,自定义触摸反馈只需要重写ViewonTouchEvent(MotionEvent event)方法,event中包含了此次触摸事件的事件类型坐标等其他信息,当触摸事件不断被触发,onTouchEvent()就不断被调用,这是触摸反馈的核心。对于简单的 自定义触摸反馈,重写这个方法已经够了,但难免我们会遇上新的问题 —— 滑动冲突,只有当我们了解整个事件分发机制,才能够彻底解决滑动冲突。
在Android中,当一个触摸事件产生,MotionEvent 将从 Activity(Window)——>ViewGroup(多个)——> View


实际上学习Android的触摸事件分发机制就是学习以下3个组件的事件分发机制

  • Activity对触摸事件的分发机制
  • ViewGroup对触摸事件的分发机制
  • View对触摸事件的分发机制

在Android的事件分发机制中,传递的核心方法有3个:

  • dispatchTouchEvent():分发(传递)点击事件,当点击事件能够传递给当前View,该方法就会被调用
  • onInterceptTouchEvent():只存在于ViewGroup中,在dispatchTouchEvent()内部被调用,判断是否拦截了某个事件
  • onTouchEvent():处理点击事件,在dispatchTouchEvent()内部调用

这三个方法的解释不严谨,目的只是让大家现在有一个关系概念,而不是将每个细节都全理解,之后在源码中会有细节

先上一个粗略的图,大概对事件分发流程有个印象,方便看源码的时候理解

Activity的事件分发机制

当一个触摸事件发生时,事件最先传到 ActivitydispatchTouchEvent()

    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) 
            onUserInteraction();
        }
    //获取Activity的window对象(实现类PhoneWindow)并调用其方法 `superDispatchTouchEvent()`
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
    //当未被处理,直接调用Activity的 `onTouchEvent()` 处理
        return onTouchEvent(ev);
    }
    
    //空方法,当Activity在栈顶,触摸、按Home、back、menu都会触发该方法
    public void onUserInteraction() {
    }
    
    //Window
    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) 
        // mDecor = 顶层View(DecorView)的实例对象
        //DecorView是PhoneWindow的内部类,继承自FrameLayout,所以是一个ViewGroup
        return mDecor.superDispatchTouchEvent(event);
    }
    
    //DecorView
    public boolean superDispatchTouchEvent(MotionEvent event) {
        // 调用父类的方法 = ViewGroup的dispatchTouchEvent()
        // 即 将事件传递到ViewGroup去处理,详细看ViewGroup的事件分发机制
        return super.dispatchTouchEvent(event);
    }

  public boolean onTouchEvent(MotionEvent event) {
        // 当一个点击事件未被Activity下任何一个View接收 / 处理时
        // 应用场景:处理发生在Window边界外的触摸事
        if (mWindow.shouldCloseOnTouch(this, event)) {
            finish();
            return true;
        }
        return false;//即只有在点击事件在Window边界外才会返回true,一般情况都返回false
    }

    public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
    // 主要是对于处理边界外点击事件的判断:是否是DOWN事件,event的坐标是否在边界内等
    if (mCloseOnTouchOutside && event.getAction() == MotionEvent.ACTION_DOWN
            && isOutOfBounds(context, event) && peekDecorView() != null) {
        return true;
    }
    return false;
    // 返回true:说明事件在边界外,即 消费事件
    // 返回false:未消费(默认)
}

流程图
Activity事件分发流程图

红框中是重点!事件从这里下发到子View/View Group!

ViewGroup事件的分发机制

Android 5.0后,ViewGroup.dispatchTouchEvent()的源码发生了变化(更加复杂),但原理相同;
为了便于理解,采用Android 5.0前的版本

    public boolean dispatchTouchEvent(MotionEvent ev) { 

    ... // 仅贴出关键代码

        // ViewGroup每次事件分发时,都需调用onInterceptTouchEvent()询问是否拦截事件
            if (disallowIntercept || !onInterceptTouchEvent(ev)) {  

            // 判断值1:disallowIntercept = 是否禁用事件拦截的功能(默认是false),可通过调用requestDisallowInterceptTouchEvent()修改
            // 判断值2: !onInterceptTouchEvent(ev) = 对onInterceptTouchEvent()返回值取反
                    // a. 若在onInterceptTouchEvent()中返回false(即不拦截事件),就会让第二个值为true,从而进入到条件判断的内部
                    // b. 若在onInterceptTouchEvent()中返回true(即拦截事件),就会让第二个值为false,从而跳出了这个条件判断
                    // c. 关于onInterceptTouchEvent() ->>分析1

                ev.setAction(MotionEvent.ACTION_DOWN);  
                final int scrolledXInt = (int) scrolledXFloat;  
                final int scrolledYInt = (int) scrolledYFloat;  
                final View[] children = mChildren;  
                final int count = mChildrenCount;  

            // 通过for循环,遍历了当前ViewGroup下的所有子View
            for (int i = count - 1; i >= 0; i--) {  
                final View child = children[i];  
                if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE  
                        || child.getAnimation() != null) { //可见或正在执行动画 
                    child.getHitRect(frame);  

                    // 判断当前遍历的View是不是正在点击的View,从而找到当前被点击的View
                    // 若是,则进入条件判断内部
                    if (frame.contains(scrolledXInt, scrolledYInt)) {  
                        final float xc = scrolledXFloat - child.mLeft;  
                        final float yc = scrolledYFloat - child.mTop;  
                        ev.setLocation(xc, yc);  
                        child.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;  

                        // 条件判断的内部调用了该View的dispatchTouchEvent()
                        // 即 实现了点击事件从ViewGroup到子View的传递(具体请看下面的View事件分发机制)
                        if (child.dispatchTouchEvent(ev))  { 

                        mMotionTarget = child;  
                        return true; 
                        // 调用子View的dispatchTouchEvent后是有返回值的
                        // 若该控件可点击,那么点击时,dispatchTouchEvent的返回值必定是true,因此会导致条件判断成立
                        // 于是给ViewGroup的dispatchTouchEvent()直接返回了true,即直接跳出
                        // 即把ViewGroup的点击事件拦截掉

                                }  
                            }  
                        }  
                    }  
                }  
            }  
            boolean isUpOrCancel = (action == MotionEvent.ACTION_UP) ||  
                    (action == MotionEvent.ACTION_CANCEL);  
            if (isUpOrCancel) {  
        //如果是ACTION_UP或者ACTION_CANCEL, 将disallowIntercept设置为默认的false   
        //假如我们调用了requestDisallowInterceptTouchEvent()方法来设置disallowIntercept为true   
        //当我们抬起手指或者取消Touch事件的时候要将disallowIntercept重置为false   
        //所以说上面的disallowIntercept默认在我们每次ACTION_DOWN的时候都是false   
                mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;  
            }  
            final View target = mMotionTarget;  

        // 若点击的是空白处(即无任何View接收事件) / 拦截事件(手动复写onInterceptTouchEvent(),从而让其返回true)
        if (target == null) {  
            ev.setLocation(xf, yf);  
            if ((mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) {  
                ev.setAction(MotionEvent.ACTION_CANCEL);  
                mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;  
            }  
            
            return super.dispatchTouchEvent(ev);
            // 调用ViewGroup父类的dispatchTouchEvent(),即View.dispatchTouchEvent()
            // 因此会执行ViewGroup的onTouch() ->> onTouchEvent() ->> performClick() ->> onClick(),即自己处理该事件,事件不会往下传递(具体请参考View事件的分发机制中的View.dispatchTouchEvent())
            // 此处需与上面区别:子View的dispatchTouchEvent()
        } 

        ... 

}

  public boolean onInterceptTouchEvent(MotionEvent ev) {  
      //返回true = 拦截,即事件停止往下传递(需手动设置,即复写onInterceptTouchEvent(),从而让其返回true)
      //返回false = 不拦截(默认)
    return false;

  } 

到这里为止,你会发现没有任何一个地方消费了(使用了)触摸事件,因为目前为止所有的过程都只是在下发(往下传递MotionEvent),而真正要处理事件,是等到View(真的View,不是ViewGroup)在 dispatchTouchEvent() 中去做操作,在这里才会 真正dispatchTouchEvent()onTouchEvent() 产生交集,接着往下看

View事件的分发机制

  public boolean dispatchTouchEvent(MotionEvent event) {  
  // 只有以下3个条件都为真,dispatchTouchEvent()才返回true;否则执行onTouchEvent()
  //     1. mOnTouchListener != null
  //     2. (mViewFlags & ENABLED_MASK) == ENABLED
  //     3. mOnTouchListener.onTouch(this, event)
        if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&  
                mOnTouchListener.onTouch(this, event)) {  
  // 假如onTouch返回了true,直接返回True
onTouchEvent()处理
            return true;  
        } 
  // 假如onTouch没返回true,交给此View的
        return onTouchEvent(event);  
  }

  // 在这里为mOnTouchListener赋值
  public void setOnTouchListener(OnTouchListener l) { 
    // 即只要我们给控件注册了Touch事件,mOnTouchListener就一定被赋值(不为空)
    mOnTouchListener = l;  
} 

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