更多精彩文章,欢迎关注作者微信公众号:码工笔记。
一、iOS 系统中手势处理的背景知识
在 iOS 系统中,屏幕点击事件从开始到结束主要经历以下步骤[1]:
-
用户点击屏幕,生成一个硬件触摸事件
-
操作系统将硬件触摸事件包装成 IOHIDEvent 后发送给 Springboard,Springboard 再将 IOHIDEvent 发给当前打开的 App 进程
-
App 进程收到事件后,将主线程 Runloop 唤醒(Source1),Source1 回调中触发 Source0 回调,将 IOHIDEvent 包装成 UIEvent 对象,发送给顶层的 UIWindow(-[UIApplication sendEvent:])
-
UIWindow 对 UIEvent 中的每个 UITouch 实例调用 hitTest 方法寻找其 hitTestView,并在 hitTest 递归调用过程中,记录最终的 hitTestView 及其各父视图上挂载的 gestureRecognizer(记录为gestureRecognizers属性)
-
同级的多个 view 之间 hitTest 调用顺序为逆序(后 addSubView 的先调用 hitTest)
-
默认根据 pointInside 方法的返回值来决定是否递归查找其子 view
-
-
将每个 UITouch 对象发给其对应的 gestureRecognizers 对象以及 hitTestView(即调用它们的 touchesBegin 方法)
-
识别成功的 gestureRecognizer 将独占相关的 touch,所有其他 gestureRecognizer 和 hitTestView 都将收到 touchsCancelled 回调,并且以后不会再收到此 touch 的事件
-
一个特例是:系统默认的 UIControl(UISlider, UISwitch 等)的控件的父view上的 gestureRecognizer 优先级低于 UIControl 本身
-
也需要配合相关 gestureRecognizer 冲突解决相关方法(如 canBePreventedByGestureRecognizer:、canPreventGestureRecognizer:等)的具体实现使用
-
-
如果 hitTestView 实例不响应 touchesBegin 等方法,则顺着 responder chain 继续找 nextResponder 来调用
-
若实现了 touchesBegin 等方法,则在其中调用 [super touchesBegin] 方法会将 touch 事件沿着 responder chain 向上传递
-
二、WebKit 中的应用
1. 用 hitTest 扩展触摸手势的响应区域或范围
通过重载 -hitTest:withEvent: 方法并在其中添加对非当前 View 子 View 的 hitTest 调用,即可扩展点击响应的区域或范围。实现中注意:
-
在手动调用 hitTest:withEvent: 方法时,需要将坐标 point 转到目标 View 的坐标系内
-
调用 [super hitTest:withEvent:] 来递归 hitTest 其子View(默认逻辑)
//WKContentView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
//这里 _interactionViewsContainerView 在视图层级中与WKContentView是同级的(同是WKWebView的子View),在这里对其子 view 调用 hitTest 方法,可以达到以扩展点击区域的效果
for (UIView *subView in [_interactionViewsContainerView.get() subviews]) {
UIView *hitView = [subView hitTest:[subView convertPoint:point fromView:self] withEvent:event];
if (hitView) {
return hitView;
}
}
...
//默认的 hitTest 逻辑,递归遍历子View
UIView* hitView = [super hitTest:point withEvent:event];
...
return hitView;
}
2. 用 hitTest 限制触摸手势的响应区域或范围
通过重载 hitTest:withEvent: 方法,并在其中定义要对具体哪些 View 做 hitTest 或直接返回。
- 注意以下例程中未调用 [super hitTest:withEvent:],即不走遍历子 View 做 hitTest 的默认逻辑
详见代码中的注释:
//WKCompositingView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
//获取 hitTestView
return [self _web_findDescendantViewAtPoint:point withEvent:event];
}
//其具体实现为:
//UIView(WKHitTesting)
- (UIView *)_web_findDescendantViewAtPoint:(CGPoint)point withEvent:(UIEvent *)event
{
Vector<UIView *, 16> viewsAtPoint;
//只收集符合位置等条件的非 WKCompositingView 类型的 view, 存到 viewsAtPoint 中
WebKit::collectDescendantViewsAtPoint(viewsAtPoint, self, point, event);
...
//对这些收集的 view 再根据业务逻辑进行过滤,根据不同类别找到其目标View
for (auto *view : WTF::makeReversedRange(viewsAtPoint)) {
//对此类 view 做递归 hitTest
if ([view conformsToProtocol:@protocol(WKNativelyInteractible)]) {
//natively interactible
CGPoint subviewPoint = [view convertPoint:point fromView:self];
return [view hitTest:subviewPoint withEvent:event];
}
//对此类 view 直接返回本身,不递归查找子 view
if ([view isKindOfClass:[WKChildScrollView class]]) {
if (WebKit::isScrolledBy((WKChildScrollView *)view, viewsAtPoint.last())) {
//child scroll view
return view;
}
}
//同上
if ([view isKindOfClass:WebKit::scrollViewScrollIndicatorClass()] && [view.superview isKindOfClass:WKChildScrollView.class]) {
if (WebKit::isScrolledBy((WKChildScrollView *)view.superview, viewsAtPoint.last())) {
//scroll indicator of child scroll view
return view;
}
}
//ignoring other views
}
return nil;
}
3. 用手势冲突解决机制来实现对 CSS touch-action 定义的手势生效规则的支持
-
CSS 中的 touch-action 属性用于设置触摸屏用户如何操纵元素的区域,主要有以下取值(详见[2]):
/* Keyword values */ touch-action: auto; touch-action: none; touch-action: pan-x; touch-action: pan-left; touch-action: pan-right; touch-action: pan-y; touch-action: pan-up; touch-action: pan-down; touch-action: pinch-zoom; touch-action: manipulation; /* Global values */ touch-action: inherit; touch-action: initial; touch-action: unset;举例来说,如果某个 DOM 元素的 touch-action 属性设置为 none 时,WebView 是不允许对此元素使用触摸手势进行滑动的。
-
实现方案:
-
为了实现对 touch-action 的支持,WebKit 中定义了一个特殊的 WKTouchActionGestureRecognizer,并将其作为最后一个 gestureRecognizer 添加到了 WKContentView 上(参考第一部分提到的事件派发优先级,最后一个 gestureRecognizer 优先级最高)
-
WKTouchActionGestureRecognizer 的 touchesBegin/touchesMoved/touchesEnded 方法实现中,都是直接调用了 _updateState 将手势识别状态置为识别成功。这样根据第一部分中讲的手势冲突的处理逻辑,其他 gestureRecognizer 和 hitTestView 就会收到 touchesCancelled 回调——从而达到阻止其他手势响应的效果。
- 注意:并不是阻止了所有手势,具体哪些可以不阻止,还取决于下面要讲的几个解决手势冲突的方法实现
-
WKTouchActionGestureRecognizer 的 canBePreventedByGestureRecognizer: 方法返回了 NO,代表即使别的 gestureRecognizer 已经识别为成功,它也仍旧可以识别成功并继续收到touches消息
-
WKTouchActionGestureRecognizer 的 canPreventGestureRecognizer: 方法,根据 touch-action 的设置,放行或禁止 WKWebView 中预定义的几种手势
//WKTouchActionGestureRecognizer (作为最后一个 gestureRecognizer 加在 WKContentView 上) //touchBegin 中即设置 recognized 状态,可以使别的 gestureRecognizer 手势识别失败(cancelled) - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self _updateState]; } - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self _updateState]; } - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self _updateState]; } - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self _updateState]; } //此方法设置gestureRecognizer为识别成功状态,这样就可以使其他gestureRecognizer或hitTestView停止接收事件(配合其他手势冲突解决的方法) - (void)_updateState { // We always want to be in a recognized state so that we may always prevent another gesture recognizer. [self setState:UIGestureRecognizerStateRecognized]; } //即使是其他 gestureRecognizer 已经识别成功,此gestureRecognizer仍可识别 - (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventingGestureRecognizer { // This allows this gesture recognizer to persist, even if other gesture recognizers are recognized. return NO; } //若此 gestureRecognizer 成功,则按 css touch-action 的规则放行部分其他的 gestureRecognizers - (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)preventedGestureRecognizer { ... //此处 _touchActionDelegate 判断 preventedGestureRecognizer 是不是 WebKit 挂载的相应的手势识别器 auto mayPan = [_touchActionDelegate gestureRecognizerMayPanWebView:preventedGestureRecognizer]; auto mayPinchToZoom = [_touchActionDelegate gestureRecognizerMayPinchToZoomWebView:preventedGestureRecognizer]; auto mayDoubleTapToZoom = [_touchActionDelegate gestureRecognizerMayDoubleTapToZoomWebView:preventedGestureRecognizer]; //不是webkit挂载的,则不禁止 if (!mayPan && !mayPinchToZoom && !mayDoubleTapToZoom) return NO; //以下即是 css touch-action 的规则 // Now that we've established that this gesture recognizer may yield an interaction that is preventable by the "touch-action" // CSS property we iterate over all active touches, check whether that touch matches the gesture recognizer, see if we have // any touch-action specified for it, and then check for each type of interaction whether the touch-action property has a // value that should prevent the interaction. auto* activeTouches = [_touchActionDelegate touchActionActiveTouches]; for (NSNumber *touchIdentifier in activeTouches) { auto iterator = _touchActionsByTouchIdentifier.find([touchIdentifier unsignedIntegerValue]); if (iterator != _touchActionsByTouchIdentifier.end() && [[activeTouches objectForKey:touchIdentifier].gestureRecognizers containsObject:preventedGestureRecognizer]) { // 设置了 pan-x/pan-y/manipulation 时,pan 手势才能生效 // Panning is only allowed if "pan-x", "pan-y" or "manipulation" is specified. Additional work is needed to respect individual values, but this takes // care of the case where no panning is allowed. if (mayPan && !iterator->value.containsAny({ WebCore::TouchAction::PanX, WebCore::TouchAction::PanY, WebCore::TouchAction::Manipulation })) return YES; // 设置了 pinch-zoom/manipulation 时,pinchToZoom 手势才能生效 // Pinch-to-zoom is only allowed if "pinch-zoom" or "manipulation" is specified. if (mayPinchToZoom && !iterator->value.containsAny({ WebCore::TouchAction::PinchZoom, WebCore::TouchAction::Manipulation })) return YES; // 设置了 none 时,双击放大手势才能生效 // Double-tap-to-zoom is only disallowed if "none" is specified. if (mayDoubleTapToZoom && iterator->value.contains(WebCore::TouchAction::None)) return YES; } } return NO; } -
4. 多个 gestureRecognizer 之间的冲突解决
WKContentView 中挂载了很多不同的 gestureRecognizer[3],要解决这些 gestureRecognizer 之间的冲突,需要重载以下方法,并在其中实现冲突解决的业务逻辑,详见下述代码中的注释:
//WKContentView
//工具方法
//static inline bool isSamePair(UIGestureRecognizer *a, UIGestureRecognizer *b, UIGestureRecognizer *x, UIGestureRecognizer *y)
//{
// return (a == x && b == y) || (b == x && a == y);
//}
//此方法指定哪些 gestureRecognizer 可以同时识别,而不是一个识别后就给别人发 touchesCancel
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer
{
...
// WKDeferringGestureRecognizer 和 toucheEventGestureRecognizer 是无冲突的
for (WKDeferringGestureRecognizer *gesture in self._deferringGestureRecognizers) {
//isSamePair用来判断四个入参中前两个与后两个是否是相同的二元组
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _touchEventGestureRecognizer.get(), gesture))
return YES;
}
...
//WKDeferringGestureRecognizer 之间是无冲突的
if ([gestureRecognizer isKindOfClass:WKDeferringGestureRecognizer.class] && [otherGestureRecognizer isKindOfClass:WKDeferringGestureRecognizer.class])
return YES;
//高亮手势和长按手势不冲突
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _highlightLongPressGestureRecognizer.get(), _longPressGestureRecognizer.get()))
return YES;
#if HAVE(UIKIT_WITH_MOUSE_SUPPORT)
//多个鼠标手势之间不冲突
if ([gestureRecognizer isKindOfClass:[WKMouseGestureRecognizer class]] || [otherGestureRecognizer isKindOfClass:[WKMouseGestureRecognizer class]])
return YES;
#endif
#if PLATFORM(MACCATALYST)
//放大镜和用力长按文字手势不冲突
if (isSamePair(gestureRecognizer, otherGestureRecognizer, [_textInteractionAssistant loupeGesture], [_textInteractionAssistant forcePressGesture]))
return YES;
//单击和放大镜手势不冲突
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _singleTapGestureRecognizer.get(), [_textInteractionAssistant loupeGesture]))
return YES;
//查找与长按手势不冲突
if (([gestureRecognizer isKindOfClass:[_UILookupGestureRecognizer class]] && [otherGestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]]) || ([otherGestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]] && [gestureRecognizer isKindOfClass:[_UILookupGestureRecognizer class]]))
return YES;
#endif // PLATFORM(MACCATALYST)
if (gestureRecognizer == _highlightLongPressGestureRecognizer.get() || otherGestureRecognizer == _highlightLongPressGestureRecognizer.get()) {
auto forcePressGesture = [_textInteractionAssistant forcePressGesture];
if (gestureRecognizer == forcePressGesture || otherGestureRecognizer == forcePressGesture)
return YES;
auto loupeGesture = [_textInteractionAssistant loupeGesture];
//放大镜手势不冲突
if (gestureRecognizer == loupeGesture || otherGestureRecognizer == loupeGesture)
return YES;
//1.5次点击手势不冲突
if ([gestureRecognizer isKindOfClass:tapAndAHalfRecognizerClass()] || [otherGestureRecognizer isKindOfClass:tapAndAHalfRecognizerClass()])
return YES;
}
//以下逻辑注释从略,有兴趣的读者可细读 WebKit 源码
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _singleTapGestureRecognizer.get(), [_textInteractionAssistant singleTapGesture]))
return YES;
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _singleTapGestureRecognizer.get(), _nonBlockingDoubleTapGestureRecognizer.get()))
return YES;
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _highlightLongPressGestureRecognizer.get(), _nonBlockingDoubleTapGestureRecognizer.get()))
return YES;
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _highlightLongPressGestureRecognizer.get(), _previewSecondaryGestureRecognizer.get()))
return YES;
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _highlightLongPressGestureRecognizer.get(), _previewGestureRecognizer.get()))
return YES;
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _nonBlockingDoubleTapGestureRecognizer.get(), _doubleTapGestureRecognizerForDoubleClick.get()))
return YES;
if (isSamePair(gestureRecognizer, otherGestureRecognizer, _doubleTapGestureRecognizer.get(), _doubleTapGestureRecognizerForDoubleClick.get()))
return YES;
#if ENABLE(IMAGE_EXTRACTION)
if (gestureRecognizer == _imageExtractionGestureRecognizer || gestureRecognizer == _imageExtractionTimeoutGestureRecognizer)
return YES;
#endif
return NO;
}
//此方法用于指明各 gestureRecognizer 之间的优先级,只有otherGestureRecognizer识别失败之后,gestureRecognizer才能识别
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
//普通触摸事件优先级低于左、右滑导航(网页前进、后退)的手势
if (gestureRecognizer == _touchEventGestureRecognizer && [_webView _isNavigationSwipeGestureRecognizer:otherGestureRecognizer])
return YES;
//对于 deferringGestureRecognizer 来说,如果它需要延迟 gestureRecognizer(事实上由 deferringGestureRecognizer的delegate来决定),则在此处指定其为高优先级
if ([otherGestureRecognizer isKindOfClass:WKDeferringGestureRecognizer.class])
return [(WKDeferringGestureRecognizer *)otherGestureRecognizer shouldDeferGestureRecognizer:gestureRecognizer];
return NO;
}
//指定各 gestureRecognizer 之间的优先级,对于 deferringGestureRecognizer 来说,如果它需要延迟 otherGestureRecognizer,则这里指定其优先级
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
if ([gestureRecognizer isKindOfClass:WKDeferringGestureRecognizer.class])
return [(WKDeferringGestureRecognizer *)gestureRecognizer shouldDeferGestureRecognizer:otherGestureRecognizer];
return NO;
}
5. 用 WKDeferringGestureRecognizer 来延迟其他 gestureRecognizer 的识别
延迟其他 gestureRecognizer 的识别主要有几下几种应用场景:
-
同一个 view 上如果挂载多个 gestureRecognizer,而这些不同的 recognizer 所能识别的 touch 序列之间又包含共同前缀序列时,就需要对这些 recognizer 的成功识别做一个延迟,以保证所有的 recognizer 都有机会被识别。
-
另一个场景是前端支持在 touchstart 等事件处理函数中禁用默认手势(event.preventDefault()),这就要求在处理 web 中的 touchstart 等事件时暂时延缓其他默认手势识别。
-
而如果用户在 scrollView 滚动过程发起了触摸手势,则新手势不应该被延迟。
WKDeferringGestureRecognizer 实现延迟其他 gestureRecognizer 识别过程的主要机制为:
-
在 WKDeferringGestureRecognizer 的 touchesBegan/touchesEnded 方法中,询问它的 delegate 是否要在此时 defer 此 event。如果不需要 defer,则直接将self.state = UIGestureRecognizerStateFailed,这时它对其他手势识别不造成影响;如果需要 defer,则不改变其手势识别状态,这样其他依赖它 fail 以后才能识别的 gestureRecognizer 的相关识别操作将被延迟。
-
touchesBegin 时判断是否 defer 的逻辑是看对应的 touch.view 是不是 scrollView,而且 scrollView 是否正在交互中(SPI: _isInterruptingDeceleration),如果是,则不 defer,否则 defer
-
touchesEnd 时则判断当前是否正在处理前端的 touchstart 事件(此事件可以阻止其他手势),如果是,则 defer
-
canBePreventedByGestureRecognizer 直接返回 NO,表示不会由于别的 gestureRecognizer 的识别成功而被强制 cancel 掉
//WKDeferringGestureRecognizer
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[super touchesBegan:touches withEvent:event];
//若delegate认为需要defer,则直接return,不设置failed状态,这样后面依赖它 fail 才能进行的操作将被延迟。
if ([_deferringGestureDelegate deferringGestureRecognizer:self shouldDeferGesturesAfterBeginningTouchesWithEvent:event])
return;
self.state = UIGestureRecognizerStateFailed;
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[super touchesEnded:touches withEvent:event];
if (self.state != UIGestureRecognizerStatePossible)
return;
if ([_deferringGestureDelegate deferringGestureRecognizer:self shouldDeferGesturesAfterEndingTouchesWithEvent:event])
return;
self.state = UIGestureRecognizerStateFailed;
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[super touchesCancelled:touches withEvent:event];
self.state = UIGestureRecognizerStateFailed;
}
//不能被其他gestureRecognizer取消
- (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventingGestureRecognizer
{
return NO;
}
-
在 WKContentView 的手势冲突处理函数中,会调用以下方法来解决手势前缀序列问题
//手势冲突相关方法调用链: -[WKContentView gestureRecognizer:shouldRequireFailureOfGestureRecognizer:] 调用: -[WKDeferringGestureRecognizer shouldDeferGestureRecognizer] 调用: -[WKContentView deferringGestureRecognizer:shouldDeferOtherGestureRecognizer:] //WKContentView //判断此 deferringGestureRecognizer 是否需要延迟 gestureRecognizer - (BOOL)deferringGestureRecognizer:(WKDeferringGestureRecognizer *)deferringGestureRecognizer shouldDeferOtherGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer { #if ENABLE(IOS_TOUCH_EVENTS) //页面前进、后退手势不应该被延迟 if ([_webView _isNavigationSwipeGestureRecognizer:gestureRecognizer]) return NO; //判断手势对在的view是否挂载于webview视图树上 auto webView = _webView.getAutoreleased(); auto view = gestureRecognizer.view; BOOL gestureIsInstalledOnOrUnderWebView = NO; while (view) { if (view == webView) { gestureIsInstalledOnOrUnderWebView = YES; break; } view = view.superview; } //非 webview 视图树上的手势不应该被延迟 if (!gestureIsInstalledOnOrUnderWebView) return NO; //其他 deferringGestureRecognizer 不应该被延迟 if ([gestureRecognizer isKindOfClass:WKDeferringGestureRecognizer.class]) return NO; //web touch 的手势不应该被延迟 if (gestureRecognizer == _touchEventGestureRecognizer) return NO; auto mayDelayResetOfContainingSubgraph = [&](UIGestureRecognizer *gesture) -> BOOL { #if USE(UICONTEXTMENU) && HAVE(LINK_PREVIEW) if (gesture == [_contextMenuInteraction gestureRecognizerForFailureRelationships]) return YES; #endif #if ENABLE(DRAG_SUPPORT) if (gesture.delegate == [_dragInteraction _initiationDriver]) return YES; #endif // 1.5次点击手势应该延迟 if ([gesture isKindOfClass:tapAndAHalfRecognizerClass()]) return YES; // 放大镜手势应该延迟 if (gesture == [_textInteractionAssistant loupeGesture]) return YES; // 单指多次点击手势应该被延迟 if ([gesture isKindOfClass:UITapGestureRecognizer.class]) { UITapGestureRecognizer *tapGesture = (UITapGestureRecognizer *)gesture; return tapGesture.numberOfTapsRequired > 1 && tapGesture.numberOfTouchesRequired < 2; } return NO; }; //双击、单击手势应该延迟 if (gestureRecognizer == _doubleTapGestureRecognizer || gestureRecognizer == _singleTapGestureRecognizer) return deferringGestureRecognizer == _deferringGestureRecognizerForSyntheticTapGestures; if (mayDelayResetOfContainingSubgraph(gestureRecognizer)) return deferringGestureRecognizer == _deferringGestureRecognizerForDelayedResettableGestures; return deferringGestureRecognizer == _deferringGestureRecognizerForImmediatelyResettableGestures; #else UNUSED_PARAM(deferringGestureRecognizer); UNUSED_PARAM(gestureRecognizer); return NO; #endif }
参考资料
- [1] iOS中的事件响应:www.jianshu.com/p/c294d1bd9…
- [2] CSS touch-action 文档:developer.mozilla.org/zh-CN/docs/…
- [3] WKWebView中各gestureRecognizer的具体作用:blog.csdn.net/hursing/art…