Flutter:解决CupertinoContextMenu手势冲突
前置知识
两个TapGestureRecognizer的冲突
两个Tap gesture Recognizer,一个套在另一个上层
时间过短的tap,arena刚开启,kPressTimeout还未完成就将关闭,两个tap不会自己承认成功。arena默认选择子child的tap作winner,顺序
onPointerDown
inside onTapDown
inside onTapUp
inside onTap一旦超过100ms(kPressTimeout),两个tap的onTapDown都会触发
接下来无论持续按多久
只要平移距离在preAcceptSlopTolerance(flutter里这个值限定死了是kTouchSlop,令人感叹)内
arena里没有其他手势宣布胜利(在本情形里没有其他主动胜利的gesture)
那么两个tap都准备成功,最后选出子child的tap作winner,reject 外层的tap,最终触发顺序:
onPointerDown
inside onTapDown and onTapDown
inside onTapUp
inside onTap(与onTapUp一般来说是同时触发,但这里标明顺序是因为flutter里这么写的=w=)and onTapCancel(一个手势win同时会触发另一个的lose)
理解CupertinoContextMenu部分原理
1 | // The duration of the transition used when a modal popup is shown. Eyeballed |
打开_ContextMenuRoute之前的三个阶段:
onTapDown:叠加诱饵子(Decoy child),并开始[_openController]动画。
triggerred after a period of time after Listener sent onPointerDown. Typically, the time elapsed is the [kPressTimeout] in flutter.
holding: 只要手指一直按住,openController就会保持动画
一旦指针抬起屏幕,就会调用onTapUp(如果对方获胜,则调用onTapCancel)
1.如果动画进度大于中点:继续完成。动画完成后,_ContextMenuRoute将被推进,诱饵子将被删除。
2.其他:反向执行动画直到完毕。一旦完毕,就删除诱饵子。
总之,ContextMenu的成功触发并不取决于此点击手势的获胜。
冲突原因
如果我们在“CupertinoContextMenu”的子树上放置一个“TapGestureRecognizer”。
为了简单起见,我们将“CupertinoContextMenu”中的“TapGestureRecognizer”命名为tg2,将“CupertinoContextMenu”子树中的“TapGestureRecognizer”命名为tg1,按压持续时间命名为t1。
当 kPressTimeout+_previewLongPressTimeout/2
(500ms) < t1 < kPressTimeout+_previewLongPressTimeout
(900ms)时,此时
1.虽然tg2被拒绝了,但_ContextMenuRoute
仍然被打开。
2.tg1的onTap方法被触发
问题是,此ContextMenu中的GestureRecognizer是TapGestureRecognizer,而不是LongPressGestureRecognizer,Tap不声称获胜,但GestureArena选择最深的Tap来获胜。
1.当t1大于500ms时,这意味着它已通过“PrimaryPointerGestureRecognizer.deadline”加上“openController”持续时间的一半,意味着“_ContextMenuRoute”即将打开。在“GestureArena”收到指针活动后,tg1与tg2竞争。默认的“子胜利”起作用了,并调用了tg1的onTap,因此两者都被触发了。
2.当t1小于900ms时,因为路线此时已打开,并且两者都将被竞技场拒绝。
解决方法
原因是“TapGestureRecognizer”不会自行宣布胜利。添加以下代码。
1 | // call this when animation's value first reaches [_midpoint] |
Related issues
https://github.com/flutter/flutter/issues/70716
https://github.com/flutter/flutter/issues/52226
https://github.com/flutter/flutter/issues/81057
Related PR
https://github.com/flutter/flutter/pull/131030
2024/8/12 Updated
以上只声明了手势胜利,如果没有主动声明失败,也会导致手势冲突。
理想情况和现状
比如一个 tg1 套在 contextMenu(tg2) 外层。理想情况是,点击后只有 tg1 响应(contextMenu 不应该点一下就打开),然而现状是点击时只会响应 tg2 。
因为 tg2 在子树,且他没有主动声明失败,所以默认 tg1失败了。
解决方法
为了主动声明失败,我们需要列一下失败场景:
- 非常迅速的点击,未超过 100ms。此时两个 tg 的 ontapdown 方法还未触发,onPointerUp 便已经触发,竞技场准备好决胜了。此时 tg2 准备获胜,tg2 的 ontapdown、ontapup 被依次调用,decoyChild的动画也被短暂执行。
- 超过 100ms 但未超过 midpoint。此时 tg1 和 tg2 都作为待选者,二者 ontapdown 都被触发。在竞技场决胜时(onPointerUp),最终 tg2 还是获胜,tg 的 ontapup 被调用。
- 超过 midpoint 的持续按压。此时 tg2 主动声明胜利。
可见场景 1 和 2 是需要调整的。调整为:
- 在竞技场决胜时,tg2 直接声明失败,tg2 ontapdown、ontapup、ontapcancel 均不会调用,tg1 胜利。
- tg2 声明失败,tg2 ontapdown虽已调用,但不会调用 up ,会调用 ontapcancel 清理decoyChild动画逻辑,tg1 胜利。
综上所述,解决方法应该只有一个,Listener中的onPointerUp事件中添加以下代码:
1 | Listener( |
曾想过用 GestureArenaManager.hold 方式延缓决胜,这样也许可以让场景 1 和 2 统一,但非常迅速的点击出现decoyChild的动画不合理,遂放弃。