位置与尺寸
1.关于控件的位置,有left,translationX 和 X三种,他们都和 RenderNode 有关,计算公式为
1 | public float getX() { |
为什么要在View中搞出这么多变量呢?
如果只变更 left,则只是左边界的变化,View 的视觉宽度将发生变化;TranslationX 则能够产生平移的效果,它同时附加在 left 和 right 上。
实际上同时移动 left 和right 相同的距离,也能达到平移的效果,类似于ViewCompat.offsetLeftAndRight 造成的效果,但这个与 TranslationX 实现的平移效果有所区别。
实际上同时移动 left 和right 相同的距离,也能达到平移的效果,类似于 ViewCompat.offsetLeftAndRight 造成的效果,但这个与 TranslationX 实现的平移效果有所区别。
前者只是非常机械的渲染效果,只要碰上 requestLayout 方法就复原了,后者的平移在重新布局后仍然有效。而且只变更left不会考虑一些类似于居中的要求,控件在变化过程中往往就“失真’’了。
如果确实要改变控件的宽度,应该从 LayoutParams.width 入手,修改此值,并调用方法,控件会重新进行测量。
2.再来说说 mScrollX/mScrollY ,它们只影响内容的绘制,不会影响背景 Drawable 的绘制,实际是通过对内容区域加偏移造成,最终的绘制效果是偏移后的内容区域与原本区域的。
1 | public void invalidate(Rect dirty) { |
3.此外还可以使用动画来改变控件位置,属性动画就不提了,值得一提的是补间动画,默认的四种补间动画采用Transformation里的矩阵来操作渲染结果,但并不真的改变属性,如果要达成属性动画的效果,可以利用下面的方法
1 | public void setFillBefore(boolean fillBefore); |
最后评价下各种操作的性能
- left 虽然是RenderNode实现,但需要 CPU 更新 displayList,调用和递归多(56/1022),GPU监视上蓝色部分长
- translationX 基本无更新 displayList 的调用和递归存在,蓝线几乎没有,耗时极少
- LayoutParams 除非测量事件,耗时验证
- offset ,耗时极少
- animation 更新 displayList极多
- animtor 不更新 displayList,快
layout_* 机制与 LayoutParams
layout*机制通过内部类 LayoutParams 中定义以 layout为前缀的属性供子控件使用,从而控制子控件的行为,这些属性并不为布局类自己所使用。以AppBarLayout为例
1 | <android.support.design.widget.AppBarLayout> |
Toolbar 本身并没有 layoutscrollFlags 属性,该属性定义在类 AppBarLayout.LayoutParams 中,加前缀 layout表示这是一个父布局定义而子控件使用的属性。虽然这个属性是由容器来读取 XML文件进行解析和实例化的。
最广泛最著名的属性来自于 ViewGroup.LayoutParams 中的属性:layout_width 与 layout_height, 是容器用来约束子控件的宽度和高度的,ViewGroup.MarginLayoutParams 增加了6个margin属性,如果采用这种 LayoutParams,就能够给子控件设置 layout__margin属性,容器会在布局时利用这个属性。
自定义 LayoutParams 需要覆盖以下4个方法,这些方法是为子控件生成 LayoutParams 对象所必须的
1 |
|
实际上布局每添加一个控件,都会采用 generateDefaultLayoutParams() 方法来生成一个 LayoutParams 对象应用在子控件上(见addView方法)。
如果默认产生的 LayoutParams 对象不能通过 checkLayoutParams 方法的检查,则使用 generateLayoutParams 方法来产生 LayoutParams 对象。
因此自定义LayoutParams 时要故意使得 generateDefaultLayoutParams 产生的对象通不过检查,而使用 generateLayoutParams(AttributeSet attrs)产生的 LayoutParams 对象。
约定 LayoutParams 中解析的属性值均要以 layout_ 开头。
尺寸测量
控件的尺寸测量实际比想象的复杂,我们先看系统的默认实现。对于容器而言,除了测量自身,还要考虑测量子控件,这一步往往是调用 measureChild 方法来完成的
1 | protected void measureChild(View child, int parentWidthMeasureSpec, |
在真正的测量方法前,将对复合尺寸进行重新计算,以求得实际的控件尺寸。其方法如下
1 | //spec 的复合尺寸,padding 是控件的留白 |
控件最终的复合尺寸受到两个因素的影响,即控件自定义的 LayoutParams 和容器的 MeasureSpec。其结果如下表所示
Spec\Params | MATCH_PARENT | WRAP_CONTENT | 具体数值(NUM) |
---|---|---|---|
EXACTLY | EXACTLY + size | AT_MOST + size | EXACTLY+ NUM |
AT_MOST | AT_MOST + size | AT_MOST + size | EXACTLY + NUM |
UNSPECIFIED | UNSPECIFIED + size | UNSPECIFIED + size | EXACTLY + NUM |
实际默认的容器 SpecMode 往往是 EXACTLY,因此关注第一行。子控件设置为 match_parent 和 具体数值时,都将获得 exactly模式,不过尺寸有所差别而已,而 warp_content 的情况较为复杂,其模式为 at_most,尺寸想达到容器尺寸,需要进一步处理。
而后就是控件自身的测量方法
1 | protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
默认情况下的最小尺寸为背景 drawable 的尺寸 ,要在所得的尺寸和这个最小尺寸间做出决定,即 getDefaultSize 方法
1 | //size 是最小尺寸,measureSpec 是复合尺寸 |
getDefaultSize 方法将根据复合尺寸的模式得到最终在最小尺寸和复合尺寸间做出选择。
specMode | UNSPECIFIED | AT_MOST | EXACTLY |
---|---|---|---|
最终尺寸 | size | specSize | specSize |
即仅在容器指定尺寸模式为 UNSPECIFIED 的情况下使用最小尺寸,其余情况可以默认这一步不存在。
这里有一个问题如果控件采用 warp_content,那么到这一步的 specSize 实际是容器的尺寸。这说明默认情况下处理 warp_content 是不合理,自定义控件直接继承View时要注意。
最后就是为测量尺寸 mMeasuredWidth 和 mMeasuredHeight 赋值。
1 | private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) { |
resolveSizeAndState
刚才说了默认的 measureChild 在碰到子控件设置了 warp_content 时会取容器尺寸的问题,除非子控件不采用默认的自测量方式。这个问题可以用 resolveSizeAndState 方法修复。
1 | /** |
specMode | AT_MOST | EXACTLY | UNSPECIFIED |
---|---|---|---|
最终尺寸 | Min(specSize, size) | specSize | size |
resolveSizeAndState 方法和 getDefaultSize 方法不同之处在于如何解析 AT_MOST,这是关键之处。
即将默认的测量方法改为
1 |
|
自定义测量方法
如果想达到一些其它效果,如在 DrawLayout 中要求内容控件占据容器,实际上在 LayoutParams 上的设置已无任何意义,测量时完全不予考虑,这时就要求抛弃默认的测量方法,去自定义测量方法。
自定义测量方法y要依赖控件的自测量方法,其核心在于在合适的时机改造复合尺寸。
例如要求一个控件占据容器是最容易的情况,此时完全不用考虑容器Spec模式和 LayoutParams 参数。
1 | mContent.measure( |
在控件内部首先对传入的尺寸进行改造
1 |
|
最要命的情况是要同时处理容器Spec模式和 LayoutParams 参数共九种情况,不过好在遵循 getChildMeasureSpec 方法的套路就行。
布局机制
布局机制比测量机制要简单地多,但应该注意到布局时会掉用测量过程,此外有时子控件如何布局与 LayoutParams 参数有关,布局时要考虑 padding 和 margin 的影响,获取控件宽度不应该用 LayoutParams.width (可能为负值) 等等。
触摸机制
触摸机制首先从 Activity 开始,而后有ViewRootImpl传递下去
1 | public boolean dispatchTouchEvent(MotionEvent ev) { |
默认的处理流程是一个来回:一半是事件分发(dispatchTouchEvent方法),一半是事件处理(onTouch 方法)。其中容器 ViewGroup 和控件 View 的分发方法是不一致的,前者会使用 onInterceptTouchEvent 方法拦截事件,且默认情况下不拦截,逐层交给子控件分发,直到根View;控件 View 的分发会调用事件处理,且会逐级上溯,直到返回 Activity 。
假设布局层级是 TopLayout,BottomLayout 和 CircleImage,则默认情况下流程是
1 | //1.向下传递拦截事件 |
默认情况下整个流程是不进行拦截的处理的,所以流程会从顶到根走一个来回,但仅能处理 Action_down 的情况,后续的其它动作如Action_Move 等将停留在Activity 中。
如果在 BottomLayout 中截断事件,这里有几种情况。只拦截不处理和默认情况一样。同时拦截和处理结果如下
1 | TopLayout onInterceptTouchEvent: false ACTION_DOWNBottomLayout onInterceptTouchEvent: true ACTION_DOWN //拦截方法只能执行一次BottomLayout onTouchEvent: true ACTION_DOWNBottomLayout dispatchTouchEvent: true ACTION_DOWNTopLayout dispatchTouchEvent: true ACTION_DOWNMainActivity dispatchTouchEvent: true ACTION_DOWNTopLayout onInterceptTouchEvent: false ACTION_MOVE //父布局的拦截方法倒是能多次执行BottomLayout onTouchEvent: true ACTION_MOVEBottomLayout dispatchTouchEvent: true ACTION_MOVETopLayout dispatchTouchEvent: true ACTION_MOVE //消灭这一行,禁止 TopLayout 拦截MainActivity dispatchTouchEvent: true ACTION_MOVETopLayout onInterceptTouchEvent: false ACTION_UPBottomLayout onTouchEvent: true ACTION_UPBottomLayout dispatchTouchEvent: true ACTION_UPTopLayout dispatchTouchEvent: true ACTION_UPMainActivity dispatchTouchEvent: true ACTION_UP |
即流程到 BottomLayout 这一层被截断了,如果要 TopLayout 不进行拦截,可以使用下列方法
1 | getParent().requestDisallowInterceptTouchEvent(true); |
如果不拦截,只处理结果如下
1 | TopLayout onInterceptTouchEvent: false 0BottomLayout onInterceptTouchEvent: false 0CircleImage onTouchEvent: false 0CircleImage dispatchTouchEvent: false 0BottomLayout onTouchEvent: true 0BottomLayout dispatchTouchEvent: true 0TopLayout dispatchTouchEvent: true 0MainActivity dispatchTouchEvent: true 0TopLayout onInterceptTouchEvent: false 2BottomLayout onTouchEvent: true 2BottomLayout dispatchTouchEvent: true 2TopLayout dispatchTouchEvent: true 2MainActivity dispatchTouchEvent: true 2TopLayout onInterceptTouchEvent: false 1BottomLayout onTouchEvent: true 1BottomLayout dispatchTouchEvent: true 1TopLayout dispatchTouchEvent: true 1MainActivity dispatchTouchEvent: true 1 |
区别是下面的子控件还能得到一次处理的机会, 否则即便子控件设置了处理方法,也不会得到执行机会。当然如果子控件不处理,那么以后依然将会被屏蔽。
监听器是事件处理的特殊情况,如果配置了监听器,等同于设置了事件处理。如果容器和子控件都配置了监听器,那么按照事件处理的顺序是子控件优先截断控制权;容器如果想夺回控制权,只能使用拦截方法进行拦截。
修改触摸机制
修改触摸机制的核心是确定那个控件在哪种情况下能够进行拦截和消费事件,这样使得不同的控件都有机会处理触摸事件。
先说下可以着手的三个地方
- onInterceptTouchEvent 方法:返回 true,则事件不再向下分发,即屏蔽了子控件,使得子控件设置的点击事件失效,因此要谨慎使用。此外如果容器消费了事件,该方法只执行一次。
- onTouchEvent 方法:返回 true,则此后的处理流程就到这一层。
- dispatchTouchEvent 方法:返回 true,此时不管*onTouchEvent 方法如何,都能形成闭合流程。
例如我们要求在 TopLayout 中拦截左右滑动,在 BottomLayout 中拦截 上下滑动,同时也要保证 CircleImage 能够响应点击事件。
三个控件都要能拦截事件,这就要求它们各自精确拦截自己的那一部分。
首先给 CircleImage 设置一个监听器,此时事件是到 CircleImage 这里得到处理的,但TopLayout 和 BottomLayout 还有拦截事件的机会。
再来改造 TopLayout ,前提是不能拦截 ACTION_DOWN 和 ACTION_UP,这样就屏蔽了子控件的点击监听,这两个动作里只能做一些初始化和清理的工作。而后在 ACTION_MOVE 中进行拦截
1 | public boolean onInterceptTouchEvent(MotionEvent ev) { |
还有在 onTouchEvent 方法里也不能在 ACTION_DOWN 和 ACTION_UP 里进行事件的处理,这样也会导致子控件的点击事件失效。只能在 ACTION_MOVE 进行相同条件下的处理,这样才能构成闭环处理流程。
最后是 BottomLayout ,和TopLayout 类似,只需要改一下拦截和处理的条件。
这里三个控件都要获得拦截事件的机会,底层的控件尤其重要,它要求响应点击事件的特性使得父控件无法拦截和处理 ACTION_DOWN 和 ACTION_UP。父控件之间更是不得不小心翼翼,避免彼此之间的冲突。
实践:点击控件带扩张效果
要达成这样的效果,且要求保留子控件的点击事件,实际上不需要进行事件的拦截,只需要在 dispatchTouchEvent 中添加一个动画即可。
1 |
|
这里要注意的是如果容器即其子控件不设置点击事件监听器,那么可以不加第一行,因为此时dispatchTouchEvent只执行一次,在ACTION_DOWN之后就被屏蔽了;
然而这过于理想了,一旦有一个子控件拦截的事件,将造成多个动画同时执行,View的scale参数将紊乱。因此为了安全,需要添加第一行。
MotionEvent和手势
动作包含动作码和坐标集合两个部分,前者表示是按下还是离开等;此外多触摸屏幕还可以响应多个手指,其中只能有一个起作用的,即常见的 mActivePointerId。
1 | final int action = MotionEventCompat.getActionMasked(event); |
手势库:Sensey
滚动机制(Scroller)
scrollTo 方法就能够使得控件内容移位,其原理是改变参数 mScrollX/mScrollY ,这两个数值会在绘制的时候移动绘制区域,这是滚动机制的基础。
scrollTo 方法的问题在于滚动花费的时间太短,Scroller 通过拉长这个时间带来平滑的视觉效果,具体做法是将整个过程拆分成若干的步进过程,逐步改变 mScrollX/mScrollY 的数值。
Scroller 类本身是一个纯属性类,只需要传入滑动时间,起始值等描述一次滑动过程的必要参数。但它并不能直接滑动控件,想要滑动控件需要与具体控件配合驱动步进计算。Scroller类的手动更新方法是
1 | //1.启动滚动过程 |
这里达成真实的滚动效果是靠改变控件 mScrollX/mScrollY 位置来产生的,完全可以考改变其它属性来达成其它动画效果。
为了兼容性,可以使用类ScrollerCompat代替实现,该类还提供了fling 和 springback 两种滑动方式。
绘制机制与动画机制
View 的实际绘制区域与布局区域是不一致的,它与 mScrollX/mScrollY 有关
1 | public void getDrawingRect(Rect outRect) { |
在 onDraw 方法上添加附加效果是一种常用的手段。
View 可以执行三种动画
1.补间动画
1 | public void startAnimation(Animation animation) |
2.状态转移动画
1 | public void setStateListAnimator(StateListAnimator stateListAnimator); |
3.属性动画
1 | public ViewPropertyAnimator animate(); |
EdgeEffect
EdgeEffect 是用来绘制边界阴影的,它的实质是画一个弧顶过某边的圆,同时截取弧顶部分。默认情况下绘制的顶边。
通过 onPull, onAbsorb 方法,EdgeEffect 可以控制弧顶漏出的比例,这是纯属性设置的方法,必须刷新绘制才能生效。
1 | public void onPull(float deltaDistance, float displacement) |
通过 onRelease 方法可以产生回弹,这与Scroll 自我驱动更新的原理是一样的
1 |
|
在实际的使用中,应该给每一个边设置一个 EdgeEffectCompat , 这需要移位,旋转等操作 。以右边为例
1 | canvas.rotate(90); //画布旋转90度,意味着坐标轴也偏移了90度, |
左边
1 | canvas.rotate(270); //画布旋转270度,此时y轴向右 |
下边
1 | canvas.rotate(180); |
SurfaceView
SurfaceView 在一个独立的线程中进行绘制,不在主线程中不会占用主线程资源,一方面可以实现复杂而高效的UI,另一方面又不会导致用户输入得不到及时响应。由于应用程序的主线程除了要绘制UI之外,还需要及时地响应用户输入,否则的话,系统就会认为应用程序没有响应了,因此就会弹出一个ANR对话框出来。对于一些游戏画面就不适合在应用程序的主线程中进行绘制。这时候就可以使用 SurfaceView 。一个Surface绘图示例如下
1 | SurfaceView surfaceView = (SurfaceView) findViewById(R.id.surfaceview); |
SurfaceView 有两个子类GLSurfaceView
和VideoView
,可见视频图像亦是靠Surface
机制来渲染。SurfaceView
是一个专门负责绘制Surface
内容的View
对象,它最大的优点是使用单独线程完成绘制工作。
通常每个窗口背后对应一个Surface
,不同Surface
的Z序列不同,在底层将这些Surface
的内容合成;Surface
可以理解成绘制内容数据。
绘制工作主要是由SurfaceHolder来完成的。SurfaceHolder负责与 Surface 内容数据打交道,可以控制比如Surface
尺寸,格式,像素并监听Surface
的变化。
一般而言,SurfaceView 进入前台时 Surface 内容被创建,转入后台则被销毁;这是Android系统内存管理的特征;在 Surface 内容被创建后可以通过 holder.lockCanvas(Rect dirty); 获得一个 canvas 对象,对 dirty 矩形区域内进行绘制;此时脏区内的 Surface 内容被锁定是线程安全的;使用 unlockCanvasAndPost(canvas) 方法释放锁定后,脏区内容将被系统渲染展示到屏幕上。但数据内容没有被清除;因此如果更改了绘制区域后,将绘制先前内容。因此使用holder.lockCanvas(new Rect(0, 0, 0, 0)) 锁定并取消锁定的方法清除内容。
ViewTreeObserver
ViewTreeObserver 接口可以响应视图树的变化,其中最重要的一个接口就是 OnPreDrawListener
1 | public interface OnPreDrawListener { |
这个接口之所以重要在于它的执行时机非常好,处在布局之后,绘制之前,各项参数(如尺寸和位置)均已确定,正是使用各种 trick 的方法。
此外一个使用较多的接口是 OnGlobalLayoutListener ,执行时间在控件发生 visibility 的变化。
各种子控件
Space 与 ViewStub
Space 是一个典型的占位控件,它始终处于 INVISIBLE 状态,参与测量和布局,但 draw 方法为空。
ViewStub 则处于 GONE 状态,这意味着它没有尺寸, draw 方法也为空。 直到执行 inflate() 方法载入其它控件
1 | ViewStub stub = (ViewStub) findViewById(R.id.stub); |
ViewStub 会将子布局加载到它的父布局中去,新的子布局将继承原 ID,同时让父布局移除自己。
FrameLayout
FrameLayout 测量时以子控件的最大高度/宽度作为自己的尺寸。它的 LayoutParams 多了一个参数,并利用这个参数来对齐
1 | public int gravity = UNSPECIFIED_GRAVITY; |
在布局方法中根据子控件的 gravity 参数来对齐子控件
1 | final int layoutDirection = getLayoutDirection(); |
absoluteGravity 决定了子控件的 left 位置,verticalGravity 决定了子控件的 top 位置,计算如下
1 | switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) { |
ViewAnimator
ViewAnimator 是 FrameLayout 的子类,在切换布局时能够执行动画。这种效果是重写 addView 和 removeView 方法来实现的,其子类有 TextSwitcher,ImageSwitcher 和 ViewFlipper。
ViewFlipper 能够自动播放,这是通过在 onAttachedToWindow 方法中启动线程循环达成的,有意思的是它还使用广播接收器处理了屏幕熄灭和用户划开屏幕保护锁的广播
1 |
|
HorizontalScrollView
HorizontalScrollView 是一个继承 FrameLayout 的水平滚动控件,只应有一个子控件,它的滚动被称之为覆盖滚动(OverScroll
),需要处理容器自身的尺寸小于它所容纳的子控件的尺寸的情况。
覆盖滚动有三种类型
- OVER_SCROLL_NEVER 子控件永远被束缚在容器内
- OVER_SCROLL_ALWAYS 子控件永远能够滚动出容器外
- OVER_SCROLL_IF_CONTENT_SCROLLS 只有子控件大于容器才能发生
1.测量方法:容器只处理水平滚动,在垂直方向上倾向于将子控件完全扩展,且以自身容器宽度作为子控件宽度。如果不想这么做,可以设置参数 FillViewport 为 false。
2.拦截方法: 容器根据控件是否处于拖动状态决定是否拦截,只有处在MotionEvent.ACTION_MOVE 状态,滑动距离足够且触摸点在子控件内可以拦截此事件。发生拦截后,在事件处理方法中完成覆盖滑动。这一过程本质上是通过改变 mScrollX/mScrollY 的位置来实现的。
a.是否能够进行覆盖滑动,由滑动方式和滑动范围决定。
1 | int range = getScrollRange();//计算滑动范围int |
b.覆盖滑动范围为子控件宽度与容器宽度的差值,计算如下
1 | Math.max(0,child.getWidth() - (getWidth() - mPaddingLeft - mPaddingRight); |
滑动范围为子控件的宽度-容器的内容宽度,而且如果此值为负,就无法滑动。因此只有子控件的宽度大于容器宽度才能产生滑动,这也是判断子控件能否滚动的依据。
c.覆盖滑动的行为由 overScrollBy 完成
1 | //mOverscrollDistance 指的是触发边界效应的距离 |
该方法采用 Scroller 来进行滑动,会修正新的 ScrollX 和 ScrollY 值,。
4.翻页滑动和全页滑动
翻页滑动(pageScroll),点击 (shift+)space可以调用,这里的页宽就是容器的宽度,如果向左翻页
则左边界为Math.max(0,getScrollX() - width)
,右边界为right = mTempRect.left + width
。
全页滑动(fullScroll 包括 arrowScroll),点击 (alt+)Pad key可以调用,如果向左翻页,左边界为0,右边界为width
,即容器宽度。如果向右翻页,左右边界确定如下
1 | View view = getChildAt(0); |