前言
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。
在父布局节点设置
image.png爷爷布局:FrameLayout
父布局:LinearLayout
子布局:Button
当在父布局(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 点个赞呗~
网友评论