前言:
react-native hitSlop介绍:这一属性定义了按钮的外延范围
为了方便用户使用,公司的自研跨端框架描述语言前期对齐了react-native
,最近研发的同学疯狂push框架提供对齐RN的hitSlop
,但是flutter
并没有提供这个能力,只能借助flutter现有能力实现。
思路
hitSlop
是在不改变布局的情况下,扩大节点的可点击范围,关键的两个点:需要得知是否命中到节点或者节点的外部。当命中节点的外部时,节点同时一定是可响应点击的,比如被绝对定位覆盖,滚动出视口等场景。
- 点击事件命中节点内外可以通过
TapRegion
解决
TapRegion
:是 Flutter 框架中用于捕获点击事件的一个组件,适用于监听用户在特定区域内或外的点击操作。
定义点击区域:
TapRegion
包裹的子组件形成一个“点击区域”。
捕获事件:onTapInside
:当点击事件发生在TapRegion
所包裹的子组件内时触发。onTapOutside
:当点击事件发生在TapRegion
定义的区域外时触发。
简单写个demo看下效果:
点击蓝色部分触发onTapInside
,点击红色部分触发onTapOutside
,点击白色部分不触发回调
- 节点是否可响应点击事件,可以通过
hitTest
当前节点的位置,检查命中结果中第一个节点是否本节点,决定是否成功命中(表示被遮挡),关于hitTest
可以参考Flutter是如何处理一次点击事件有详细介绍
总结:通过 TapRegion
包裹需要扩大热区的组件,当触发 onTapOutside
时,计算点击的position
是否落在hitSlop
的范围,此时通过hitTest
当前组件的position
检查命中结果中第一个节点是否本节点,决定是否回调热区扩大。
实现
1.在根节点包裹TapRegionSurface
,然后当传入的节点中的props带有hitSlop
,给当前节点包一层TapRegion
:
@override
Widget build(BuildContext context) {
...
Widget hitSlop != null && enableHitSlop == true
? TapRegion(
child: child,
onTapOutside: (PointerDownEvent event) {
onTapOutside(event, hitSlop, context);
})
: child;
...
}
2.当命中onTapOutside
时,计算点击的position
落入扩大热区范围内:
bool hasHitSlop(
Offset tapPosition,
EdgeInsets hitSlop,
BuildContext currentContext
) {
final RenderBox renderBox = currentContext.findRenderObject();
// 宽或者高为0时,不再扩大热区
if (renderBox.size.width == 0 || renderBox.size.height == 0) {
print('命中宽或者高为0时,不再扩大热区');
return false;
}
// 获取左上角的位置
final Offset topLeftPosition = renderBox.localToGlobal(Offset.zero);
// 获取右下角的位置
final Offset bottomRightPosition = renderBox
.localToGlobal(Offset(renderBox.size.width, renderBox.size.height));
// 算出新热区左上角和右下角的位置
Offset hitSlopTopLeftPosition = Offset(
topLeftPosition.dx - hitSlop.left, topLeftPosition.dy - hitSlop.top);
Offset hitSlopBottomRightPosition = Offset(
bottomRightPosition.dx + hitSlop.right,
bottomRightPosition.dy + hitSlop.bottom);
// 点击位置落到节点上
bool isHitNode = tapPosition.dx < bottomRightPosition.dx &&
tapPosition.dy < bottomRightPosition.dy &&
tapPosition.dx > topLeftPosition.dx &&
tapPosition.dy > topLeftPosition.dy;
// 点击位置落到热区
bool isHitSlop = tapPosition.dx < hitSlopBottomRightPosition.dx &&
tapPosition.dy < hitSlopBottomRightPosition.dy &&
tapPosition.dx > hitSlopTopLeftPosition.dx &&
tapPosition.dy > hitSlopTopLeftPosition.dy;
// 点击的位置在新热区的位置且不在原节点
return isHitSlop && !isHitNode;
}
3.当命中了扩大热区时,获取当前命中的节点的位置,然后进行从根节点执行命中测试,并取出hitTestResult
中第一个节点,非本节点则证明此时本节点无法在手势竞技场赢得点击,也就不予响应扩大热区事件
bool isCovered(BuildContext currentContext) {
// 获取尺寸和位置
final RenderBox renderBox = currentContext?.findRenderObject() as RenderBox;
// 获取组件的位置
final Offset offset = renderBox.localToGlobal(Offset.zero);
// 执行命中测试
final HitTestResult hitTestResult = HitTestResult();
WidgetsBinding.instance?.hitTest(hitTestResult, offset);
// 检查命中结果中第一个节点非本节点(表示被遮挡)
return hitTestResult.path.first.target != renderBox;
}
- 实现
onTapOutside
方法,完成扩大热区
void onTapOutside(
PointerDownEvent event, EdgeInsets hitSlop, BuildContext currentContext) {
// 屏幕点击的位置如果在用户设置的范围,发送onpress事件
if (hasHitSlop(event.position, hitSlop, currentContext) == true) {
bool isCover = isCovered(currentContext);
// 检查命中结果中第一个节点非本节点的场景不可点击
if (isCover) {
return;
}
print('点击组件-扩大后的热区');
// 给出点击回调
onPressCallback(
params: {
'nativeEvent': {
'pageX': position.dx.toInt(),
'pageY': position.dy.toInt(),
'tapOutside': true // 点击到外部给一个标识
}
});
}
TapRegion原理
- 在
RenderTapRegionSurface
中注册TapRegion
,得到一个TapRegion
列表,
RenderTapRegionSurface#registerTapRegion
:
@override
void registerTapRegion(RenderTapRegion region) {
assert(_tapRegionDebug('Region $region registered.'));
assert(!_registeredRegions.contains(region));
// 把TapRegion加入列表
_registeredRegions.add(region);
if (region.groupId != null) {
_groupIdToRegions[region.groupId] ??= <RenderTapRegion>{};
_groupIdToRegions[region.groupId]?.add(region);
}
}
2.在TapRegionSurface
的hitTest
中保存从当前节点(TapRegionSurface
)一直到子节点的命中测试结果_cachedResults
RenderTapRegionSurface#hitTest
:
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) {
return false;
}
final bool hitTarget =
hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget) {
final BoxHitTestEntry entry = BoxHitTestEntry(this, position);
// 将收集到的命中测试节点列表保存下来
_cachedResults[entry] = result;
result.add(entry);
}
return hitTarget;
}
3. handleEvent
中处理当前TapRegionSurface
下所有的TapRegion
节点的onTapInside
和onTapOutside
RenderTapRegionSurface#handleEvent
:
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
... // 省略
// 在按下事件处理
if (event is! PointerDownEvent) {
return;
}
final BoxHitTestResult? result = _cachedResults[entry];
// 取注册的 TapRegion列表和命中测试的交集,这些注册者响应onTapInside
final Set<RenderTapRegion> hitRegions =
_getRegionsHit(_registeredRegions, result.path)
.cast<RenderTapRegion>()
.toSet();
final Set<RenderTapRegion> insideRegions = <RenderTapRegion>{};
for (final RenderTapRegion region in hitRegions) {
if (region.groupId == null) {
insideRegions.add(region);
continue;
}
// Add all grouped regions to the insideRegions so that groups act as a
// single region.
insideRegions.addAll(_groupIdToRegions[region.groupId]!);
}
// 注册的列表中不响应onTapInside则响应onTapOutside
final Set<RenderTapRegion> outsideRegions =
_registeredRegions.difference(insideRegions);
bool consumeOutsideTaps = false;
for (final RenderTapRegion region in outsideRegions) {
... // 省略
// 回调onTapOutside
region.onTapOutside?.call(event);
}
for (final RenderTapRegion region in insideRegions) {
// 回调onTapInside
region.onTapInside?.call(event);
}
... // 省略
}
总结:TapRegionSurface
捕获并分发点击事件,TapRegion
响应其范围内的点击(onTapInside
)或范围外的点击(onTapOutside
)
结语
其实在web中并不存在扩大热区的功能,但是web灵活的布局完全能满足研发同学的日常需求,这就是跨端研发体验和h5的一个差距吧