“我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛
前言
可能有人看过这篇文章:
# 三步实现一个自定义任意路径的嫦娥奔月(Flutter版)
在这篇文章中,除了通篇废话外、让人瞎眼的UI外,还遗留这么一个大问题:
item可点击位置,是有问题的,和实际展示位置不一样
现在这篇文章,就来解决这个问题;
按照国际惯例,先上效果图(这回为了清晰的显示点击区域,将背景改成红色,并加上了index):
首先整理下,需要修改哪些东西
1、Item大小需要进行调整
不知道有没有细心的读者在上篇文章的 [3. 修改绘制,按path要求绘制] 中有注意到这么一段注释:
if (tf?.position != null) {
/// 这里的50 魔法数,是因为之前设置item的height为100,
/// 因为listView好像强制将item的高度固定为listView的高度(横向情况)
/// 这块找个时间研究下怎么搞
/// 强调下,好孩子不要学我这写法
childItemOffset = Offset(
tf!.position.dx - child.size.width / 2, tf.position.dy - 50);
}
出现这种情况的原因,正如注释中所述:
以横向listView为例,其所有Item的高度均被固定,涉案代码:
sliver_list.dart:
@override
void performLayout() {
final SliverConstraints constraints = this.constraints;
childManager.didStartLayout();
childManager.setDidUnderflow(false);
final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final double remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final double targetEndScrollOffset = scrollOffset + remainingExtent;
final BoxConstraints childConstraints = constraints.asBoxConstraints();
......
}
sliver.dart:
/// Returns [BoxConstraints] that reflects the sliver constraints.
///
/// The `minExtent` and `maxExtent` are used as the constraints in the main
/// axis. If non-null, the given `crossAxisExtent` is used as a tight
/// constraint in the cross axis. Otherwise, the [crossAxisExtent] from this
/// object is used as a constraint in the cross axis.
///
/// Useful for slivers that have [RenderBox] children.
BoxConstraints asBoxConstraints({
double minExtent = 0.0,
double maxExtent = double.infinity,
double? crossAxisExtent,
}) {
crossAxisExtent ??= this.crossAxisExtent;
switch (axis) {
case Axis.horizontal:
return BoxConstraints(
minHeight: crossAxisExtent,
maxHeight: crossAxisExtent,
minWidth: minExtent,
maxWidth: maxExtent,
);
case Axis.vertical:
return BoxConstraints(
minWidth: crossAxisExtent,
maxWidth: crossAxisExtent,
minHeight: minExtent,
maxHeight: maxExtent,
);
}
}
minHeight和maxHeight的值均被设置为crossAxisExtent;
现在我们要做item点击区域判断,自然需要获取item位置和大小,如果能获取到item的大小,这个问题正好也可以顺便解决~
2、点击位置计算与判断
对于大部分listView ,或者说sliver和其子类来说,点击事件的处理流程都是大同小异:
调用hitTestChildren方法,判断child是否被点击
这个hitTestChildren方法具体的实现,由各个子类的需要的功能来决定,但是大部分我们使用到的sliver及其子类,其方法是这样的:
以RenderSliverMultiBoxAdaptor为例,我们最常用到的listView、gridView之类的都是使用它:
@override
bool hitTestChildren(SliverHitTestResult result, { required double mainAxisPosition, required double crossAxisPosition }) {
RenderBox? child = lastChild;
final BoxHitTestResult boxResult = BoxHitTestResult.wrap(result);
while (child != null) {
if (hitTestBoxChild(boxResult, child, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition))
return true;
child = childBefore(child);
}
return false;
}
做法很简单:遍历child,通过hitTestBoxChild方法来判断是否child被点击;
而这hitTestBoxChild 就是普通的根据滑动距离和滑动方向判断一下点到那个child了,并将点击事件传给它;
然鹅,现在现在已经将item的展示位置改为自定义路径了,要按这套逻辑来走,自然就会出现开头所说的那个,点击位置和实际展示不一致的问题
3、点击事件传递
当然,点击事件不能只终止在listView这个层面;
解决方案与代码修改
1、item大小自适应:
这个问题的解决方案也很简单,自己强制规定允许最小高度即可,例如在performLayout的方法中的constraints.asBoxConstraints()后面加上这么一句话:
///改变child 的 布局约束,让其可以自适应
childConstraints = BoxConstraints(
minWidth: 0,
maxWidth: childConstraints.maxWidth,
minHeight: 0,
maxHeight: childConstraints.maxHeight);
允许其最小高度为0即可;
这样从约束层面就可以做到最小自适应,上面提到的50魔法数,也可以改成child.size.height / 2 这种方式了
点击位置和其生效判断
现在需要做的事,简单的来说,就是判断一下点击位置是不是在我们自定义路径的item上,是的话,传给它
按照这个思路来,最后能发现,其实改动仍然不大,围绕hitTestBoxChild方法进行修改即可,整理下需要做这么几件事:
- [ 修改点击事件部分,将点击事件实际位置传给
hitTestBoxChild] - [ 判断具体是否点击到child ]
- [ 传递点击事件 ]
修改点击事件
首先呢,如果打个断点,其实会发现给hitTestBoxChild的入参,并不是实际点击位置的像素点的位置,而是经过转换的一个数值;
按照之前所述,点击事件是一层层从上往下传递的,追踪到ViewPort这块,会发现,它给child传递的点击事件位置,是经过处理之后的,代码如下:
viewport.dart —— RenderViewportBase
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
......
for (final RenderSliver child in childrenInHitTestOrder) {
if (!child.geometry!.visible) {
continue;
}
final Matrix4 transform = Matrix4.identity();
applyPaintTransform(child, transform); // must be invertible
final bool isHit = result.addWithOutOfBandPosition(
paintTransform: transform,
hitTest: (BoxHitTestResult result) {
return child.hitTest(
sliverResult,
mainAxisPosition: computeChildMainAxisPosition(child, mainAxisPosition),
crossAxisPosition: crossAxisPosition,
);
},
);
if (isHit) {
return true;
}
}
return false;
}
其中给child.hitTest方法的入参,是一个computeChildMainAxisPosition方法修改后的数值
所以,正好可以在之前自定义ViewPort的OverScrollRenderViewportBase 文件中,将这块改成mainAxisPosition,传递原始点击位置;
判断具体是否点击到child
这块就是hitTestBoxChild方法具体负责的部分了,在前面简单的分析了一下这个方法的步骤和作用;
但是呢,由于我们是自定义的路径,所以别看这个方法写了不少,对于现在,一句有用的都没有……全部木大
那么,这块要重新设计的话,我的思路是这样的:
-
将之前在
paint方法中,规划好child的绘制位置,以及偏移量等看上去有用的东西,放在一个parentData中;这样当hitTest触发的时候,从parentData中直接就能拿到这个item的实际位置,不用再计算或者别的什么东西; -
拿到实际位置后,遍历child,将点击位置减去child的实际位置,判断是否在child.size范围内;
-
将减去child实际位置的事件,发送给child,以供后续的hitTest事件处理;
经过上面三步之后,你就会发现,其实一点用都没有,甚至hitTestBoxChild 方法都没触发;
原因呢,也很简单……hitTest方法规定了最大最小范围……而原始指针位置,正好在最大范围外……
所以需要手动规定一下hitTestExtent 这个最大范围,例如在 performLayout方法中,给构造方法中规定一下:
最后
现在Item可以正确响应事件了,不过,如果结合前面的预告效果,给Item加上变化效果后,好像这块又有问题……
看来有必要将包括平移、变换之类的效果统一用一个Widget包裹一下,然后由这个widget统一处理比较好???
那么下一步是实现一个完完全全,从widget到RenderObject,并且符合flutter的规范的一个代理型Widget吗?