Android clipChildren 使用与疑难点解析

前言

ClipXX 系列:

Android clipChildren 使用与疑难点解析
Android clipToPadding 使用与疑难点解析

我们知道,通常来说当子布局的边界处在父布局之外的时候,此时子布局超出的部分是无法显示的。想要显示超出的部分,通过设置clipChildren 属性可以解决此问题,本篇将会探究clipChildren 属性的使用及其原理。
通过本篇文章,你将了解到:

1、clipChildren 使用场景
2、clipChildren 如何使用
3、clipChildren 设置在父布局为什么无效
4、子布局超出部分如何响应点击事件
5、总结

1、clipChildren 使用场景

先来看图:


图.jpeg

如上图所示,底部有三个按钮,它们是包裹在同一个父布局里的,整体布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <LinearLayout
        android:background="@color/red"
        android:layout_gravity="bottom"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="200px">

        <Button
            android:id="@+id/btn1"
            android:layout_marginLeft="50px"
            android:text="button 1"
            android:layout_width="0px"
            android:layout_weight="1"
            android:background="@color/green"
            android:layout_height="match_parent">
        </Button>

        <Button
            android:id="@+id/btn2"
            android:layout_marginLeft="50px"
            android:text="button 2"
            android:layout_width="0px"
            android:layout_weight="1"
            android:background="@color/green"
            android:layout_height="match_parent">
        </Button>

        <Button
            android:id="@+id/btn3"
            android:layout_marginHorizontal="50px"
            android:text="button 3"
            android:layout_width="0px"
            android:layout_weight="1"
            android:background="@color/green"
            android:layout_height="match_parent">
        </Button>
    </LinearLayout>

</FrameLayout>

简化结构层次如下:


image.png

通过布局文件并结合上图可知:

1、三个Button是放在一个横向的LinearLayout里的。
2、LinearLayout(父布局)背景色为红色。
3、Button高度与父布局高度一致。

现在想要一个效果:

点击对应的Button,使其往上移动,凸显点击效果。

效果如下:


tt0.top-475243.gif

然而,并未达到预期效果。
此时,轮到clipChildren 属性出马了。

2、clipChildren 如何使用

clipChildren 顾名思义:裁剪子布局,使得其不超过父布局展示,该属性是ViewGroup里的属性。
有两种设置方式:动态设置和xml设置。

动态设置

#ViewGroup.java
    public void setClipChildren(boolean clipChildren) {
        boolean previousValue = (mGroupFlags & FLAG_CLIP_CHILDREN) == FLAG_CLIP_CHILDREN;
        if (clipChildren != previousValue) {
            //标记不一样,需要设置
            //设置FLAG_CLIP_CHILDREN 属性
            setBooleanFlag(FLAG_CLIP_CHILDREN, clipChildren);
            for (int i = 0; i < mChildrenCount; ++i) {
                //遍历子布局,限定绘制边界
                View child = getChildAt(i);
                if (child.mRenderNode != null) {
                    child.mRenderNode.setClipToBounds(clipChildren);
                }
            }
            invalidate(true);
        }
    }

xml设置

android:clipChildren="true"
android:clipChildren="false"

默认值

#ViewGroup.java
    private void initViewGroup() {
        ...
        mGroupFlags |= FLAG_CLIP_CHILDREN;
        mGroupFlags |= FLAG_CLIP_TO_PADDING;
        ...
    }

clipChildren 属性值默认为true。
综合以上几点可知,clipChildren值默认为true,也就是默认裁剪子布局,因此为了达到上述效果,在上面布局文件里的FrameLayout布局下添加如下代码即可:

android:clipChildren="false"

效果如下:

tt0.top-114249.gif

这正是开头想要的效果。当然,借助于clipChildren 特性,我们还可以对Button做动画效果,比如点击Button后,让其移动到ViewGroup之外。

3、clipChildren 设置在父布局为什么无效

网上大部分的文章在分析clipChildren 时只会提到之前的两点:使用场景与如何使用。
思考一个问题:

既然是限制子布局的展示,而Button的父布局是LinearLayout,为啥不在LinearLayout 节点下设置android:clipChildren="false",而要在爷爷布局FrameLayout节点下设置呢?

当然一开始按照正常的逻辑是设置在父布局节点下的,然而却没什么效果,接下来分析一下为啥没效果。
想要知道为什么不生效,就需要找到clipChildren属性值在哪被使用了。我们知道自定义View的三个过程:测量、摆放、绘制。因为涉及到展示,因此猜测是在绘制过程被裁剪了,而裁剪展示区域我们就想到了Canvas的裁剪。
通过前面的文章分析的绘制过程,直接定位到如下代码(软件绘制为例):

#View.java
    boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
        //没有开启硬件加速
        if (!drawingWithRenderNode) {
            //parentFlags 为父布局的flag
            //若是父布局需要裁剪子布局,也就是说clipChildren==true
            //那么就需要对canvas进行裁剪
            if ((parentFlags & ViewGroup.FLAG_CLIP_CHILDREN) != 0 && cache == null) {
                //软件绘制offsetForScroll==true
                if (offsetForScroll) {
                    //裁剪canvas与子布局大小一致
                    //sx,sy 是scroll值,没设置scroll时sx,sy都为0
                    canvas.clipRect(sx, sy, sx + getWidth(), sy + getHeight());
                } else {
                    ...
                }
            }
            ...
        }
    }

由此可知:

1、若是clipChildren==true,那么将会裁剪子布局,方式是通过裁剪Canvas。
2、若是clipChildren==false,那么将不会裁剪Canvas。

在父布局节点设置

爷爷布局:FrameLayout
父布局:LinearLayout
子布局:Button

image.png

当在父布局(LinearLayout)节点里设置clipChildren==false时,因为爷爷布局(FrameLayout)没有设置该属性,因此还是会限定其子布局,也就是图上红色部分(父布局LinearLayout)的绘制范围为:canvas=[0,1080,800,1280]
此时,即使(父布局LinearLayout)没对子布局(Button)进行限制(clipChildren==false),但是因为canvas已经在上个步骤被限制了,因此子布局(Button)展示的范围依然在:canvas=[0,1080,800,1280]。
最后呈现的效果即是子布局不能超出父布局展示。

在爷爷布局节点设置

image.png

当在爷爷布局(FrameLayout)节点里设置clipChildren==false时,爷爷布局不会限制其子布局(红色部分父布局LinearLayout),因此父布局(LinearLayout)绘制范围为:canvas=[0,0,800,1280]。
而当父布局(LinearLayout)限制子布局(Button)的展示范围时,Canvas进行clip操作,取交集,得出子布局(Button)绘制范围为:canvas=[100,980,300,1280],超出的部分(980-800)即为多出的展示区域。
最后呈现的效果即是子布局能够超出父布局展示。

一言蔽之:

想要超出父布局展示,只需要子布局canvas绘制范围超出父布局边界即可。

注:上述以软件绘制为例阐述的,爷爷布局,父布局,子布局都是同一个Canvas对象,而开启硬件加速后Canvas不是同一对象。具体的差别请查看之前的文章。

4、子布局超出部分如何响应点击事件

在第三步已经解决了如何超出父布局展示,现在又引入了新的问题:

子布局超出的部分如何响应点击事件?

老样子,既然点击无法响应,那么先看看影响点击响应的因素是啥。
还是要从事件分发开始说起,如果点击的坐标落在目标View之内(此处是子布局Button),那么它是能够响应的。
现在问题就转为了:

点击事件分发到哪一层了?

虽然父布局(LinearLayout)的Canvas改变了,但是其顶点(left、top、right、bottom)坐标也没变,因此父布局也无法收到点击事件。可以确认的是,点击事件肯定是分发给了爷爷布局的。
问题又转为了:

爷爷布局的事件如何传递给父布局?
换句话说,父布局如何扩大点击区域?

这让我们想到了TouchDelegate---一个专注扩大目标View点击区域的类。
找到解决方案了,看代码:

        //expand touch area
        llParent.post(() -> {
            Rect hitRect = new Rect();
            //获取父布局当前有效可点击区域
            llParent.getHitRect(hitRect);
            //扩大父布局点击区域
            hitRect.top += translationY;
            TouchDelegate touchDelegate = new TouchDelegate(hitRect, llParent);
            llParent.setClickable(true);
            ViewParent viewParent = llParent.getParent();
            if (viewParent instanceof ViewGroup) {
                ((ViewGroup) viewParent).setClickable(true);
                //在爷爷布局里拦截事件分发
                ((ViewGroup) viewParent).setTouchDelegate(touchDelegate);
            }
        });

以上代码目的是:

扩大父布局响应的点击区域,在爷爷布局里将事件分发给父布局。

然而运行这段代码,子布局(Button)依然无法响应点击,于是到TouchDelegate 寻找答案。
当爷爷布局发现之前设置了TouchDelegate,于是就会调用TouchDelegate.onTouchEvent(xx)检测:

#TouchDelegate.java
    public boolean onTouchEvent(@NonNull MotionEvent event) {
        int x = (int)event.getX();
        int y = (int)event.getY();
        boolean sendToDelegate = false;
        boolean hit = true;
        boolean handled = false;
        ...
        if (sendToDelegate) {
            if (hit) {
                //命中,则将MotionEvent 坐标移动到目标View的中心
                event.setLocation(mDelegateView.getWidth() / 2, mDelegateView.getHeight() / 2);
            } else {
                ...
            }
            handled = mDelegateView.dispatchTouchEvent(event);
        }
        return handled;
    }

找到问题根源了:虽然父布局(FrameLayout)收到了点击事件,但是这个坐标是它的中心点,而中心点不一定落在其子布局(Button)里,因此Button是无法收到点击事件的。
还好,TouchDelegate是public类型的,于是我们可以重写TouchDelegate

#SimpleTouchDelegate.java
    public boolean onTouchEvent(@NonNull MotionEvent event) {
        int x = (int)event.getX();
        int y = (int)event.getY();
        boolean sendToDelegate = false;
        boolean hit = true;
        boolean handled = false;
        ...
        if (sendToDelegate) {
            if (hit) {
              //命中后不做任何操作
            } else {
                ...
            }
            handled = mDelegateView.dispatchTouchEvent(event);
        }
        return handled;
    }

此时父布局(LinearLayout)可以收到点击事件了,但问题又来了:

父布局如何将事件传递给子布局,并且还要区分三个不同的Button。

父布局收到点击事件后调用会流转到onTouchEvent(xx)里,因此需要在该方法内做文章。试想,现在父布局的onTouchEvent(xx)方法可以拿到点击的坐标,那么只需要判断该点是否落在各个子布局(Button)内即可。当然不能单纯依赖Button的四个顶点坐标,还需要配合View.getLocationOnScreen(xx)使用。
因此需要重写onTouchEvent(xx):

public class ClipViewGroup extends LinearLayout {
    public ClipViewGroup(Context context) {
        super(context);
    }

    public ClipViewGroup(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //获取坐标相对屏幕的位置
        float rawX = event.getRawX();
        float rawY = event.getRawY();
        View child;
        //检测坐标是否落在对应的子布局内
        if ((child = checkChildTouch(rawX, rawY)) != null) {
            //若是则将坐标值修改为子布局中心点
            event.setLocation(child.getWidth() / 2, child.getHeight() / 2);
            //分发事件给子布局
            return child.dispatchTouchEvent(event);
        }
        return super.onTouchEvent(event);
    }

    private View checkChildTouch(float x, float y) {
        int outLocation[] = new int[2];
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == VISIBLE) {
                //获取View 在屏幕上的可见坐标
                child.getLocationOnScreen(outLocation);
                //点击坐标是否落在View 的可见区域,若是则将事件分发给它
                boolean hit = (x >= outLocation[0] && y > outLocation[1]
                        && x <= outLocation[0] + child.getWidth() && y <= outLocation[1] + child.getHeight());
                if (hit)
                    return child;
            }
        }
        return null;
    }
}

使用ClipViewGroup 替代父布局(LinearLayout)。
最后看看效果:


tt0.top-473084.gif

注:为了更显眼地表示点击区域,此处是将子布局往上全部移动超出父布局

5、总结

虽然 clipChildren属性比较简单,使用范围也比较局限,但是想要真正弄明白它需要结合测量、摆放、绘制流程源码分析,若是还想要对点击区域做文章,那么还需要对事件分发有一定的了解。
当然,这些基础知识在前面的文章中已有系统的分析过,若是看过之前的文章,那么理解clipChildren 更简单了。

本文基于Android 10。
完整代码演示 若是有帮助,给github 点个赞呗~

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

持续更新中,和我一起步步为营系统、深入学习Android/Java

1、Android各种Context的前世今生
2、Android DecorView 一窥全貌(上)
3、Android DecorView 一窥全貌(下)
4、Window/WindowManager 不可不知之事
5、View Measure/Layout/Draw 真明白了
6、Android事件分发全套服务
7、Android invalidate/postInvalidate/requestLayout 彻底厘清
8、Android Window 如何确定大小/onMeasure()多次执行原因
9、Android事件驱动Handler-Message-Looper解析
10、Android 键盘一招搞定
11、Android 各种坐标彻底明了
12、Android Activity/Window/View 的background
13、Android IPC 之Service 还可以这么理解
14、Android IPC 之Binder基础
15、Android IPC 之Binder应用
16、Android IPC 之AIDL应用(上)
17、Android IPC 之AIDL应用(下)
18、Android IPC 之Messenger 原理及应用
19、Android IPC 之获取服务(IBinder)
20、Android 存储基础
21、Android 10、11 存储完全适配(上)
22、Android 10、11 存储完全适配(下)
23、Java 并发系列不再疑惑

推荐阅读更多精彩内容