阅读 517

Android clipChildren 使用与疑难点解析

我们知道,通常来说当子布局的边界处在父布局之外的时候,此时子布局超出的部分是无法显示的。想要显示超出的部分,通过设置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 更简单了。


作者:小鱼人爱编程
链接:https://juejin.cn/post/7020641054600724488


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐