ReactNative 详解(二) 基础内容之手势处理

在上篇文章中,我们介绍了 ReactNative 的基础布局,动画相关内容。除此之外,我们还需要和元素有交互才可以。我们看到在 ReactNative 中,有些组件是自带交互事件的,比如 button, Text 就有 onPress 事件。但是我们在普通的 View 上面就没有对应的手势事件。且,向前端那些DOM的手势事件 click等都不存在的。为此,ReactNative 专门提供了几个直接相应处理事件的组件:TouchableHighlight,TouchableNativeFeedback,TouchableOpacity和TouchableWidthoutFeedback。

Touchable* 组件

ReactNative 为我们提供了几个常用的触控组件:

1
2
3
4
TouchableHighlight,
TouchableNativeFeedback,
TouchableOpacity,
TouchableWithoutFeedback

他们的功能和使用方法基本类似,只是在Touch的时候反馈的效果不同。一般来说,你可以使用TouchableHighlight来制作按钮或者链接。注意此组件的背景会在用户手指按下时变暗。在 Android 上还可以使用TouchableNativeFeedback,它会在用户手指按下时形成类似墨水涟漪的视觉效果。ouchableOpacity会在用户手指按下时降低按钮的透明度,而不会改变背景的颜色。如果你想在处理点击事件的同时不显示任何视觉反馈,则需要使用TouchableWithoutFeedback。

触摸组件响应事件回调如下:

  1. onPress:类似前端的 click 事件
  2. onPressIn:类似前端的 touchstart 事件
  3. onPressOut:类似前端的 touchend 事件
  4. onLongPress:长按事件,onPress -> onLongPress

这很前端了,嘿嘿。然而我们并不满足,我就是想在 View 等其他组件上绑定事件呢。

手势相应系统

在不支持事件处理的组件上添加事件,我们要完成对 Responder 的申请和释放(在 ReactNative 持有Responder 的才可以响应事件,且全局只有一个 Responder)。大致流程如下:

申请成为触摸事件响应者 -> 成为触摸事件响应者 -> 处理触摸事件 -> 释放触摸事件 -> 触摸事件结束

申请

首先我们要成为申请者,RN 提供了两个申请阶段:

1
2
3
4
// 如果返回true就是申请成为触摸事件的响应者
onStartShouldSetPanResponder: (evt, gestureState) => true
// 如果返回true就是申请成为滑动过程中的响应者
onMoveShouldSetPanResponder: (evt, gestureState) => true

响应

1.手势操作开始,可以理解为 touchstart

1
onPanResponderGrant: (event, gestureState) => {}

其中 event.nativeEvent 属性为:

  • changedTouches - 在上一次事件之后,所有发生变化的触摸事件的数组集合(即上一次事件后,所有移动过的触摸点)
  • identifier - 触摸点的ID
  • locationX - 触摸点相对于父元素的横坐标
  • locationY - 触摸点相对于父元素的纵坐标
  • pageX - 触摸点相对于根元素的横坐标
  • pageY - 触摸点相对于根元素的纵坐标
  • target - 触摸点所在的元素ID
  • timestamp - 触摸事件的时间戳,可用于移动速度的计算
  • touches - 当前屏幕上的所有触摸点的集合 (多指触控用)

gestureState 是封装好的,辅助我们计算的值:

  • stateID - 触摸状态的ID。在屏幕上有至少一个触摸点的情况下,这个ID会一直有效。
  • moveX - 最近一次移动时的屏幕横坐标
  • moveY - 最近一次移动时的屏幕纵坐标
  • x0 - 当响应器产生时的屏幕坐标
  • y0 - 当响应器产生时的屏幕坐标
  • dx - 从触摸操作开始时的累计横向路程
  • dy - 从触摸操作开始时的累计纵向路程
  • vx - 当前的横向移动速度
  • vy - 当前的纵向移动速度
  • numberActiveTouches - 当前在屏幕上的有效触摸点的数量

2.手势移动,类似 touchmove

1
onPanResponderMove: (evt, gestureState) => {}

3.手势结束,类似 touchend

1
onPanResponderRelease: (evt, gestureState) => {},

4.另一个组件成为手势响应者,当前手势被取消

1
onPanResponderTerminate: (evt, gestureState) => {}

基本流程如下:

a.png

触摸事件拦截

在前端开发汇总,事件模型分为冒泡和捕获两种。在 RN 中,事件响应类似冒泡,先从子组件开始。向上查找,找到第一个成为响应者的组件为止。如下,如果 A,B,C 都成为了事件响应者,那么只有 C 会触发。

b.png

但有的时候,我们就想让组件 A 能响应呢?这就需要一种劫持机制,RN 提供了一个劫持机制,也就是在触摸事件往下传递的时候,先询问父组件是否需要劫持,不给子组件传递事件。

在触摸事件开始(touchDown)的时候,RN 容器组件会回调此函数,询问组件是否要劫持事件响应者设置,自己接收事件处理,如果返回 true,表示需要劫持

1
onStartShouldSetPanResponderCapture: (evt, gestureState) => true

此函数类似,不过是在触摸移动事件(touchMove)询问容器组件是否劫持c

1
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true

简单的是示意图如下:

c.png

其他的还有:
View.props.onResponderTerminationRequest: (evt) => true - 有其他组件请求接替响应者,当前的View是否“放权”?返回true的话则释放响应者权力。

View.props.onResponderTerminate: (evt) => {} - 响应者权力已经交出。这可能是由于其他View通过onResponderTerminationRequest请求的,也可能是由操作系统强制夺权(比如iOS上的控制中心或是通知中心)。

(注意:这里介绍的都是 PanResponder,这也是我们常用的,还有原始 Redponder,本质上就是现在所有方法去掉 Pan 关键字,同时删去 gestureState 回调参数)

pointerEvents 属性

用于控制当前视图是否可以作为触控事件的目标。

  • auto:视图可以作为触控事件的目标。
  • none:视图不能作为触控事件的目标。
  • box-none:视图自身不能作为触控事件的目标,但其子视图可以。类似于你在 CSS 中这样设置:
1
2
3
4
5
6
.box-none {
pointer-events: none;
}
.box-none * {
pointer-events: all;
}
  • ‘box-only’:视图自身可以作为触控事件的目标,但其子视图不能。类似于你在 CSS 中这样设置:
1
2
3
4
5
6
.box-only {
pointer-events: all;
}
.box-only * {
pointer-events: none;
}

简单的应用

有了这些我们就可以建立一个简单的手势封装了,参考另一篇文章 移动端手势交互详解与实现

试着封装一个手势组件实现 tappress, doubletap (做成高阶组件也行)。
简要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
enum IGesStatue {
TAPPING,
PRESSING,
PANNING,
}
class Gesture extends React.Component<IGestureProps, any> {
private panResponder: PanResponderInstance | null = null;
private gestures: IGestures = {};
private _lastTap: any = {};
constructor(props: any) {
super(props);
}
// 多个手指手势保存到 gestures中,gesturesp[touch.identifer] = touch
private addTouchPoint(touches: Array<NativeTouchEvent>, event: any) {
for (let i = 0; i < touches.length; i++) {
let touch: NativeTouchEvent = touches[i];
let startTouch = Object.assign({}, touch);
// 用来保存手势触控信息
let gesture: IGesture = {
startTime: touch.timestamp,
lastTime: touch.timestamp,
identifier: touch.identifier,
target: touch.target,
// 默认触摸
status: IGesStatue.TAPPING,
startTouch: startTouch,
lastTouch: startTouch,
duration: 0,
velocityX: 0,
velocityY: 0,
// 定时器,500ms 后,应该触发 `press` 事件。
pressingHandler: setTimeout(() => {
if (gesture.status === IGesStatue.TAPPING) {
// 修改状态为 Pressing
gesture.status = IGesStatue.PRESSING;
// 触发 press 事件
if (this.props.press) {
this.props.press(touch, event, null);
}
}
clearTimeout(gesture.pressingHandler);
gesture.pressingHandler = -1;
}, 500)
};
if (!this.gestures[gesture.identifier]) {
this.gestures[gesture.identifier] = gesture;
}
}
}
// 创建 PanResponder
componentWillMount() {
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (event, gestureState) => {
let touches: Array<NativeTouchEvent> = event.nativeEvent.changedTouches;
this.addTouchPoint(touches, event);
},
onPanResponderMove: (event, gestureState) => {
//....
},
onPanResponderRelease: (event, gestureState) => {
let touches: Array<NativeTouchEvent> = event.nativeEvent.changedTouches;
for (let i = 0; i < touches.length; i++) {
let touch: NativeTouchEvent = touches[i];
let gesture: IGesture = this.gestures[touch.identifier];
// 计算手势相对起始点移动距离
let disX: number = touch.pageX - gesture.startTouch.pageX,
disY: number = touch.pageY - gesture.startTouch.pageY,
distance: number = Math.sqrt(disX ** 2 + disY ** 2);
if (!gesture) {
continue;
}
// 清除 press 判断定时器
if (gesture.pressingHandler > -1) {
clearTimeout(gesture.pressingHandler);
gesture.pressingHandler = -1;
}
// 如果当前触摸状态为 TAPPING 且 移动距离小于 5
if ((gesture.status === IGesStatue.TAPPING) && distance < 5) {
let now = Date.now();
if (this.props.tap) {
this.props.tap(touch, event, null);
}
// 两次点击间隔少于 300ms 触发 double
if (this._lastTap && now - this._lastTap.timestamp < 300) {
if (this.props.dbltap) {
this.props.dbltap(touch, event, null);
}
}
// 记录上一次 tap 时间
this._lastTap = {
timestamp: now
};
}
// 如果状态时 Pressing 则触发 pressend 事件
if (gesture.status === IGesStatue.PRESSING) {
if (this.props.pressEnd) {
this.props.pressEnd(touch, event, null);
}
}
delete this.gestures[touch.identifier];
}
}
});
}
render() {
let {style, ...others} = this.props;
return (
<View
style={style}
{...others}
{...this.panResponder!.panHandlers}>
{this.props.children}
</View>
)
}
}

参考资料