前言
前面的文章有系统详细的分析过Android三大流程:
Measure过程确定了View的长、宽。Layout过程结合上一步的长、宽确定了View摆放位置,Draw过程结合上一步的摆放位置绘制出View,这是三者关系。本篇文章的重点是分析由Layout摆放位置引起的坐标相关知识分析。
通过本篇文章,你将了解到:
1、View坐标基础
2、常用的View获取坐标方法
1、View坐标基础
可以看出与传统的坐标系有所不同的是,屏幕的左上角作为坐标原点,X轴向右为正,Y轴向下为正。
上图分为四个层次,由内到外依次为:
- 第一层为触摸点MotionEvent
- 第二层为View,作为第三层的子布局
- 第三层为Window/RootView(根View的尺寸与Window尺寸一致),作为第二层的父布局,也是作为整个ViewTree的根布局
- 第四层为屏幕
触摸点(MotionEvent)坐标
上图红色部分为触摸点坐标相关的。
- getX()、getY()获取的坐标是相对于其所在的View,相对于View的左上角
- getRawX()、getRawY()获取的坐标是相对于整个屏幕的,相对于屏幕的左上角
对于同一个坐标点(getRawX()/getRawY()相同),在不同的View里,getX()、getY()可能不同:
黑色箭头是触摸点在View1里的getX()/getY()。
蓝色箭头是触摸点在View2里的getX()/getY()。
View 坐标
在Layout阶段会计算:mLeft、mTop、mRight、mBottom的值,也就是确定了该View的四个顶点距离父布局的左上角的偏移。这些值可正可负,以X轴为例,如果View的顶点在父布局左上角的右侧,即为正,否则为负。
这四个值用来确定View在父布局内的摆放位置。
接下来引入两个经典问题:
1、这四个值由什么决定的?
我们知道Measure过程计算了View长、宽,以LinearLayout为例,看看其如何摆放子布局的。
上图是纵向的LinearLayout,其内部有两个子布局:View1、View2。
LinearLayout布局过程如下:
1、检测到LinearLayout设置了纵向mPaddingTop,此时View1.mTop = mPaddingTop。
2、View1的底部距离父布局为:mBottom=mTop+View1.height。
3、View2设置了margin(距离View1),View2的顶部距离父布局为:mTop=View1.mbottom+margin。
由上可知:
View的四个顶点的值是相对于其直接父布局的左上角来计算的,用来指示该View在其布局内的位置。
会受到父布局设置的padding,View 设置的margin、gravity、View本身尺寸等影响。也就是说当我们设置这些值时候,最终反馈到四个顶点的值上。
获取与设置四个顶点的值方法:
//获取
{
getLeft();
getTop();
getRight();
getBottom();
}
//设置
{
layout(left, top, right, bottom);
}
既然知道了四个顶点的值会影响View绘制的范围,也就是Canvas绘制范围,那么引出下面问题:
2、还有什么能够影响Canvas绘制范围
想想在不改变View四个顶点值的情况下,如何让View1向下移动。
你可能会说:设置View margin、设置ViewGroup padding等。上面有提到过这些值最终都是反馈到View的四个顶点的值上,该答案不符合题意。
换个角度想:分别从View和ViewGroup考虑。
从View的角度想:
#View.java
public void setTranslationY(float translationY) {...}
public void setTranslationX(float translationX) {...}
public void setX(float x){...};
public void setY(float y){...};
从ViewGroup角度想:
#View.java
public void scrollTo(int x, int y) {...}
public void scrollBy(int x, int y) {...}
public void setScrollX(int value) {...}
public void setScrollY(int value){...}
以纵轴(Y)的移动为例,分别看看以上方法是如何工作的。
先看View角度的:
setTranslationY(xx)
#View.java
public void setTranslationY(float translationY) {
//translationY 为正,往下移动,为负往上移动
//设置的值与当前值不一样则认为是有效设置
if (translationY != getTranslationY()) {
invalidateViewProperty(true, false);
//记录到renderNode里
mRenderNode.setTranslationY(translationY);
//触发invalidate->三大流程
invalidateViewProperty(false, true);
invalidateParentIfNeededAndWasQuickRejected();
notifySubtreeAccessibilityStateChangedIfNeeded();
}
}
看到这可能比较疑惑,虽然是设置到了RenderNode里,什么时候拿出这个值以及什么地方使用呢?
1、对于支持硬件加速来说,RenderNode变化了,相应的Canvas绘制范围也会变化。
2、对于不支持硬件加速来说,将会在View.draw(x1,x2,x3)方法里获取matrix,从而移动Canvas。
#View.java
boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
...
//设置了setTranslationY()后,实际上就是给Canvas设置了偏移
//因此View的matrix不再是单位矩阵
final boolean childHasIdentityMatrix = hasIdentityMatrix();
if (!childHasIdentityMatrix && !drawingWithRenderNode) {
canvas.translate(-transX, -transY);
//matrix操作,getMatrix() 会影响canvas坐标
canvas.concat(getMatrix());
canvas.translate(transX, transY);
}
...
}
可以看出View.setTranslationY()最终是移动了Canvas的坐标(平移),最终使得View移动了。
setY(xx)
先看View.getY(xx)
#View.java
public float getY() {
return mTop + getTranslationY();
}
明显的,getY()获取的就是mTop顶点的值+translationY的值,因此:
#View.java
public void setY(float y) {
setTranslationY(y - mTop);
}
实际上也就是设置了translationY的值。
再看ViewGroup角度的:
scrollTo(xx)
#View.java
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
//记录到成员变量
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
来看看mScrollX、mScrollY 什么时候使用的:
一、对于开启了硬件加速的View来说:
在该方法里启用:
#View.java
public RenderNode updateDisplayListIfDirty() {
...
try {
if (layerType == LAYER_TYPE_SOFTWARE) {
...
//软件绘制缓存
} else {
computeScroll();
//mScrollX,mScrollY 在此处使用
//将Canvas进行平移,注意此处是取反
canvas.translate(-mScrollX, -mScrollY);
mPrivateFlags |= PFLAG_DRAWN | PFLAG_DRAWING_CACHE_VALID;
mPrivateFlags &= ~PFLAG_DIRTY_MASK;
...
//分发Draw事件
}
} finally {
//结束录制
renderNode.endRecording();
setDisplayListProperties(renderNode);
}
...
}
二、对于关闭了硬件加速的View来说:
#View.java
boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
...
if (!drawingWithRenderNode) {
computeScroll();
//记录值
sx = mScrollX;
sy = mScrollY;
}
final boolean drawingWithDrawingCache = cache != null && !drawingWithRenderNode;
final boolean offsetForScroll = cache == null && !drawingWithRenderNode;
...
if (offsetForScroll) {
//走软件绘制分支
//平移Canvas,此处取反了
canvas.translate(mLeft - sx, mTop - sy);
} else {
...
}
...
}
结合上述对mScrollX,mScrollY引用,我们知道这两个值的设置最终影响到了Canvas坐标,并且进行了取负。
而我们知道Canvas.translate(float dx, float dy),对于纵向来说,当dy>0,Canvas向下平移,产生的效果是View向下移动了。
当mScrollY>0时,因为取反的缘故,因此Canvas向上平移,产生的效果是View向上移动了。
scrollBy(xx), setScrollY(xx) 内部实际上就是调用了scrollTo(xx),此处不再分析。
以上分别从View、ViewGroup角度分析了如何移动View。
它们的异同点:
1、都是通过平移Canvas坐标达到移动的效果
2、都是没有改变View四个顶点的坐标值
3、只是View.setTranslationY(100) 使得View沿着纵轴向下移动。而ViewGroup. scrollTo(0, 100),使得其子布局沿着纵轴向上移动
以上分析我们知道要移动View,本质上就是对其Canvas进行操作,而Canvas没有提供外部直接操作的方法,因此通过曲线救国,总结出移动View的方法:
1、改变View四个顶点的值
2、设置translationX(xx)、translationY(xx)
3、设置Scroll(待移动View的父布局)
4、View动画
2、常用的View获取坐标方法
了解了View坐标基础,再来看看由此引出的其它属性,如:
获取View的可见区域,获取View在屏幕上的位置等。
涉及到方法如下:
public void getLocationInWindow(@Size(2) int[] outLocation){...}
public void getLocationOnScreen(@Size(2) int[] outLocation){...}
public final boolean getGlobalVisibleRect(Rect r){...}
public final boolean getLocalVisibleRect(Rect r){...}
public void getHitRect(Rect outRect){...}
public void getDrawingRect(Rect outRect){...}
public void getWindowVisibleDisplayFrame(Rect outRect);
如上图所示,绿色为ViewGroup1,它作为Window的RootView,此时的Window尺寸大小与RootView大小一致。
蓝色为ViewGroup2,作为ViewGroup1子布局,同时作为ViewGroup2的父布局。
红色+白色为View,作为ViewGroup2的子布局。
分两种情况说明:
a、子布局可以超过父布局展示
设置View不被父布局clip,如上图所示,为简单起见,只以水平方向为例分析。
前提条件
View 长宽:
width:600
height:200
View 四个顶点:
mLeft = -200
mTop = 200
mRight = 400
mBottom = 400;
结合上图来看,View不仅超出了父布局,也超出了Window,白色部分为超过Window的区域,是看不到的。
分别来看看各个方法获取的坐标值:
getLocationInWindow
View距离Window左上角坐标,因为View超出了Window,因此获取的坐标为
[x,y]=[-100,400]
getLocationOnScreen
View距离屏幕左上角的坐标,在getLocationInWindow 基础上加上Window 的偏移。
[x,y]=[-100, 400] + [200, 100] = [100, 500]
getGlobalVisibleRect
View的可见部分在Window里的区域,View的真实区域:白色 + 红色 部分,只是白色部分超出了Window,不会展示,可见区域是红色部分。
rect=[0, 400, 500, 600]
注意:此处是相对于Window左上角计算的区域,而非屏幕。网上很多文章分析是针对Activity的Window,由于此时Window大小与屏幕尺寸一致,因此会误认为getGlobalVisibleRect是相对屏幕左上角计算的。
关于Window/RootView尺寸如何测量请移步:
Android Window 如何确定大小/onMeasure()多次执行原因
getLocalVisibleRect
View可见部分相对于自身的区域,也就是说自身的哪些区域可见。在getGlobalVisibleRect基础上,不断查找。
rect=[100, 0, 600, 200]
getHitRect
获取View有效的点击区域,以四个顶点为基础,考虑matrix,得出结果如下:
rect=[-200,200,400, 400]
getDrawingRect
获取View的绘制区域,以四个顶点为基础,考虑scroll值,得出结果如下:
rect=[0,0,600,200]
getWindowVisibleDisplayFrame
Window 的可见区域,一般用来计算导航栏、状态栏、键盘高度:
具体可移步:
Android 软键盘一招搞定(原理篇)
再来看另外一个情况:
b、子布局不可以超过父布局展示
当子布局被父布局clip时(默认状态),效果图如下:
如上图,白色+红色部分为View的区域,只是白色部分由于超过了其父布局:ViewGroup2,因此不会展示。
与 a 场景相比,显然是View的可见部分发生变化,因此我们重点关注:
getGlobalVisibleRect 与getLocalVisibleRect 的变化:
getGlobalVisibleRect
可以看出,红色部分为可见区域,那么该区域相对Window左上角的距离为:
rect=[100,400,500,600]
getLocalVisibleRect
红色部分在View自身里的区域
rect=[200,0,600,200]
关于View可见与可视区域
想要隐藏一个View,通过设置View.setVisibility(VISIBLE);
判断一个View是否隐藏:getVisibility() == VISIBLE
显然这个判断不是那么的完善,试想一下:隐藏与显示仅仅只是View的状态而已,如果其父布局状态为GONE,此时View状态为VISIBLE,判断出来View是可见的,但是实际上却是看不到。
解决方法是:从View开始,不断向上寻找父布局,查找其状态是否是VISIBLE,若不是则认为该View不可见。当然,SDK里已经提供该方法。
#View.java
public boolean isShown() {...}
再想另一个问题,isShown()判断的仅仅是状态是否可见。当该View超出了Window、或者屏幕,纵然isShown()==true,对用户来说依然是不可见的,此时就需要使用getGlobalVisibleRect(xx)判断了。
小结
以上方法源码都比较简单,都是以四个顶点为基础,有些方法里会考虑matrix变化(如setTranslationX()、setTranslationY() 导致变化)、scroll值等的影响。
通过上面的图示再结合源码对比,相信大家对上面的方法不再有疑惑。
本文基于Android 10.0