feat(react): support event capture phase

This commit is contained in:
zoomchan-cxj
2021-12-02 10:05:12 -06:00
committed by Zoom Chan
parent db47f0b316
commit 8e33233e99
7 changed files with 214 additions and 51 deletions
+48 -4
View File
@@ -64,12 +64,14 @@ render() {
## 事件冒泡
[[事件冒泡范例]](//github.com/Tencent/Hippy/tree/master/examples/hippy-react-demo/src/components/ListView)
点击事件和触屏事件均可以在回调函数中定义是否需要冒泡该事件到上层组件,点击或触屏事件发生时,终端会寻找该触屏点下声明了要处理该事件的最小控件:
!> HippyReact 默认不冒泡
1. 返回 true 或没有返回值:控件处理完事件后,将不再继续冒泡,整个手势事件处理结束;
2. 返回 false:控件处理完事件后,事件将继续往上一层冒泡,如果找到某个父控件也设置了对应事件处理函数,则会调用改该回调函数,并再次根据其返回值决定是否继续冒泡。如果再向上冒泡的过程中达到了根节点,则事件冒泡结束;
1. 返回 `true` 或 `没有返回值`:控件处理完事件后,将不再继续冒泡,整个手势事件处理结束;
2. 返回 `false`:控件处理完事件后,事件将继续往上一层冒泡,如果找到某个父控件也设置了对应事件处理函数,则会调用改该回调函数,并再次根据其返回值决定是否继续冒泡。如果再向上冒泡的过程中达到了根节点,则事件冒泡结束;
我们通过以下示例进一步说明事件冒泡的机制:
@@ -80,19 +82,21 @@ render() {
onClick={() => { console.log("根节点 点击"); }}
>
<Text style={{ width: 150, height: 100, backgroundColor: "#FF0000" }}
onClick={() => console.log("点击按钮1 点击")}
onClick={() => console.log("按钮1 点击")}
>
点击按钮1
</Text>
<View style={{ width: 150, height: 100, backgroundColor: "#00FF00" }}
onClick={() => {
console.log("父控件 点击");
// 不再向上冒泡到跟节点
return true;
}}
>
<Text style={{ width: 80, height: 50, backgroundColor: "#0000FF" }}
onClick={() => {
console.log("点击按钮2 点击");
console.log("按钮2 点击");
// 向上冒泡到父控件
return false;
}}
>
@@ -115,6 +119,46 @@ new Hippy({
}).start();
````
## 事件捕获
> 最低支持版本 2.11.2
[[事件捕获范例]](//github.com/Tencent/Hippy/tree/master/examples/hippy-react-demo/src/components/ListView)
点击事件和触屏事件支持事件捕获,如需注册捕获阶段的事件处理函数,则应在目标元素事件名添加 `Capture` 后缀,如 `onClickCapture`、`onTouchDownCapture`。如果目标元素没有`Capture` 事件处理函数,默认不开启捕获。事件捕获会有一定性能损耗,如非必要尽量不开启。
例子如下:
```js
render() {
return (
<View style={{ width: 300, height: 200, backgroundColor: "#FFFFFF" }}
onClick={() => { console.log("根节点 点击"); }}
onClickCapture={() => console.log("根节点 捕获点击")}
>
<Text style={{ width: 150, height: 100, backgroundColor: "#FF0000" }}
onClick={() => {
// 点击按钮1不会触发根节点捕获点击
console.log("按钮1 点击")
}}
>
点击按钮1
</Text>
<View style={{ width: 150, height: 100, backgroundColor: "#00FF00" }}>
<Text style={{ width: 80, height: 50, backgroundColor: "#0000FF" }}
onClickCapture={() => {
// 点击按钮2会触发根节点捕获点击
console.log("按钮2 点击");
}}
>
点击按钮2
</Text>
</View>
</View>
);
}
```
## 事件的拦截
某些场景下,父控件又需要优先拦截到子控件的手势事件,因此 Hippy 也提供了手势事件拦截机制,手势拦截由父控件的两个属性控制 `onInterceptTouchEvent` 和`onInterceptPullUpEvent`,这两个属性仅对能容纳子控件的组件生效,如 `<Image/>` 这种控件就不支持这两个属性:
@@ -66,8 +66,12 @@ const styles = StyleSheet.create({
function Style1({ index }) {
return (
<View style={styles.container}
onClickCapture={() => {
console.log('onClickCapture style1');
}}
onClick={() => {
console.log('click style1');
return false;
}}
>
<Text numberOfLines={1}>{ `${index}: Style 1 UI` }</Text>
@@ -202,7 +206,12 @@ export default class ListExample extends React.Component {
// pass
}
return (
<View style={styles.container}>
<View style={styles.container}
onClick={() => {
console.log('click style outer');
// return false means trigger bubble
return false;
}}>
<View style={styles.itemContainer}>
{styleUI}
</View>
@@ -230,8 +239,13 @@ export default class ListExample extends React.Component {
const { dataSource } = this.state;
return (
<ListView
onClickCapture={() => {
console.log('onClickCapture listview');
}}
onClick={() => {
console.log('click listview');
// return false means trigger bubble
return false;
}}
bounces={true}
overScrollEnabled={true}
+2 -2
View File
@@ -4,8 +4,8 @@ import App from './app';
new Hippy({
appName: 'Demo',
entryPage: App,
// set bubbles, default is false
bubbles: true,
// set global bubbles, default is false
bubbles: false,
// set log output, default is false
silent: false,
}).start();
@@ -25,7 +25,10 @@ export const Entry = () => (
const Comp = item.component;
return (
<Route key={item.path} exact path={`${item.path}`}>
<View style={{ flex: 1, backgroundColor: '#fff' }}>
<View style={{ flex: 1, backgroundColor: '#fff' }}
onClick={() => {
console.log('click router');
}}>
<Header route={item} />
<Comp meta={item.meta || {}} />
</View>
@@ -33,6 +33,7 @@ import {
isNumber,
warn,
convertImgUrl,
isCaptureEvent,
} from '../utils';
import ViewNode from './view-node';
import '@localTypes/global';
@@ -426,6 +427,9 @@ class ElementNode extends ViewNode {
match: () => true,
action: () => {
if (typeof value === 'function') {
if (isCaptureEvent(key)) {
key = key.replace('Capture', '');
}
this.attributes[key] = true;
} else {
this.attributes[key] = value;
+120 -41
View File
@@ -18,8 +18,9 @@
* limitations under the License.
*/
import { Fiber } from 'react-reconciler';
import { getFiberNodeFromId } from '../utils/node';
import { trace, isBubbles } from '../utils';
import { trace, isGlobalBubble, isHostComponent } from '../utils';
import HippyEventHub from './hub';
import '@localTypes/global';
@@ -79,6 +80,111 @@ function receiveNativeEvent(nativeEvent: EventParam) {
currEventHub.notifyEvent(eventParams);
}
interface ListenerObj { listener: Function, isCapture: boolean }
/**
* convertEventName - convert all special event name
* @param eventName
* @param nodeItem
*/
function convertEventName(eventName: string, nodeItem: Fiber) {
let processedEvenName = eventName;
if (nodeItem.memoizedProps
&& !nodeItem.memoizedProps[eventName]
&& eventName === 'onClick'
&& nodeItem.memoizedProps.onPress) {
// Compatible with React Native
processedEvenName = 'onPress';
}
return processedEvenName;
}
function isNodePropFunction(prop: string, nextNodeItem: Fiber) {
return !!(nextNodeItem.memoizedProps && typeof nextNodeItem.memoizedProps[prop] === 'function');
}
/**
* doCaptureAndBubbleLoop - process capture phase and bubble phase
* @param {string} originalEventName
* @param {NativeEvent} nativeEvent
* @param {Fiber} nodeItem
*/
function doCaptureAndBubbleLoop(originalEventName: string, nativeEvent: NativeEvent, nodeItem: Fiber) {
const eventQueue: ListenerObj[] = [];
let nextNodeItem: Fiber | null = nodeItem;
let eventName = originalEventName;
// capture and bubble loop
while (nextNodeItem) {
eventName = convertEventName(eventName, nextNodeItem);
const captureName = `${eventName}Capture`;
if (isNodePropFunction(captureName, nextNodeItem)) {
// capture phase to add listener at queue head
eventQueue.unshift({ listener: nextNodeItem.memoizedProps[captureName], isCapture: true });
}
if (isNodePropFunction(eventName, nextNodeItem)) {
// bubble phase to add listener at queue tail
eventQueue.push({ listener: nextNodeItem.memoizedProps[eventName], isCapture: false });
}
nextNodeItem = nextNodeItem.return;
while (nextNodeItem && !isHostComponent(nextNodeItem.tag)) {
// only handle HostComponent
nextNodeItem = nextNodeItem.return;
}
}
if (eventQueue.length > 0) {
let listenerObj: ListenerObj | undefined;
let isStopBubble: any = false;
while (!isStopBubble && (listenerObj = eventQueue.shift()) !== undefined) {
try {
if (listenerObj.isCapture) {
listenerObj.listener(nativeEvent);
} else {
isStopBubble = listenerObj.listener(nativeEvent);
// If callback have no return, use global bubble config to set isStopBubble.
if (typeof isStopBubble !== 'boolean') {
isStopBubble = !isGlobalBubble();
}
}
} catch (err) {
(console as any).reportUncaughtException(err);
}
}
}
}
/**
* doBubbleLoop - process only bubble phase
* @param {string} originalEventName
* @param {NativeEvent} nativeEvent
* @param {Fiber} nodeItem
*/
function doBubbleLoop(originalEventName: string, nativeEvent: NativeEvent, nodeItem: Fiber) {
let isStopBubble: any = false;
let nextNodeItem: Fiber | null = nodeItem;
let eventName = originalEventName;
// only bubble loop
do {
eventName = convertEventName(eventName, nextNodeItem);
if (isNodePropFunction(eventName, nextNodeItem)) {
try {
isStopBubble = nextNodeItem.memoizedProps[eventName](nativeEvent);
// If callback have no return, use global bubble config to set isStopBubble.
if (typeof isStopBubble !== 'boolean') {
isStopBubble = !isGlobalBubble();
}
} catch (err) {
(console as any).reportUncaughtException(err);
}
}
if (isStopBubble === false) {
nextNodeItem = nextNodeItem.return;
while (nextNodeItem && !isHostComponent(nextNodeItem.tag)) {
// only handle HostComponent
nextNodeItem = nextNodeItem.return;
}
}
} while (!isStopBubble && nextNodeItem);
}
function receiveNativeGesture(nativeEvent: NativeEvent) {
trace(...componentName, 'receiveNativeGesture', nativeEvent);
@@ -90,47 +196,20 @@ function receiveNativeGesture(nativeEvent: NativeEvent) {
if (!targetNode) {
return;
}
let eventHandled: any = false;
let nextNodeItem = targetNode;
let hasCapturePhase: boolean = true;
let { name: eventName } = nativeEvent;
do {
if (nextNodeItem.memoizedProps
&& !nextNodeItem.memoizedProps[eventName]
&& eventName === 'onClick'
&& nextNodeItem.memoizedProps.onPress) {
// Compatible with React Native
eventName = 'onPress';
}
if (nextNodeItem.memoizedProps
&& nextNodeItem.memoizedProps[eventName]
&& typeof nextNodeItem.memoizedProps[eventName] === 'function') {
try {
eventHandled = nextNodeItem.memoizedProps[eventName](nativeEvent);
// If callback have no return, set global bubbles config to eventHandled.
if (typeof eventHandled !== 'boolean') {
eventHandled = !isBubbles();
}
} catch (err) {
(console as any).reportUncaughtException(err); // eslint-disable-line
}
}
// If callback have no return is meaning no need the event bubbling
if (typeof eventHandled !== 'boolean') {
eventHandled = true;
}
if (eventHandled === false) {
// @ts-ignore
nextNodeItem = nextNodeItem.return;
while (nextNodeItem && nextNodeItem.tag !== 5) {
// @ts-ignore
nextNodeItem = nextNodeItem.return;
}
}
} while (!eventHandled && nextNodeItem);
eventName = convertEventName(eventName, targetNode);
const captureName = `${eventName}Capture`;
const nextNodeItem: Fiber = targetNode;
// if current target has no capture listener, only do bubble phase loop to improve performance
if (targetNode.memoizedProps && typeof targetNode.memoizedProps[captureName] !== 'function') {
hasCapturePhase = false;
}
if (hasCapturePhase) {
doCaptureAndBubbleLoop(eventName, nativeEvent, nextNodeItem);
} else {
doBubbleLoop(eventName, nativeEvent, nextNodeItem);
}
}
function receiveUIComponentEvent(nativeEvent: string[]) {
+21 -2
View File
@@ -52,6 +52,15 @@ function unicodeToChar(text: string): string {
return text.replace(/\\u[\dA-F]{4}|\\x[\dA-F]{2}/gi, match => String.fromCharCode(parseInt(match.replace(/\\u|\\x/g, ''), 16)));
}
const captureEventReg = new RegExp('^on.+Capture$');
/**
* ensure capture event name
* @param {any} eventName
*/
function isCaptureEvent(eventName: any) {
return captureEventReg.test(eventName);
}
/**
* Convert to string as possible
*/
@@ -114,7 +123,7 @@ function setBubbles(bubbles: boolean = false): void {
* get bubbles config
* @returns boolean
*/
function isBubbles(): boolean {
function isGlobalBubble(): boolean {
return defaultBubbles;
}
@@ -134,15 +143,25 @@ function convertImgUrl(url: string): string {
return url;
}
/**
* isHostComponent - judge current tag is hostComponent type
* @param {number} tag
*/
function isHostComponent(tag: number) {
return tag === 5;
}
export {
trace,
warn,
unicodeToChar,
tryConvertNumber,
isCaptureEvent,
isFunction,
isNumber,
setSilent,
setBubbles,
isBubbles,
isGlobalBubble,
convertImgUrl,
isHostComponent,
};