React Native觸摸事件處理詳解
觸控是移動(dòng)設(shè)備的核心功能,也移動(dòng)應(yīng)用交互的基礎(chǔ),Android 和 iOS 各自都有完善的觸摸事件處理機(jī)制。React Native(以下簡(jiǎn)稱 RN)提供了一套統(tǒng)一的處理方式,能夠方便的處理界面中組件的觸摸事件、用戶手勢(shì)等。本文嘗試介紹 RN 中觸摸事件處理。
1. RN 基本觸摸組件
RN 的組件除了 Text,其他組件默認(rèn)是不支持點(diǎn)擊事件,也不能響應(yīng)基本觸摸事件,所以 RN 中提供了幾個(gè)直接處理響應(yīng)事件的組件,基本上能夠滿大部分的點(diǎn)擊處理需求TouchableHighlight, TouchableNativeFeedback, TouchableOpacity 和 TouchableWithoutFeedback。因?yàn)檫@幾個(gè)組件的功能和使用方法基本類似,只是 Touch 的反饋效果不一樣,所以一般我們用 Touchable** 代替。Touchable** 有如下幾個(gè)回調(diào)方法:
- onPressIn:點(diǎn)擊開始;
- onPressOut:點(diǎn)擊結(jié)束或者離開;
- onPress:?jiǎn)螕羰录卣{(diào);
- onLongPress:長(zhǎng)按事件回調(diào)。
它們的基本使用方法如下,這里以 TouchableHighlight 為例:
- <TouchableHighlight
- onPressIn={() => console.log("onPressIn")}
- onPressOut={() => console.log("onPressOut")}
- onPress={() => console.log("onPress")}
- onLongPress={() => console.log("onLongPress")}
- >
- <Image
- style={styles.button}
- source={require('./img/rn_logo.png')} />
- </TouchableHighlight>
RN 中提供的觸摸組件使用非常簡(jiǎn)單,可以參考 官方文檔,這里也不做詳細(xì)的介紹了。下面主要介紹用戶觸摸事件處理。
2. 單組件觸摸事件處理
我們知道,RN 的組件默認(rèn)不進(jìn)行處理觸摸事件。組件要處理觸摸事件,首先要“申請(qǐng)”成為摸事件的響應(yīng)者(Responder),完成事件處理以后,會(huì)釋放響應(yīng)者的角色。一個(gè)觸摸事件處理周期,是從用戶手指按下屏幕,到用戶抬起手指抬起結(jié)束,這是用戶的一次完整觸摸操作。
單個(gè)組件的單次操作交互處理的生命周期如下:
我們來(lái)詳細(xì)分析一下事件處理的生命周期,在整個(gè)事件處理的過(guò)程中,組件有可能處于兩種身份中的一種,并且可以相互切換:非事件響應(yīng)者和事件響應(yīng)者。
非事件響應(yīng)者
默認(rèn)情況下,觸摸事件輸入不會(huì)直接傳遞給組件,不能進(jìn)行事件響應(yīng)處理,也就是非事件響應(yīng)者。如果組件要進(jìn)行觸摸事件處理,首先要申請(qǐng)成為事件響應(yīng)者,組件有如下兩個(gè)屬性可以做這樣的申請(qǐng):
- View.props.onStartShouldSetResponder,這個(gè)屬性接收一個(gè)回調(diào)函數(shù),函數(shù)原型是 function(evt): bool,在觸摸事件開始(touchDown)的時(shí)候,RN 會(huì)回調(diào)此函數(shù),詢問(wèn)組件是否需要成為事件響應(yīng)者,接收事件處理,如果返回 true,表示需要成為響應(yīng)者;
- View.props.onMoveShouldSetResponder,它和前一個(gè)屬性類似,不過(guò)這是觸摸是進(jìn)行過(guò)程中(touchMove),RN 詢問(wèn)組件是否要成為響應(yīng)者,返回 true 表示是。
假如組件通過(guò)上面的方法返回了 true,表示發(fā)出了申請(qǐng)要成為事件響應(yīng)者請(qǐng)求,想要接收后續(xù)的事件輸入。因?yàn)橥粫r(shí)刻,只能有一個(gè)事件處理響應(yīng)者,RN 還需要協(xié)調(diào)所有組件的事件處理請(qǐng)求,所以不是每個(gè)組件申請(qǐng)都能成功,RN 通過(guò)如下兩個(gè)回調(diào)來(lái)通知告訴組件它的申請(qǐng)結(jié)果,:
- View.props.onResponderGrant: (evt) => {}:表示申請(qǐng)成功,組件成為了事件處理響應(yīng)者,這時(shí)組件就開始接收后序的觸摸事件輸入。一般情況下,這時(shí)開始,組件進(jìn)入了激活狀態(tài),并進(jìn)行一些事件處理或者手勢(shì)識(shí)別的初始化。
- View.props.onResponderReject: (evt) => {}:表示申請(qǐng)失敗了,這意味者其他組件正在進(jìn)行事件處理,并且它不想放棄事件處理,所以你的申請(qǐng)被拒絕了,后續(xù)輸入事件不會(huì)傳遞給本組件進(jìn)行處理。
事件響應(yīng)者
如果通過(guò)上面的步驟,組件申請(qǐng)成為了事件響應(yīng)者,后續(xù)的事件輸入都會(huì)通過(guò)回調(diào)函數(shù)通知到組件,如下:
- View.props.onResponderStart: (evt) => {}:表示手指按下時(shí),成功申請(qǐng)為事件響應(yīng)者的回調(diào);
- View.props.onResponderMove: (evt) => {}:表示觸摸手指移動(dòng)的事件,這個(gè)回調(diào)可能非常頻繁,所以這個(gè)回調(diào)函數(shù)的內(nèi)容需要盡量簡(jiǎn)單;
- View.props.onResponderRelease: (evt) => {}:表示觸摸完成(touchUp)的時(shí)候的回調(diào),表示用戶完成了本次的觸摸交互,這里應(yīng)該完成手勢(shì)識(shí)別的處理,這以后,組件不再是事件響應(yīng)者,組件取消激活。
- View.props.onResponderEnd: (evt) => {}:表示組件結(jié)束事件響應(yīng)的回調(diào)。
從前面的圖中也看到,在組件成為事件響應(yīng)者期間,其他組件也可能會(huì)申請(qǐng)觸摸事件處理。此時(shí) RN 會(huì)通過(guò)回調(diào)詢問(wèn)你是否可以釋放響應(yīng)者角色讓給其他組件。回調(diào)如下:
- View.props.onResponderTerminationRequest: (evt) => bool
如果回調(diào)函數(shù)返回為 true,則表示同意釋放響應(yīng)者角色,同時(shí)會(huì)回調(diào)如下函數(shù),通知組件事件響應(yīng)處理被終止了:
- View.props.onResponderTerminate: (evt) => {}
這個(gè)回調(diào)也會(huì)發(fā)生在系統(tǒng)直接終止組件的事件處理,例如用戶在觸摸操作過(guò)程中,突然來(lái)電話的情況。
事件數(shù)據(jù)結(jié)構(gòu)
從前面我們看到,觸摸事件處理的回調(diào)都有一個(gè) evt 參數(shù),包含一個(gè)觸摸事件數(shù)據(jù) nativeEvent。nativeEvent 的詳細(xì)內(nèi)容如下:
- identifier:觸摸的 ID,一般對(duì)應(yīng)手指,在多點(diǎn)觸控的時(shí)候,用來(lái)區(qū)分是哪個(gè)手指的觸摸事件;
- locationX 和 locationY:觸摸點(diǎn)相對(duì)組件的位置;
- pageX 和 pageY:觸摸點(diǎn)相對(duì)于屏幕的位置;
- timestamp:當(dāng)前觸摸的事件的時(shí)間戳,可以用來(lái)進(jìn)行滑動(dòng)計(jì)算;
- target:接收當(dāng)前觸摸事件的組件 ID;
- changedTouches:evt 數(shù)組,從上次回調(diào)上報(bào)的觸摸事件,到這次上報(bào)之間的所有事件數(shù)組。因?yàn)橛脩粲|摸過(guò)程中,會(huì)產(chǎn)生大量事件,有時(shí)候可能沒(méi)有及時(shí)上報(bào),系統(tǒng)用這種方式批量上報(bào);
- touches:evt 數(shù)組,多點(diǎn)觸摸的時(shí)候,包含當(dāng)前所有觸摸點(diǎn)的事件。
這些數(shù)據(jù)中,最常用的是 locationX 和 locationY 數(shù)據(jù),需要注意的是,因?yàn)檫@里是 Native 的數(shù)據(jù),所以他們的單位是實(shí)際像素。如果要轉(zhuǎn)換為 RN 中的邏輯單位,可以示使用如下方法:
- var pX = evt.nativeEvent.locationX / PixelRatio.get();
3. 嵌套組件事件處理
上一小節(jié)介紹的都是針對(duì)單個(gè)組件來(lái)說(shuō),事件處理的流程和機(jī)制。但是前面也提到了,當(dāng)組件需要作為事件處理響應(yīng)者時(shí),需要通過(guò) onStartShouldSetResponder 或者 onMoveShouldSetResponder 回調(diào)返回值為 true 來(lái)申請(qǐng)。假如當(dāng)多個(gè)組件嵌套的時(shí)候,這兩個(gè)回調(diào)都返回了 true 的時(shí)候,但是同一個(gè)只能有一個(gè)事件處理響應(yīng)者,這種情況怎么處理呢?為了便于描述,假設(shè)我們的組件布局如下:
在 RN 中,默認(rèn)情況下使用冒泡機(jī)制,響應(yīng)最深的組件***開始響應(yīng),所以前面描述的這種情況,如圖中,如果 A、B、C 三個(gè)組件的 on*ShouldSetResponder 都返回為 true,那么只有 C 組件會(huì)得到響應(yīng)成為響應(yīng)者。這種機(jī)制才能保證了界面所有的組件才能得到響應(yīng)。但是有些情況下,可能父組件可能需要處理事件,而禁止子組件響應(yīng)。RN 提供了一個(gè)劫持機(jī)制,也就是在觸摸事件往下傳遞的時(shí)候,先詢問(wèn)父組件是否需要劫持,不給子組件傳遞事件,也就是如下兩個(gè)回調(diào):
- View.props.onStartShouldSetResponderCapture:這個(gè)屬性接收一個(gè)回調(diào)函數(shù),函數(shù)原型是 function(evt): bool,在觸摸事件開始(touchDown)的時(shí)候,RN 容器組件會(huì)回調(diào)此函數(shù),詢問(wèn)組件是否要劫持事件響應(yīng)者設(shè)置,自己接收事件處理,如果返回 true,表示需要劫持;
- View.props.onMoveShouldSetResponderCapture:此函數(shù)類似,不過(guò)是在觸摸移動(dòng)事件(touchMove)詢問(wèn)容器組件是否劫持。
可以把這種劫持機(jī)制看成是一種下沉機(jī)制,與上面的冒泡機(jī)制對(duì)應(yīng),我們可以總結(jié) RN 事件處理流程如下圖:
注,圖中的 * 表示可以為 Start 或者 Move,例如 on*ShouldSetResponderCapture 表示 onStartShouldSetResponderCapture 或者 onMoveShouldSetResponderCapture,其他的類似。
觸摸事件開始,首先調(diào)用 A 組件的 onStartShouldSetResponderCapture,若此回調(diào)返回 false,則按照?qǐng)D傳遞到 B 組件,然后調(diào)用 B 組件 onStartShouldSetResponderCapture,若返回 true,則事件不再傳遞給 C 組件,直接調(diào)用本組件的 onResponderStart,則 B 組件就成為事件響應(yīng)者,后續(xù)事件直接傳遞給它。其他的分析類似。
注意到,圖中還有 onTouchStart/onTouchStop 回調(diào),這個(gè)回調(diào)并不受響應(yīng)者的影響,在范圍內(nèi)的組件都會(huì)回調(diào)此函數(shù),而且調(diào)用順序是從最深層組件到最上層組件。
4. 手勢(shì)識(shí)別
前面只是介紹了簡(jiǎn)單的觸摸事件處理機(jī)制及其使用方法,其實(shí)連續(xù)的觸摸事件,可以組成一些更高級(jí)手勢(shì),例如我們最常見(jiàn)的滑動(dòng)屏幕內(nèi)容,雙指縮放(Pinch)或者旋轉(zhuǎn)圖片都是通過(guò)手勢(shì)識(shí)別完成的。
因?yàn)橛行┦謩?shì)是很常用的,RN 也提供了內(nèi)置的手勢(shì)識(shí)別庫(kù) PanResponder,它封裝了上面的事件回調(diào)函數(shù),對(duì)觸摸事件數(shù)據(jù)進(jìn)行加工,完成滑動(dòng)手勢(shì)識(shí)別,向我們提供更加高級(jí)有意義的接口,如下:
- onMoveShouldSetPanResponder: (e, gestureState) => bool
- onMoveShouldSetPanResponderCapture: (e, gestureState) => bool
- onStartShouldSetPanResponder: (e, gestureState) => bool
- onStartShouldSetPanResponderCapture: (e, gestureState) => bool
- onPanResponderReject: (e, gestureState) => {…}
- onPanResponderGrant: (e, gestureState) => {…}
- onPanResponderStart: (e, gestureState) => {…}
- onPanResponderEnd: (e, gestureState) => {…}
- onPanResponderRelease: (e, gestureState) => {…}
- onPanResponderMove: (e, gestureState) => {…}
- onPanResponderTerminate: (e, gestureState) => {…}
- onPanResponderTerminationRequest: (e, gestureState) => {…}
- onShouldBlockNativeResponder: (e, gestureState) => bool
可以看到,這些接口與前面接收的基礎(chǔ)回調(diào)基本上是一一對(duì)應(yīng)的,其功能也是類似,這里就不再贅述。這里有一個(gè)特別的回調(diào) onShouldBlockNativeResponder 表示是否用 Native 平臺(tái)的事件處理,默認(rèn)是禁用的,全部使用 JS 中的事件處理,注意此函數(shù)目前只能在 Android 平臺(tái)上使用。不過(guò)這里回調(diào)函數(shù)都有一個(gè)新的參數(shù) gestureState,這是與滑動(dòng)相關(guān)的數(shù)據(jù),是對(duì)基本觸摸數(shù)據(jù)的分析處理,它的內(nèi)容如下:
- stateID:滑動(dòng)手勢(shì)的 ID,在一次完整的交互中此 ID 保持不變;
- moveX 和 moveY:自上次回調(diào),手勢(shì)移動(dòng)距離;
- x0 和 y0:滑動(dòng)手勢(shì)識(shí)別開始的時(shí)候的在屏幕中的坐標(biāo);
- dx 和 dy:從手勢(shì)開始時(shí),到當(dāng)前回調(diào)是移動(dòng)距離;
- vx 和 vy:當(dāng)前手勢(shì)移動(dòng)的速度;
- numberActiveTouches:當(dāng)期觸摸手指數(shù)量。
下面介紹一個(gè)簡(jiǎn)單的實(shí)例,本例實(shí)現(xiàn)可以使用手指拖動(dòng)界面的圓形控件,使用實(shí)例如下:
- import React from 'react';
- import {
- AppRegistry,
- PanResponder,
- StyleSheet,
- View,
- processColor,
- } from 'react-native';
- var CIRCLE_SIZE = 80;
- var CIRCLE_COLOR = 'blue';
- var CIRCLE_HIGHLIGHT_COLOR = 'green';
- var PanResponderExample = React.createClass({
- statics: {
- title: 'PanResponder Sample',
- description: 'Shows the use of PanResponder to provide basic gesture handling.',
- },
- _panResponder: {},
- _previousLeft: 0,
- _previousTop: 0,
- _circleStyles: {},
- circle: (null : ?{ setNativeProps(props: Object): void }),
- componentWillMount: function() {
- this._panResponder = PanResponder.create({
- onStartShouldSetPanResponder: (evt, gestureState) => true,
- onMoveShouldSetPanResponder: (evt, gestureState) => true,
- onPanResponderGrant: this._handlePanResponderGrant,
- onPanResponderMove: this._handlePanResponderMove,
- onPanResponderRelease: this._handlePanResponderEnd,
- onPanResponderTerminate: this._handlePanResponderEnd,
- });
- this._previousLeft = 20;
- this._previousTop = 84;
- this._circleStyles = {
- style: {
- left: this._previousLeft,
- top: this._previousTop
- }
- };
- },
- componentDidMount: function() {
- this._updatePosition();
- },
- render: function() {
- return (
- <View style={styles.container}>
- <View
- ref={(circle) => {
- this.circle = circle;
- }}
- style={styles.circle}
- {...this._panResponder.panHandlers}
- />
- </View>
- );
- },
- _highlight: function() {
- const circle = this.circle;
- circle && circle.setNativeProps({
- style: {
- backgroundColor: processColor(CIRCLE_HIGHLIGHT_COLOR)
- }
- });
- },
- _unHighlight: function() {
- const circle = this.circle;
- circle && circle.setNativeProps({
- style: {
- backgroundColor: processColor(CIRCLE_COLOR)
- }
- });
- },
- _updatePosition: function() {
- this.circle && this.circle.setNativeProps(this._circleStyles);
- },
- _handlePanResponderGrant: function(e: Object, gestureState: Object) {
- this._highlight();
- },
- _handlePanResponderMove: function(e: Object, gestureState: Object) {
- this._circleStyles.style.left = this._previousLeft + gestureState.dx;
- this._circleStyles.style.top = this._previousTop + gestureState.dy;
- this._updatePosition();
- },
- _handlePanResponderEnd: function(e: Object, gestureState: Object) {
- this._unHighlight();
- this._previousLeft += gestureState.dx;
- this._previousTop += gestureState.dy;
- },
- });
- var styles = StyleSheet.create({
- circle: {
- width: CIRCLE_SIZE,
- height: CIRCLE_SIZE,
- borderRadius: CIRCLE_SIZE / 2,
- backgroundColor: CIRCLE_COLOR,
- position: 'absolute',
- left: 0,
- top: 0,
- },
- container: {
- flex: 1,
- paddingTop: 64,
- },
- });
可見(jiàn),在 componentWillMount 中創(chuàng)建一個(gè) PanResponder 實(shí)例,并設(shè)置想好相關(guān)的屬性,然后把這個(gè)對(duì)象設(shè)置給 View 的屬性,如下:
- <View
- {...this._panResponder.panHandlers}
- />
其余的代碼也比較簡(jiǎn)單,這里就不詳述了。
5. 總結(jié)
通過(guò)上面的介紹,可以看到 RN 中提供了類似 Native 平臺(tái)的事件處理機(jī)制,所以也可以實(shí)現(xiàn)各種的觸摸事件處理,甚至也可以實(shí)現(xiàn)復(fù)雜的手勢(shì)識(shí)別。
在嵌套組件的事件處理中,RN 中提供了“冒泡”和“下沉”兩個(gè)方向的事件處理,這有點(diǎn)類似于 Android Native 上不久前才支持的 NestedScrolling,這就提供更加強(qiáng)大的事件處理機(jī)制。
另外需要注意,因?yàn)?RN 的異步通信和執(zhí)行機(jī)制,前面描述的所有回調(diào)函數(shù)都是在 JS 線程中,并不是 Native 的 UI 線程,而 Native 平臺(tái)的 Touch 事件都是在 UI 線程中。所以在 JS 中通過(guò) Touch 或者手勢(shì)實(shí)現(xiàn)動(dòng)畫,可能會(huì)延遲的問(wèn)題。