diff --git a/Libraries/CustomComponents/Navigator/Navigation/NavigationContext.js b/Libraries/CustomComponents/Navigator/Navigation/NavigationContext.js index d241e86fb..dfce209bf 100644 --- a/Libraries/CustomComponents/Navigator/Navigation/NavigationContext.js +++ b/Libraries/CustomComponents/Navigator/Navigation/NavigationContext.js @@ -26,30 +26,43 @@ */ 'use strict'; +var NavigationEvent = require('NavigationEvent'); var NavigationEventEmitter = require('NavigationEventEmitter'); var NavigationTreeNode = require('NavigationTreeNode'); var emptyFunction = require('emptyFunction'); var invariant = require('invariant'); -import type * as NavigationEvent from 'NavigationEvent'; import type * as EventSubscription from 'EventSubscription'; +var { + AT_TARGET, + BUBBLING_PHASE, + CAPTURING_PHASE, +} = NavigationEvent; + /** * Class that contains the info and methods for app navigation. */ class NavigationContext { __node: NavigationTreeNode; - _eventEmitter: ?NavigationEventEmitter; + _bubbleEventEmitter: ?NavigationEventEmitter; + _captureEventEmitter: ?NavigationEventEmitter; _currentRoute: any; + _emitCounter: number; + _emitQueue: Array; constructor() { - this._eventEmitter = new NavigationEventEmitter(this); + this._bubbleEventEmitter = new NavigationEventEmitter(this); + this._captureEventEmitter = new NavigationEventEmitter(this); this._currentRoute = null; // Sets the protected property `__node`. this.__node = new NavigationTreeNode(this); + this._emitCounter = 0; + this._emitQueue = []; + this.addListener('willfocus', this._onFocus, this); this.addListener('didfocus', this._onFocus, this); } @@ -72,9 +85,12 @@ class NavigationContext { addListener( eventType: string, listener: Function, - context: ?Object + context: ?Object, + useCapture: ?boolean ): EventSubscription { - var emitter = this._eventEmitter; + var emitter = useCapture ? + this._captureEventEmitter : + this._bubbleEventEmitter; if (emitter) { return emitter.addListener(eventType, listener, context); } else { @@ -83,19 +99,115 @@ class NavigationContext { } emit(eventType: String, data: any, didEmitCallback: ?Function): void { - var emitter = this._eventEmitter; - if (emitter) { - emitter.emit(eventType, data, didEmitCallback); + if (this._emitCounter > 0) { + // An event cycle that was previously created hasn't finished yet. + // Put this event cycle into the queue and will finish them later. + var args: any = Array.prototype.slice.call(arguments); + this._emitQueue.push(args); + return; + } + + this._emitCounter++; + + var targets = [this]; + var parentTarget = this.parent; + while (parentTarget) { + targets.unshift(parentTarget); + parentTarget = parentTarget.parent; + } + + var propagationStopped = false; + var defaultPrevented = false; + var callback = (event) => { + propagationStopped = propagationStopped || event.isPropagationStopped(); + defaultPrevented = defaultPrevented || event.defaultPrevented; + }; + + // capture phase + targets.some((currentTarget) => { + if (propagationStopped) { + return true; + } + + var extraInfo = { + defaultPrevented, + eventPhase: CAPTURING_PHASE, + propagationStopped, + target: this, + }; + + currentTarget.__emit(eventType, data, callback, extraInfo); + }, this); + + // bubble phase + targets.reverse().some((currentTarget) => { + if (propagationStopped) { + return true; + } + var extraInfo = { + defaultPrevented, + eventPhase: BUBBLING_PHASE, + propagationStopped, + target: this, + }; + currentTarget.__emit(eventType, data, callback, extraInfo); + }, this); + + if (didEmitCallback) { + var event = NavigationEvent.pool(eventType, this, data); + propagationStopped && event.stopPropagation(); + defaultPrevented && event.preventDefault(); + didEmitCallback.call(this, event); + event.dispose(); + } + + this._emitCounter--; + while (this._emitQueue.length) { + var args: any = this._emitQueue.shift(); + this.emit.apply(this, args); } } dispose(): void { - var emitter = this._eventEmitter; + // clean up everything. + this._bubbleEventEmitter && this._bubbleEventEmitter.removeAllListeners(); + this._captureEventEmitter && this._captureEventEmitter.removeAllListeners(); + this._bubbleEventEmitter = null; + this._captureEventEmitter = null; + this._currentRoute = null; + } + + // This method `__method` is protected. + __emit( + eventType: String, + data: any, + didEmitCallback: ?Function, + extraInfo: Object, + ): void { + var emitter; + switch (extraInfo.eventPhase) { + case CAPTURING_PHASE: // phase = 1 + emitter = this._captureEventEmitter; + break; + case BUBBLING_PHASE: // phase = 3 + emitter = this._bubbleEventEmitter; + break; + default: + invariant(false, 'invalid event phase %s', extraInfo.eventPhase); + } + + if (extraInfo.target === this) { + // phase = 2 + extraInfo.eventPhase = AT_TARGET; + } + if (emitter) { - // clean up everything. - emitter.removeAllListeners(); - this._eventEmitter = null; - this._currentRoute = null; + emitter.emit( + eventType, + data, + didEmitCallback, + extraInfo + ); } } diff --git a/Libraries/CustomComponents/Navigator/Navigation/NavigationEvent.js b/Libraries/CustomComponents/Navigator/Navigation/NavigationEvent.js index 474336b65..6e27f5d5e 100644 --- a/Libraries/CustomComponents/Navigator/Navigation/NavigationEvent.js +++ b/Libraries/CustomComponents/Navigator/Navigation/NavigationEvent.js @@ -36,13 +36,13 @@ class NavigationEventPool { this._list = []; } - get(type: string, target: Object, data: any): NavigationEvent { + get(type: string, currentTarget: Object, data: any): NavigationEvent { var event; if (this._list.length > 0) { event = this._list.pop(); - event.constructor.call(event, type, target, data); + event.constructor.call(event, type, currentTarget, data); } else { - event = new NavigationEvent(type, target, data); + event = new NavigationEvent(type, currentTarget, data); } return event; } @@ -54,21 +54,57 @@ class NavigationEventPool { var _navigationEventPool = new NavigationEventPool(); +/** + * The NavigationEvent interface represents any event of the navigation. + * It contains common properties and methods to any event. + * + * == Important Properties == + * + * - target: + * A reference to the navigation context that dispatched the event. It is + * different from event.currentTarget when the event handler is called during + * the bubbling or capturing phase of the event. + * + * - currentTarget: + * Identifies the current target for the event, as the event traverses the + * navigation context tree. It always refers to the navigation context the + * event handler has been attached to as opposed to event.target which + * identifies the navigation context on which the event occurred. + * + * - eventPhase: + * Returns an integer value which specifies the current evaluation phase of + * the event flow; possible values are listed in NavigationEvent phase + * constants below. + */ class NavigationEvent { + static AT_TARGET: number; + static BUBBLING_PHASE: number; + static CAPTURING_PHASE: number; + static NONE: number; + + _currentTarget: ?Object; _data: any; _defaultPrevented: boolean; - _propagationStopped: boolean; _disposed: boolean; - _target: ?Object; + _propagationStopped: boolean; _type: ?string; - static pool(type: string, target: Object, data: any): NavigationEvent { - return _navigationEventPool.get(type, target, data); + target: ?Object; + + // Returns an integer value which specifies the current evaluation phase of + // the event flow. + eventPhase: number; + + static pool(type: string, currentTarget: Object, data: any): NavigationEvent { + return _navigationEventPool.get(type, currentTarget, data); } - constructor(type: string, target: Object, data: any) { + constructor(type: string, currentTarget: Object, data: any) { + this.target = currentTarget; + this.eventPhase = NavigationEvent.NONE; + this._type = type; - this._target = target; + this._currentTarget = currentTarget; this._data = data; this._defaultPrevented = false; this._disposed = false; @@ -81,8 +117,8 @@ class NavigationEvent { } /* $FlowFixMe - get/set properties not yet supported */ - get target(): Object { - return this._target; + get currentTarget(): Object { + return this._currentTarget; } /* $FlowFixMe - get/set properties not yet supported */ @@ -122,8 +158,10 @@ class NavigationEvent { this._disposed = true; // Clean up. + this.target = null; + this.eventPhase = NavigationEvent.NONE; this._type = null; - this._target = null; + this._currentTarget = null; this._data = null; this._defaultPrevented = false; @@ -132,4 +170,26 @@ class NavigationEvent { } } +/** + * Event phase constants. + * These values describe which phase the event flow is currently being + * evaluated. + */ + +// No event is being processed at this time. +NavigationEvent.NONE = 0; + +// The event is being propagated through the currentTarget's ancestor objects. +NavigationEvent.CAPTURING_PHASE = 1; + +// The event has arrived at the event's currentTarget. Event listeners registered for +// this phase are called at this time. +NavigationEvent.AT_TARGET = 2; + +// The event is propagating back up through the currentTarget's ancestors in reverse +// order, starting with the parent. This is known as bubbling, and occurs only +// if event propagation isn't prevented. Event listeners registered for this +// phase are triggered during this process. +NavigationEvent.BUBBLING_PHASE = 3; + module.exports = NavigationEvent; diff --git a/Libraries/CustomComponents/Navigator/Navigation/NavigationEventEmitter.js b/Libraries/CustomComponents/Navigator/Navigation/NavigationEventEmitter.js index a379edd7a..a2abd3d55 100644 --- a/Libraries/CustomComponents/Navigator/Navigation/NavigationEventEmitter.js +++ b/Libraries/CustomComponents/Navigator/Navigation/NavigationEventEmitter.js @@ -30,14 +30,15 @@ var EventEmitter = require('EventEmitter'); var NavigationEvent = require('NavigationEvent'); -type EventParams = { - data: any; - didEmitCallback: ?Function; - eventType: string; +type ExtraInfo = { + defaultPrevented: ?boolean, + eventPhase: ?number, + propagationStopped: ?boolean, + target: ?Object, }; class NavigationEventEmitter extends EventEmitter { - _emitQueue: Array; + _emitQueue: Array; _emitting: boolean; _target: Object; @@ -51,18 +52,38 @@ class NavigationEventEmitter extends EventEmitter { emit( eventType: string, data: any, - didEmitCallback: ?Function + didEmitCallback: ?Function, + extraInfo: ?ExtraInfo ): void { if (this._emitting) { // An event cycle that was previously created hasn't finished yet. // Put this event cycle into the queue and will finish them later. - this._emitQueue.push({eventType, data, didEmitCallback}); + var args: any = Array.prototype.slice.call(arguments); + this._emitQueue.unshift(args); return; } this._emitting = true; - var event = new NavigationEvent(eventType, this._target, data); + var event = NavigationEvent.pool(eventType, this._target, data); + + if (extraInfo) { + if (extraInfo.target) { + event.target = extraInfo.target; + } + + if (extraInfo.eventPhase) { + event.eventPhase = extraInfo.eventPhase; + } + + if (extraInfo.defaultPrevented) { + event.preventDefault(); + } + + if (extraInfo.propagationStopped) { + event.stopPropagation(); + } + } // EventEmitter#emit only takes `eventType` as `String`. Casting `eventType` // to `String` to make @flow happy. @@ -76,8 +97,8 @@ class NavigationEventEmitter extends EventEmitter { this._emitting = false; while (this._emitQueue.length) { - var arg = this._emitQueue.shift(); - this.emit(arg.eventType, arg.data, arg.didEmitCallback); + var args: any = this._emitQueue.shift(); + this.emit.apply(this, args); } } } diff --git a/Libraries/CustomComponents/Navigator/Navigation/__tests__/NavigationContext-test.js b/Libraries/CustomComponents/Navigator/Navigation/__tests__/NavigationContext-test.js index 8df287f3b..37eb820f8 100644 --- a/Libraries/CustomComponents/Navigator/Navigation/__tests__/NavigationContext-test.js +++ b/Libraries/CustomComponents/Navigator/Navigation/__tests__/NavigationContext-test.js @@ -29,6 +29,7 @@ jest .mock('ErrorUtils'); var NavigationContext = require('NavigationContext'); +var NavigationEvent = require('NavigationEvent'); describe('NavigationContext', () => { it('defaults `currentRoute` to null', () => { @@ -48,4 +49,206 @@ describe('NavigationContext', () => { parent.appendChild(child); expect(child.parent).toBe(parent); }); + + it('captures event', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var logs = []; + + var listener = (event) => { + var {currentTarget, eventPhase, target, type} = event; + logs.push({ + currentTarget, + eventPhase, + target, + type, + }); + }; + + parent.addListener('yo', listener, null, true); + child.addListener('yo', listener, null, true); + + child.emit('yo'); + + expect(logs).toEqual([ + { + currentTarget: parent, + eventPhase: NavigationEvent.CAPTURING_PHASE, + target: child, + type: 'yo', + }, + { + currentTarget: child, + eventPhase: NavigationEvent.AT_TARGET, + target: child, + type: 'yo', + } + ]); + }); + + it('bubbles events', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var logs = []; + + var listener = (event) => { + var {currentTarget, eventPhase, target, type} = event; + logs.push({ + currentTarget, + eventPhase, + target, + type, + }); + }; + + parent.addListener('yo', listener); + child.addListener('yo', listener); + + child.emit('yo'); + + expect(logs).toEqual([ + { + currentTarget: child, + eventPhase: NavigationEvent.AT_TARGET, + target: child, + type: 'yo', + }, + { + currentTarget: parent, + eventPhase: NavigationEvent.BUBBLING_PHASE, + target: child, + type: 'yo', + }, + ]); + }); + + it('stops event propagation at capture phase', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var counter = 0; + + parent.addListener('yo', event => event.stopPropagation(), null, true); + child.addListener('yo', event => counter++, null, true); + + child.emit('yo'); + + expect(counter).toBe(0); + }); + + it('stops event propagation at bubbling phase', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var counter = 0; + + parent.addListener('yo', event => counter++); + child.addListener('yo', event => event.stopPropagation()); + + child.emit('yo'); + + expect(counter).toBe(0); + }); + + it('prevents event at capture phase', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var val; + parent.addListener('yo', event => event.preventDefault(), null, true); + child.addListener('yo', event => val = event.defaultPrevented, null, true); + + child.emit('yo'); + + expect(val).toBe(true); + }); + + it('prevents event at bubble phase', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var val; + parent.addListener('yo', event => val = event.defaultPrevented); + child.addListener('yo', event => event.preventDefault()); + + child.emit('yo'); + + expect(val).toBe(true); + }); + + it('emits nested events in order at capture phase', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var logs = []; + + var listener = (event) => { + var {currentTarget, type} = event; + logs.push({ + currentTarget, + type, + }); + }; + + child.addListener('yo', event => { + // event `didyo` should be fired after the full propagation cycle of the + // `yo` event. + child.emit('didyo'); + }); + + parent.addListener('yo', listener, null, true); + parent.addListener('didyo', listener, null, true); + child.addListener('yo', listener, null, true); + + child.emit('yo'); + + expect(logs).toEqual([ + {type: 'yo', currentTarget: parent}, + {type: 'yo', currentTarget: child}, + {type: 'didyo', currentTarget: parent}, + ]); + }); + + it('emits nested events in order at bubbling phase', () => { + var parent = new NavigationContext(); + var child = new NavigationContext(); + parent.appendChild(child); + + var logs = []; + + var listener = (event) => { + var {currentTarget, type} = event; + logs.push({ + currentTarget, + type, + }); + }; + + child.addListener('yo', event => { + // event `didyo` should be fired after the full propagation cycle of the + // `yo` event. + child.emit('didyo'); + }); + + parent.addListener('yo', listener); + child.addListener('yo', listener); + parent.addListener('didyo', listener); + + child.emit('yo'); + + expect(logs).toEqual([ + {type: 'yo', currentTarget: child}, + {type: 'yo', currentTarget: parent}, + {type: 'didyo', currentTarget: parent}, + ]); + }); });