美文网首页Android开发Android开发经验谈Android开发
Android clipChildren 使用与疑难点解析

Android clipChildren 使用与疑难点解析

作者: 小鱼人爱编程 | 来源:发表于2021-11-15 13:17 被阅读0次

    前言

    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

    相关文章

      网友评论

        本文标题:Android clipChildren 使用与疑难点解析

        本文链接:https://www.haomeiwen.com/subject/ajhqtrtx.html