Android 各种坐标彻底明了

前言

前面的文章有系统详细的分析过Android三大流程:

  • Android 自定义View之Measure过程
  • Android 自定义View之Layout过程
  • Android 自定义View之Draw过程(上)

Measure过程确定了View的长、宽。Layout过程结合上一步的长、宽确定了View摆放位置,Draw过程结合上一步的摆放位置绘制出View,这是三者关系。本篇文章的重点是分析由Layout摆放位置引起的坐标相关知识分析。
通过本篇文章,你将了解到:

1、View坐标基础
2、常用的View获取坐标方法

1、View坐标基础

image.png

可以看出与传统的坐标系有所不同的是,屏幕的左上角作为坐标原点,X轴向右为正,Y轴向下为正。

上图分为四个层次,由内到外依次为:

  • 第一层为触摸点MotionEvent
  • 第二层为View,作为第三层的子布局
  • 第三层为Window/RootView(根View的尺寸与Window尺寸一致),作为第二层的父布局,也是作为整个ViewTree的根布局
  • 第四层为屏幕

触摸点(MotionEvent)坐标

上图红色部分为触摸点坐标相关的。

  • getX()、getY()获取的坐标是相对于其所在的View,相对于View的左上角
  • getRawX()、getRawY()获取的坐标是相对于整个屏幕的,相对于屏幕的左上角

对于同一个坐标点(getRawX()/getRawY()相同),在不同的View里,getX()、getY()可能不同:


image.png

黑色箭头是触摸点在View1里的getX()/getY()。
蓝色箭头是触摸点在View2里的getX()/getY()。

View 坐标

在Layout阶段会计算:mLeft、mTop、mRight、mBottom的值,也就是确定了该View的四个顶点距离父布局的左上角的偏移。这些值可正可负,以X轴为例,如果View的顶点在父布局左上角的右侧,即为正,否则为负。

这四个值用来确定View在父布局内的摆放位置。
接下来引入两个经典问题:
1、这四个值由什么决定的?
我们知道Measure过程计算了View长、宽,以LinearLayout为例,看看其如何摆放子布局的。

image.png

上图是纵向的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绘制范围

image.png

想想在不改变View四个顶点值的情况下,如何让View1向下移动。
你可能会说:设置View margin、设置ViewGroup padding等。上面有提到过这些值最终都是反馈到View的四个顶点的值上,该答案不符合题意。


image.png

换个角度想:分别从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);
image.png

如上图所示,绿色为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时(默认状态),效果图如下:


image.png

如上图,白色+红色部分为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

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

你可能感兴趣的:(Android 各种坐标彻底明了)