回顾和结论
上篇在写Flutter事件分发时,漏了个重要的点HitTestBehavior,这期补上
问题 以及解决方案
日常开发中是不是碰到过Container的空白区域点击不响应。
先看个demo,源码链接
// 默认情况下,Row的空白处不响应点击事件,有两个方法
// 1. 设置behavior为HitTestBehavior.opaque或者translucent
// 2. Container设置背景色,任意背景色都可以
GestureDetector(
behavior: HitTestBehavior.deferToChild,
child: Container(
height: 50,
color: Colors.transparent,
padding: EdgeInsets.only(left: 5, right: 5),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
Text(
"left text",
style: TextStyle(fontSize: 20),
),
Text(
"right text",
style: TextStyle(fontSize: 20),
),
],
),
),
onTap: () {
print("click");
},
),
运行后截图如下
正常情况下,只有两个文案能响应点击事件,空白处是不响应的
解决方法有两种
- GestureDetector的behavior设置为opaque或者translucent才行,
- Container设置任意背景色
思考,这是为什么呢?下面会从源码角度结合demo一起解释
源码解析
先看源码的注释
/// How to behave during hit tests.
enum HitTestBehavior {
/// 事件是否处理取决于自己的孩子
deferToChild,
/// Opaque targets can be hit by hit tests, causing them to both receive
/// events within their bounds and prevent targets visually behind them from
/// also receiving events.
/// 这里保留英文的注释,如果用中文翻译下是自己可以命中hitTest,又在视觉上阻止位于其后方的目标也接收事件。
opaque,
/// Translucent targets both receive events within their bounds and permit
/// targets visually behind them to also receive events.
/// 半透明目标既可以接收其范围内的事件,也可以在视觉上允许目标后面的目标也接收事件。
translucent,
}
emmm... opaque和translucent的机翻没看懂。。还是看源码
下面是看了源码之后的结论
类型 | 含义 |
---|---|
deferToChild | 事件是否消费完全取决于他的儿子 |
opaque | position在自己的范围内,子类的HitTestSlef只要不被重写,自身就会消费事件 |
translucent | position在自己的范围内,都会消费事件 |
补充一点,注释里说的opaque阻止位于其后方的目标也接收事件,translucent可以在视觉上允许目标后面的目标也接收事件,文章的最后会有demo解释。
接下来看源码,从GestureDetector开始
GestureDetector.build方法
@override
Widget build(BuildContext context) {
/// 省略其他不重要的代码..
/// 核心还是RawGestureDetector,具体可与看开篇里链接的文章
return RawGestureDetector(
gestures: gestures,
behavior: behavior,
excludeFromSemantics: excludeFromSemantics,
child: child,
);
}
接着看RawGestureDetector
RawGestureDetectorState.build方法
核心还是Listener
@override
Widget build(BuildContext context) {
// 核心代码在Listener里,跟着参数behavior走
Widget result = Listener(
onPointerDown: _handlePointerDown,
behavior: widget.behavior ?? _defaultBehavior,
child: widget.child,
);
//这个忽略
if (!widget.excludeFromSemantics)
result = _GestureSemantics(
child: result,
assignSemantics: _updateSemanticsForRenderObject,
);
return result;
}
链路有点长,看源码引用的顺序 Listener->_PointerListener->RenderPointerListener->RenderProxyBoxWithHitTestBehavior
RenderProxyBoxWithHitTestBehavior源码,代码很少,但逻辑就是在这里了
/// A RenderProxyBox subclass that allows you to customize the
/// hit-testing behavior.
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {
@override
bool hitTest(BoxHitTestResult result, { Offset position }) {
bool hitTarget = false;
// position在自己范围内
if (size.contains(position)) {
// 判断孩子和自己是否命中,这是普通逻辑,没什么特别的
hitTarget =
hitTestChildren(result, position: position) || hitTestSelf(position);
// 如果是HitTestBehavior.translucent,强行将自己命中hittest,参与事件消费的队列中,这里hitTestChildren和hitTestSelf的结果就不重要了
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
//如果Behavior是opaque,且没有被子类重写,那就是返回true,也即是参与到事件消费的队列中
@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
}
结论就是,当我们用GestureDetector监听事件时,最后都会走到RenderProxyBoxWithHitTestBehavior里,只要behavio是opaque或translucent都会将自己加入到事件消费的队列。
到这里可以回答第一个问题了
GestureDetector的behavior设置为opaque或者translucent,空白处可以响应点击事件
behavior默认是HitTestBehavior.deferToChild,当点击空白处时
hitTestChildren(result, position: position)返回false
hitTestSelf(hitTestSelf) 也返回false
当设置为opaque或translucent的任意一个,都能解决。
引申思考,为什么点击Text就能响应呢?当作作业吧~
接下来看第二个问题,为什么Container设置任意背景色就可以响应点击事件? 答案还是在源码里
先看Container源码
@override
Widget build(BuildContext context) {
Widget current = child;
// 省略其他代码...
if (color != null)
current = ColoredBox(color: color, child: current);
return current;
// 省略其他代码...
}
Container其实是个StateLessWidget,它本身并没有RenderObject对应,可以理解为是个配置项,真实渲染的render是其他配置引进的,比如color对应的ColoredBox
ColoredBox->_RenderColoredBox
// 一眼就看到了,强制设置为opaque了,答案和第一个问题一样了
class _RenderColoredBox extends RenderProxyBoxWithHitTestBehavior {
_RenderColoredBox({@required Color color})
: _color = color,
//看这里
super(behavior: HitTestBehavior.opaque);
}
至此,第二个问题也回答完了
Container设置任意背景色,可以响应点击事件
因为设置了color后,返回的widget里包含了ColoredBox,ColoredBox对应的RenderObject是_RenderColoredBox,_RenderColoredBox继承自RenderProxyBoxWithHitTestBehavior并强制指定了behavior是HitTestBehavior.opaque。
translucent和opaque的区别
最后再补充一个demo,解释translucent和opaque的区别
return Stack(
children: <Widget>[
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(300.0, 300.0)),
child: DecoratedBox(decoration: BoxDecoration(color: Colors.red)),
),
onPointerDown: (event) => print("first child"),
),
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(200.0, 200.0)),
child: Center(child: Text("左上角200*200范围内-空白区域点击")),
),
onPointerDown: (event) => print("second child"),
//放开此行注释后,单词点击 first ,second都会响应,HitTestBehavior.opaque是不行的
// behavior: HitTestBehavior.translucent,
)
],
);
当点击左上角,非文本区域时,只会响应first child,当把这句代码注释打开
// behavior: HitTestBehavior.translucent,
会先输出second child,再输出first child。原因还是在RenderProxyBoxWithHitTestBehavior的hitTest方法
@override
bool hitTest(BoxHitTestResult result, { Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
//如果是translucent,尽快自身加入了命中测试队列,但返回的结果还是false,
//但如果是opaque,孩子不重写hitTestSelf,那hitTarget肯定就是true了
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
对于Stack来说,对应的Render是RenderStack
RenderStack hitTestChildren
@override
bool hitTestChildren(BoxHitTestResult result, { Offset position }) {
return defaultHitTestChildren(result, position: position);
}
最终走到了RenderBoxContainerDefaultsMixin的defaultHitTestChildren
bool defaultHitTestChildren(BoxHitTestResult result, { Offset position }) {
// The x, y parameters have the top left of the node's box as the origin.
// 从最上层的孩子开始遍历
ChildType child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData as ParentDataType;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.offset);
return child.hitTest(result, position: transformed);
},
);
// 第一个命中,就直接返回了,后续的孩子不再执行命中测试,所以translucent能透传,因为被它修饰的Listener,返回的结果是false
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
至此,所有的分析都结束了,从这篇文章中,我们知道了Container的空白区域点击不响应的问题,以及HitTestBehavior.translucent和HitTestBehavior.opaque的真正区别