diff --git a/apps/automated/src/xml-declaration/xml-declaration-tests.ts b/apps/automated/src/xml-declaration/xml-declaration-tests.ts index a2a623ea4..a0e13e211 100644 --- a/apps/automated/src/xml-declaration/xml-declaration-tests.ts +++ b/apps/automated/src/xml-declaration/xml-declaration-tests.ts @@ -409,7 +409,7 @@ export function test_parse_ShouldParseBindingsToGestures() { var observer = (lbl).getGestureObservers(GestureTypes.tap)[0]; TKUnit.assert(observer !== undefined, 'Expected result: true.'); - TKUnit.assert(observer.context === context, 'Context should be equal to binding context. Actual result: ' + observer.context); + TKUnit.assert(observer.observer.context === context, 'Context should be equal to binding context. Actual result: ' + observer.observer.context); } export function test_parse_ShouldParseBindingsToGesturesWithOn() { @@ -426,7 +426,7 @@ export function test_parse_ShouldParseBindingsToGesturesWithOn() { var observer = (lbl).getGestureObservers(GestureTypes.tap)[0]; TKUnit.assert(observer !== undefined, 'Expected result: true.'); - TKUnit.assert(observer.context === context, 'Context should be equal to binding context. Actual result: ' + observer.context); + TKUnit.assert(observer.observer.context === context, 'Context should be equal to binding context. Actual result: ' + observer.observer.context); } export function test_parse_ShouldParseSubProperties() { diff --git a/packages/core/accessibility/index.android.ts b/packages/core/accessibility/index.android.ts index b36f3bc77..cae004e42 100644 --- a/packages/core/accessibility/index.android.ts +++ b/packages/core/accessibility/index.android.ts @@ -1,8 +1,9 @@ import * as Application from '../application'; +import { DOMEvent } from '../data/dom-events/dom-event'; import { Trace } from '../trace'; import { SDK_VERSION } from '../utils/constants'; import type { View } from '../ui/core/view'; -import { GestureTypes } from '../ui/gestures'; +import { GestureEventData, GestureTypes } from '../ui/gestures'; import { notifyAccessibilityFocusState } from './accessibility-common'; import { getAndroidAccessibilityManager } from './accessibility-service'; import { AccessibilityRole, AccessibilityState, AndroidAccessibilityEvent } from './accessibility-types'; @@ -54,17 +55,18 @@ function accessibilityEventHelper(view: Partial, eventType: number) { * These aren't triggered for custom tap events in NativeScript. */ if (SDK_VERSION >= 26) { - // Find all tap gestures and trigger them. - for (const tapGesture of view.getGestureObservers(GestureTypes.tap) ?? []) { - tapGesture.callback({ + // Trigger all tap handlers on this view. + new DOMEvent('tap').dispatchTo({ + target: view as View, + data: { android: view.android, eventName: 'tap', ios: null, object: view, type: GestureTypes.tap, - view: view, - }); - } + view, + } as GestureEventData, + }); } return; diff --git a/packages/core/data/dom-events/dom-event.ts b/packages/core/data/dom-events/dom-event.ts new file mode 100644 index 000000000..ea8e90cc5 --- /dev/null +++ b/packages/core/data/dom-events/dom-event.ts @@ -0,0 +1,379 @@ +import type { EventData, ListenerEntry, Observable } from '../observable/index'; +import type { ViewBase } from '../../ui/core/view-base'; + +const timeOrigin = Date.now(); + +/** + * Purely a performance utility. We fall back to an empty array on various + * optional accesses, so reusing the same one and treating it as immutable + * avoids unnecessary allocations on a relatively hot path of the library. + */ +const emptyArray = [] as const; + +export class DOMEvent { + readonly NONE = 0; + readonly CAPTURING_PHASE = 1; + readonly AT_TARGET = 2; + readonly BUBBLING_PHASE = 3; + + /** + * Returns true or false depending on how event was initialized. Its return + * value does not always carry meaning, but true can indicate that part of + * the operation during which event was dispatched, can be canceled by + * invoking the preventDefault() method. + */ + readonly cancelable: boolean = false; + + /** + * Returns true or false depending on how event was initialized. True if + * event goes through its target's ancestors in reverse tree order, and + * false otherwise. + */ + readonly bubbles: boolean = false; + + private _canceled = false; + + /** @deprecated Setting this value does nothing. */ + cancelBubble = false; + + /** + * Returns true or false depending on how event was initialized. True if + * event invokes listeners past a ShadowRoot node that is the root of its + * target, and false otherwise. + */ + readonly composed: boolean; + + /** + * Returns true if event was dispatched by the user agent, and false + * otherwise. + * For now, all NativeScript events will have isTrusted: false. + */ + readonly isTrusted: boolean = false; + + /** @deprecated Use defaultPrevented instead. */ + get returnValue() { + return !this.defaultPrevented; + } + + /** + * Returns the event's timestamp as the number of milliseconds measured + * relative to the time origin. + */ + readonly timeStamp: DOMHighResTimeStamp = timeOrigin - Date.now(); + + /** @deprecated */ + get srcElement(): Observable | null { + return this.target; + } + + /** + * Returns true if preventDefault() was invoked successfully to indicate + * cancelation, and false otherwise. + */ + get defaultPrevented() { + return this._canceled; + } + + private _eventPhase: 0 | 1 | 2 | 3 = this.NONE; + /** + * Returns the event's phase, which is one of NONE, CAPTURING_PHASE, + * AT_TARGET, and BUBBLING_PHASE. + */ + get eventPhase() { + return this._eventPhase; + } + private set eventPhase(value: 0 | 1 | 2 | 3) { + this._eventPhase = value; + } + + private _currentTarget: Observable | null = null; + /** + * Returns the object whose event listener's callback is currently being + * invoked. + */ + get currentTarget() { + return this._currentTarget; + } + private set currentTarget(value: Observable | null) { + this._currentTarget = value; + } + + private _target: Observable | null = null; + /** Returns the object to which event is dispatched (its target). */ + get target() { + return this._target; + } + private set target(value: Observable | null) { + this._target = value; + } + + // From CustomEvent rather than Event. Can consider factoring out this + // aspect into DOMCustomEvent. + private readonly detail: unknown | null; + + private propagationState: EventPropagationState = EventPropagationState.resume; + + constructor( + /** + * Returns the type of event, e.g. "click", "hashchange", or "submit". + */ + public type: string, + options: CustomEventInit = {} + ) { + const { bubbles = false, cancelable = false, composed = false, detail = null } = options; + + this.bubbles = bubbles; + this.cancelable = cancelable; + this.composed = composed; + this.detail = detail; + } + + /** + * Returns the invocation target objects of event's path (objects on which + * listeners will be invoked), except for any nodes in shadow trees of which + * the shadow root's mode is "closed" that are not reachable from event's + * currentTarget. + */ + composedPath(): Observable[] { + if (!this.target) { + return []; + } + + // Walk up the target's parents if it has parents (is a ViewBase or + // subclass of ViewBase) or not (is an Observable). + return this.target.isViewBase() ? this.getEventPath(this.target, 'bubble') : [this.target]; + } + + /** + * Returns the event path by walking up the target's parents. + * + * - 'capture' paths are ordered from root to target. + * - 'bubble' paths are ordered from target to root. + * @example + * [Page, StackLayout, Button] // 'capture' + * @example + * [Button, StackLayout, Page] // 'bubble' + */ + private getEventPath(responder: ViewBase, path: 'capture' | 'bubble'): ViewBase[] { + const chain = [responder]; + let nextResponder = responder.parent; + while (nextResponder) { + path === 'capture' ? chain.unshift(nextResponder) : chain.push(nextResponder); + + // TODO: decide whether to walk up from Page to Frame, and whether + // to then walk from Frame to Application or something. + nextResponder = nextResponder?.parent; + } + return chain; + } + + /** @deprecated */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + initEvent(type: string, bubbles?: boolean, cancelable?: boolean): void { + // This would be trivial to implement, but it's quite nice for `bubbles` + // and `cancelable` to not have backing variables. + throw new Error('Deprecated; use Event() instead.'); + } + + /** + * If invoked when the cancelable attribute value is true, and while + * executing a listener for the event with passive set to false, signals to + * the operation that caused event to be dispatched that it needs to be + * canceled. + */ + preventDefault(): void { + if (!this.cancelable) { + return; + } + this._canceled = true; + } + /** + * Invoking this method prevents event from reaching any registered event + * listeners after the current one finishes running and, when dispatched in + * a tree, also prevents event from reaching any other objects. + */ + stopImmediatePropagation(): void { + this.propagationState = EventPropagationState.stopImmediate; + } + /** + * When dispatched in a tree, invoking this method prevents event from + * reaching any objects other than the current object. + */ + stopPropagation(): void { + this.propagationState = EventPropagationState.stop; + } + + /** + * Dispatches a synthetic event event to target and returns true if either + * event's cancelable attribute value is false or its preventDefault() + * method was not invoked, and false otherwise. + */ + dispatchTo({ target, data, getGlobalEventHandlersPreHandling, getGlobalEventHandlersPostHandling }: { target: Observable; data: EventData; getGlobalEventHandlersPreHandling?: () => readonly ListenerEntry[]; getGlobalEventHandlersPostHandling?: () => readonly ListenerEntry[] }): boolean { + if (this.eventPhase !== this.NONE) { + throw new Error('Tried to dispatch a dispatching event'); + } + this.eventPhase = this.CAPTURING_PHASE; + this.target = target; + this._canceled = false; + + /** + * Resets any internal state to allow the event to be redispatched. Call + * this before returning. + */ + const reset = () => { + this.currentTarget = null; + this.target = null; + this.eventPhase = this.NONE; + this.propagationState = EventPropagationState.resume; + }; + + // `Observable.removeEventListener` would likely suffice, but grabbing + // the static method named `removeEventListener` on the target's class + // allows us to be robust to the possiblity of the case of the target + // overriding it (however unlikely). + const removeGlobalEventListener = (target.constructor as unknown as typeof target).removeEventListener.bind(target.constructor) as Observable['removeEventListener']; + + // Global event handlers are a NativeScript-only concept, so we'll not + // try to add new formal event phases for them (as that could break DOM + // libraries expecting strictly four phases). + // + // Instead, events handled by global event handlers will exhibit the + // following values: + // - For 'pre-handling phase' global event handlers: + // - eventPhase: CAPTURING_PHASE + // - currentTarget: null + // - For 'post-handling phase' global event handlers: + // - eventPhase: BUBBLING_PHASE + // - currentTarget: The value of currentTarget following the capturing + // and bubbling phases. + // So effectively, we don't make any changes when handling a global + // event. This keeps behaviour as consistent with DOM Events as + // possible. + + this.handleEvent({ + data, + isGlobal: true, + getListenersForType: () => getGlobalEventHandlersPreHandling?.() ?? emptyArray, + removeEventListener: removeGlobalEventListener, + phase: this.CAPTURING_PHASE, + }); + + const eventPath = target.isViewBase() ? this.getEventPath(target, 'capture') : [target]; + + // Capturing phase, e.g. [Page, StackLayout, Button] + for (const currentTarget of eventPath) { + this.currentTarget = currentTarget; + this.eventPhase = this.target === this.currentTarget ? this.AT_TARGET : this.CAPTURING_PHASE; + + this.handleEvent({ + data, + isGlobal: false, + getListenersForType: () => currentTarget.getEventList(this.type) ?? emptyArray, + removeEventListener: currentTarget.removeEventListener.bind(currentTarget) as Observable['removeEventListener'], + phase: this.CAPTURING_PHASE, + }); + if (this.propagationState !== EventPropagationState.resume) { + reset(); + return this.returnValue; + } + } + + // Bubbling phase, e.g. [Button, StackLayout, Page] + // It's correct to dispatch the event to the target during both phases. + for (const currentTarget of eventPath.reverse()) { + this.currentTarget = currentTarget; + this.eventPhase = this.target === this.currentTarget ? this.AT_TARGET : this.BUBBLING_PHASE; + + this.handleEvent({ + data, + isGlobal: false, + getListenersForType: () => currentTarget.getEventList(this.type) ?? emptyArray, + removeEventListener: currentTarget.removeEventListener.bind(currentTarget) as Observable['removeEventListener'], + phase: this.BUBBLING_PHASE, + }); + if (this.propagationState !== EventPropagationState.resume) { + reset(); + return this.returnValue; + } + + // If the event doesn't bubble, then, having dispatched it at the + // target (the first iteration of this loop) we don't let it + // propagate any further. + if (!this.bubbles) { + reset(); + break; + } + + // Restore event phase in case it changed to AT_TARGET during + // this.handleEvent(). + this.eventPhase = this.BUBBLING_PHASE; + } + + this.handleEvent({ + data, + isGlobal: true, + getListenersForType: () => getGlobalEventHandlersPostHandling?.() ?? emptyArray, + removeEventListener: removeGlobalEventListener, + phase: this.BUBBLING_PHASE, + }); + + reset(); + return this.returnValue; + } + + private handleEvent({ data, isGlobal, getListenersForType, phase, removeEventListener }: { data: EventData; isGlobal: boolean; getListenersForType: () => readonly ListenerEntry[]; phase: 0 | 1 | 2 | 3; removeEventListener: (eventName: string, callback?: any, thisArg?: any, capture?: boolean) => void }) { + // Work on a copy of the array, as any callback could modify the + // original array during the loop. + const listenersForTypeCopy = getListenersForType().slice(); + + for (let i = listenersForTypeCopy.length - 1; i >= 0; i--) { + const listener = listenersForTypeCopy[i]; + const { callback, capture, thisArg, once, passive } = listener; + + // The event listener may have been removed since we took a copy of + // the array, so bail out if so. + // + // We simply use a strict equality check here because we trust that + // the listeners provider will never allow two deeply-equal + // listeners into the array. + if (!getListenersForType().includes(listener)) { + continue; + } + + // Handle only the events appropriate to the phase. Global events + // (a NativeScript-only concept) are allowed to be handled + // regardless of phase, for backwards-compatibility. + if (!isGlobal && ((phase === this.CAPTURING_PHASE && !capture) || (phase === this.BUBBLING_PHASE && capture))) { + continue; + } + + if (once) { + removeEventListener(this.type, callback, thisArg, capture); + } + + // Consistent with the original implementation, we only apply + // context to the function if thisArg is truthy. + const returnValue = callback.apply(thisArg || undefined, [data]); + + // This ensures that errors thrown inside asynchronous functions do + // not get swallowed. + if (returnValue instanceof Promise) { + returnValue.catch(console.error); + } + + if (passive && event.defaultPrevented) { + console.warn('Unexpected call to event.preventDefault() in passive event listener.'); + } + + if (this.propagationState === EventPropagationState.stopImmediate) { + return; + } + } + } +} + +enum EventPropagationState { + resume, + stop, + stopImmediate, +} diff --git a/packages/core/data/observable-array/index.ts b/packages/core/data/observable-array/index.ts index 03a5d7e18..a471c2a83 100644 --- a/packages/core/data/observable-array/index.ts +++ b/packages/core/data/observable-array/index.ts @@ -428,8 +428,9 @@ export interface ObservableArray { * @param eventNames - String corresponding to events (e.g. "propertyChange"). Optionally could be used more events separated by `,` (e.g. "propertyChange", "change"). * @param callback - Callback function which will be executed when event is raised. * @param thisArg - An optional parameter which will be used as `this` context for callback execution. + * @param options An optional parameter. If passed as a boolean, configures the useCapture value. Otherwise, specifies options. */ - on(eventNames: string, callback: (data: EventData) => void, thisArg?: any): void; + on(eventNames: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; - on(event: 'change', callback: (args: ChangedData) => void, thisArg?: any): void; + on(event: 'change', callback: (args: ChangedData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; } diff --git a/packages/core/data/observable/index.d.ts b/packages/core/data/observable/index.d.ts index 086aedaf8..bfe752a53 100644 --- a/packages/core/data/observable/index.d.ts +++ b/packages/core/data/observable/index.d.ts @@ -35,6 +35,11 @@ export interface PropertyChangeData extends EventData { oldValue?: any; } +export interface ListenerEntry extends AddEventListenerOptions { + callback: (data: EventData) => void; + thisArg: any; +} + /** * Helper class that is used to fire property change even when real object is the same. * By default property change will not be fired for a same object. @@ -85,42 +90,45 @@ export class Observable { * @param eventNames - String corresponding to events (e.g. "propertyChange"). Optionally could be used more events separated by `,` (e.g. "propertyChange", "change"). * @param callback - Callback function which will be executed when event is raised. * @param thisArg - An optional parameter which will be used as `this` context for callback execution. + * @param options An optional parameter. If passed as a boolean, configures the useCapture value. Otherwise, specifies options. */ - on(eventNames: string, callback: (data: EventData) => void, thisArg?: any); - - static on(eventName: string, callback: any, thisArg?: any): void; + on(eventNames: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Raised when a propertyChange occurs. */ - on(event: 'propertyChange', callback: (data: EventData) => void, thisArg?: any); + on(event: 'propertyChange', callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; + + static on(eventName: string, callback: (data: EventData) => void, thisArg?: any, capture?: boolean): void; /** * Adds one-time listener function for the event named `event`. * @param event Name of the event to attach to. * @param callback A function to be called when the specified event is raised. * @param thisArg An optional parameter which when set will be used as "this" in callback method call. + * @param options An optional parameter. If passed as a boolean, configures the useCapture value. Otherwise, specifies options. */ - once(event: string, callback: (data: EventData) => void, thisArg?: any); + once(event: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; - static once(eventName: string, callback: any, thisArg?: any): void; + static once(eventName: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Shortcut alias to the removeEventListener method. */ - off(eventNames: string, callback?: any, thisArg?: any); + off(eventNames: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void; - static off(eventName: string, callback?: any, thisArg?: any): void; + static off(eventName: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void; /** * Adds a listener for the specified event name. * @param eventNames Comma delimited names of the events to attach the listener to. * @param callback A function to be called when some of the specified event(s) is raised. * @param thisArg An optional parameter which when set will be used as "this" in callback method call. + * @param options An optional parameter. If passed as a boolean, configures the useCapture value. Otherwise, specifies options. */ - addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any); + addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; - static addEventListener(eventName: string, callback: any, thisArg?: any): void; + static addEventListener(eventName: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Removes listener(s) for the specified event name. @@ -128,9 +136,9 @@ export class Observable { * @param callback An optional parameter pointing to a specific listener. If not defined, all listeners for the event names will be removed. * @param thisArg An optional parameter which when set will be used to refine search of the correct callback which will be removed as event listener. */ - removeEventListener(eventNames: string, callback?: any, thisArg?: any); + removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void; - static removeEventListener(eventName: string, callback?: any, thisArg?: any): void; + static removeEventListener(eventName: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void; /** * Updates the specified property with the provided value. @@ -148,10 +156,52 @@ export class Observable { get(name: string): any; /** - * Notifies all the registered listeners for the event provided in the data.eventName. + * Notifies all the registered listeners for the event provided in the + * data.eventName. + * + * Old behaviour (for reference): + * - pre-handling phase: Notifies all observers registered globally, i.e. + * for the given event name on the given class name (or all class names) + * with the eventName suffix 'First'. + * + * - handling phase: Notifies all observers registered on the Observable + * itself. + * + * - post-handling phase: Notifies all observers registered globally, i.e. + * for the given event name on the given class name (or all class names) + * without any eventName suffix. + * + * + * New behaviour (based on DOM, but backwards-compatible): + * - pre-handling phase: Same as above. + * + * - capturing phase: Calls the callback for event listeners registered on + * each ancestor of the target in turn (starting with the most ancestral), + * but not the target itself. + * + * - at-target phase: Calls the callback for event listeners registered on + * the target. Equivalent to the old 'handling phase'. + * + * - bubbling phase: Calls the callback for event listeners registered on + * each ancestor of the target (again, not the target itself) in turn, + * starting with the immediate parent. + * + * - post-handling phase: Same as above. + * + * - The progragation can be stopped in any of these phases using + * event.stopPropagation() or event.stopImmediatePropagation(). + * + * The old behaviour is the default. That is to say, by taking the default + * option of { bubbles: false } and ensuring that any event listeners added + * also use the default option of { capture: false }, then the event will + * go through just the pre-handling, at-target, and post-handling phases. As + * long as none of the new DOM-specific features like stopPropagation() are + * used, it will behave equivalently. + * * @param data The data associated with the event. + * @param options Options for the event, in line with DOM Standard. */ - notify(data: T): void; + notify(data: T, options?: CustomEventInit): boolean; /** * Notifies all the registered listeners for the property change event. @@ -177,6 +227,11 @@ export class Observable { * @private */ public _isViewBase: boolean; + /** + * Type predicate to accompany the _isViewBase property. + * @private + */ + public isViewBase(): this is boolean; //@endprivate } diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 0ad4c63fb..1de1b6746 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -1,3 +1,6 @@ +import type { ViewBase } from '../../ui/core/view-base'; +import { DOMEvent } from '../dom-events/dom-event'; + import { Observable as ObservableDefinition, WrappedValue as WrappedValueDefinition } from '.'; export interface EventData { @@ -20,10 +23,9 @@ export interface PropertyChangeData extends EventData { oldValue?: any; } -interface ListenerEntry { +export interface ListenerEntry extends AddEventListenerOptions { callback: (data: EventData) => void; thisArg: any; - once?: true; } let _wrappedIndex = 0; @@ -45,13 +47,20 @@ export class WrappedValue implements WrappedValueDefinition { const _wrappedValues = [new WrappedValue(null), new WrappedValue(null), new WrappedValue(null), new WrappedValue(null), new WrappedValue(null)]; -const _globalEventHandlers = {}; +const _globalEventHandlers: { + [eventClass: string]: { + [eventName: string]: ListenerEntry[]; + }; +} = {}; export class Observable implements ObservableDefinition { public static propertyChangeEvent = 'propertyChange'; public _isViewBase: boolean; + isViewBase(): this is ViewBase { + return this._isViewBase; + } - private _observers = {}; + private readonly _observers: { [eventName: string]: ListenerEntry[] } = {}; public get(name: string): any { return this[name]; @@ -85,28 +94,19 @@ export class Observable implements ObservableDefinition { } } - public on(eventNames: string, callback: (data: EventData) => void, thisArg?: any): void { - this.addEventListener(eventNames, callback, thisArg); + public on(eventNames: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { + this.addEventListener(eventNames, callback, thisArg, options); } - public once(event: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof event !== 'string') { - throw new TypeError('Event must be string.'); - } - - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } - - const list = this._getEventList(event, true); - list.push({ callback, thisArg, once: true }); + public once(event: string, callback: (data: EventData) => void, thisArg?: any, options?: (AddEventListenerOptions & { once: true }) | boolean): void { + this.addEventListener(event, callback, thisArg, { ...normalizeEventOptions(options), once: true }); } - public off(eventNames: string, callback?: any, thisArg?: any): void { - this.removeEventListener(eventNames, callback, thisArg); + public off(eventNames: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void { + this.removeEventListener(eventNames, callback, thisArg, options); } - public addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any): void { + public addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { if (typeof eventNames !== 'string') { throw new TypeError('Events name(s) must be string.'); } @@ -115,76 +115,65 @@ export class Observable implements ObservableDefinition { throw new TypeError('callback must be function.'); } - const events = eventNames.split(','); + const events = eventNames.trim().split(eventDelimiterPattern); for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); - const list = this._getEventList(event, true); + const event = events[i]; + const list = this.getEventList(event, true); + if (Observable._indexOfListener(list, callback, thisArg, options) >= 0) { + // Don't allow addition of duplicate event listeners. + continue; + } + // TODO: Performance optimization - if we do not have the thisArg specified, do not wrap the callback in additional object (ObserveEntry) list.push({ - callback: callback, - thisArg: thisArg, + callback, + thisArg, + ...normalizeEventOptions(options), }); } } - public removeEventListener(eventNames: string, callback?: any, thisArg?: any): void { + public removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void { if (typeof eventNames !== 'string') { throw new TypeError('Events name(s) must be string.'); } if (callback && typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + throw new TypeError('Callback, if provided, must be function.'); } - const events = eventNames.split(','); - for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); - if (callback) { - const list = this._getEventList(event, false); - if (list) { - const index = Observable._indexOfListener(list, callback, thisArg); - if (index >= 0) { - list.splice(index, 1); - } - if (list.length === 0) { - delete this._observers[event]; - } - } - } else { - this._observers[event] = undefined; + for (const event of eventNames.trim().split(eventDelimiterPattern)) { + if (!callback) { delete this._observers[event]; + continue; + } + + const list = this.getEventList(event, false); + if (list) { + const index = Observable._indexOfListener(list, callback, thisArg, options); + if (index >= 0) { + list.splice(index, 1); + } + if (list.length === 0) { + delete this._observers[event]; + } } } } - public static on(eventName: string, callback: any, thisArg?: any): void { - this.addEventListener(eventName, callback, thisArg); + public static on(eventName: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { + this.addEventListener(eventName, callback, thisArg, options); } - public static once(eventName: string, callback: any, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); - } - - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } - - const eventClass = this.name === 'Observable' ? '*' : this.name; - if (!_globalEventHandlers[eventClass]) { - _globalEventHandlers[eventClass] = {}; - } - if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { - _globalEventHandlers[eventClass][eventName] = []; - } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg, once: true }); + public static once(eventName: string, callback: (data: EventData) => void, thisArg?: any, options?: (AddEventListenerOptions & { once: true }) | boolean): void { + this.addEventListener(eventName, callback, thisArg, { ...normalizeEventOptions(options), once: true }); } - public static off(eventName: string, callback?: any, thisArg?: any): void { - this.removeEventListener(eventName, callback, thisArg); + public static off(eventName: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void { + this.removeEventListener(eventName, callback, thisArg, options); } - public static removeEventListener(eventName: string, callback?: any, thisArg?: any): void { + public static removeEventListener(eventName: string, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void { if (typeof eventName !== 'string') { throw new TypeError('Event must be string.'); } @@ -201,38 +190,29 @@ export class Observable implements ObservableDefinition { } const events = _globalEventHandlers[eventClass][eventName]; - if (thisArg) { - for (let i = 0; i < events.length; i++) { - if (events[i].callback === callback && events[i].thisArg === thisArg) { - events.splice(i, 1); - i--; - } - } - } else if (callback) { - for (let i = 0; i < events.length; i++) { - if (events[i].callback === callback) { - events.splice(i, 1); - i--; - } + if (callback) { + const index = Observable._indexOfListener(events, callback, thisArg, options); + if (index >= 0) { + events.splice(index, 1); } } else { // Clear all events of this type delete _globalEventHandlers[eventClass][eventName]; } - if (events.length === 0) { + if (!events.length) { // Clear all events of this type delete _globalEventHandlers[eventClass][eventName]; } // Clear the primary class grouping if no events are left const keys = Object.keys(_globalEventHandlers[eventClass]); - if (keys.length === 0) { + if (!keys.length) { delete _globalEventHandlers[eventClass]; } } - public static addEventListener(eventName: string, callback: any, thisArg?: any): void { + public static addEventListener(eventName: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { if (typeof eventName !== 'string') { throw new TypeError('Event must be string.'); } @@ -248,73 +228,90 @@ export class Observable implements ObservableDefinition { if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { _globalEventHandlers[eventClass][eventName] = []; } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg }); - } - private _globalNotify(eventClass: string, eventType: string, data: T): void { - // Check for the Global handlers for JUST this class - if (_globalEventHandlers[eventClass]) { - const event = data.eventName + eventType; - const events = _globalEventHandlers[eventClass][event]; - if (events) { - Observable._handleEvent(events, data); - } - } - - // Check for he Global handlers for ALL classes - if (_globalEventHandlers['*']) { - const event = data.eventName + eventType; - const events = _globalEventHandlers['*'][event]; - if (events) { - Observable._handleEvent(events, data); - } - } - } - - public notify(data: T): void { - const eventData = data as EventData; - eventData.object = eventData.object || this; - const eventClass = this.constructor.name; - this._globalNotify(eventClass, 'First', eventData); - - const observers = >this._observers[data.eventName]; - if (observers) { - Observable._handleEvent(observers, eventData); - } - - this._globalNotify(eventClass, '', eventData); - } - - private static _handleEvent(observers: Array, data: T): void { - if (!observers) { + const list = _globalEventHandlers[eventClass][eventName]; + if (Observable._indexOfListener(list, callback, thisArg, options) >= 0) { + // Don't allow addition of duplicate event listeners. return; } - for (let i = observers.length - 1; i >= 0; i--) { - const entry = observers[i]; - if (entry) { - if (entry.once) { - observers.splice(i, 1); - } - let returnValue; - if (entry.thisArg) { - returnValue = entry.callback.apply(entry.thisArg, [data]); - } else { - returnValue = entry.callback(data); - } - - // This ensures errors thrown inside asynchronous functions do not get swallowed - if (returnValue && returnValue instanceof Promise) { - returnValue.catch((err) => { - console.error(err); - }); - } - } - } + _globalEventHandlers[eventClass][eventName].push({ + callback, + thisArg, + ...normalizeEventOptions(options), + }); } - public notifyPropertyChange(name: string, value: any, oldValue?: any) { - this.notify(this._createPropertyChangeData(name, value, oldValue)); + /** + * Notifies all the registered listeners for the event provided in the + * data.eventName. + * + * Old behaviour (for reference): + * - pre-handling phase: Notifies all observers registered globally, i.e. + * for the given event name on the given class name (or all class names) + * with the eventName suffix 'First'. + * + * - handling phase: Notifies all observers registered on the Observable + * itself. + * + * - post-handling phase: Notifies all observers registered globally, i.e. + * for the given event name on the given class name (or all class names) + * without any eventName suffix. + * + * + * New behaviour (based on DOM, but backwards-compatible): + * - pre-handling phase: Same as above. + * + * - capturing phase: Calls the callback for event listeners registered on + * each ancestor of the target in turn (starting with the most ancestral), + * but not the target itself. + * + * - at-target phase: Calls the callback for event listeners registered on + * the target. Equivalent to the old 'handling phase'. + * + * - bubbling phase: Calls the callback for event listeners registered on + * each ancestor of the target (again, not the target itself) in turn, + * starting with the immediate parent. + * + * - post-handling phase: Same as above. + * + * - The progragation can be stopped in any of these phases using + * event.stopPropagation() or event.stopImmediatePropagation(). + * + * The old behaviour is the default. That is to say, by taking the default + * option of { bubbles: false } and ensuring that any event listeners added + * also use the default option of { capture: false }, then the event will + * go through just the pre-handling, at-target, and post-handling phases. As + * long as none of the new DOM-specific features like stopPropagation() are + * used, it will behave equivalently. + * + * @param data The data associated with the event. + * @param options Options for the event, in line with DOM Standard. + */ + public notify(data: T, options?: CustomEventInit): void { + data.object = data.object || this; + + // Now that we've filled in the `object` field (that was optional in + // NotifyData), `data` can be treated as EventData. + const eventData = data as EventData; + + new DOMEvent(data.eventName, options).dispatchTo({ + target: this, + data: eventData, + getGlobalEventHandlersPreHandling: () => this._getGlobalEventHandlers(eventData, 'First'), + getGlobalEventHandlersPostHandling: () => this._getGlobalEventHandlers(eventData, ''), + }); + } + + private _getGlobalEventHandlers(data: EventData, eventType: 'First' | ''): ListenerEntry[] { + const eventClass = data.object?.constructor?.name; + const globalEventHandlersForOwnClass = _globalEventHandlers[eventClass]?.[`${data.eventName}${eventType}`] ?? []; + const globalEventHandlersForAllClasses = _globalEventHandlers['*']?.[`${data.eventName}${eventType}`] ?? []; + return [...globalEventHandlersForOwnClass, ...globalEventHandlersForAllClasses]; + } + + public notifyPropertyChange(name: string, value: any, oldValue?: any, options?: CustomEventInit) { + this.notify(this._createPropertyChangeData(name, value, oldValue), options); } public hasListeners(eventName: string) { @@ -332,20 +329,17 @@ export class Observable implements ObservableDefinition { } public _emit(eventNames: string) { - const events = eventNames.split(','); - - for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); + for (const event of eventNames.trim().split(eventDelimiterPattern)) { this.notify({ eventName: event, object: this }); } } - private _getEventList(eventName: string, createIfNeeded?: boolean): Array { + public getEventList(eventName: string, createIfNeeded?: boolean): ListenerEntry[] | undefined { if (!eventName) { throw new TypeError('EventName must be valid string.'); } - let list = >this._observers[eventName]; + let list = this._observers[eventName]; if (!list && createIfNeeded) { list = []; this._observers[eventName] = list; @@ -354,21 +348,9 @@ export class Observable implements ObservableDefinition { return list; } - private static _indexOfListener(list: Array, callback: (data: EventData) => void, thisArg?: any): number { - for (let i = 0; i < list.length; i++) { - const entry = list[i]; - if (thisArg) { - if (entry.callback === callback && entry.thisArg === thisArg) { - return i; - } - } else { - if (entry.callback === callback) { - return i; - } - } - } - - return -1; + protected static _indexOfListener(list: Array, callback: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): number { + const capture = normalizeEventOptions(options)?.capture ?? false; + return list.findIndex((entry) => entry.callback === callback && (!thisArg || entry.thisArg === thisArg) && !!entry.capture === capture); } } @@ -416,6 +398,12 @@ function addPropertiesFromObject(observable: ObservableFromObject, source: any, }); } +export const eventDelimiterPattern = /\s*,\s*/; + +export function normalizeEventOptions(options?: AddEventListenerOptions | boolean) { + return typeof options === 'object' ? options : { capture: options }; +} + export function fromObject(source: any): Observable { const observable = new ObservableFromObject(); addPropertiesFromObject(observable, source, false); diff --git a/packages/core/ui/core/view/index.android.ts b/packages/core/ui/core/view/index.android.ts index af560c101..bb3b88ea1 100644 --- a/packages/core/ui/core/view/index.android.ts +++ b/packages/core/ui/core/view/index.android.ts @@ -53,7 +53,7 @@ const GRAVITY_FILL_VERTICAL = 112; // android.view.Gravity.FILL_VERTICAL const modalMap = new Map(); -let TouchListener: TouchListener; +let TouchListener: TouchListener | null = null; let DialogFragment: DialogFragment; interface AndroidView { @@ -113,6 +113,9 @@ function initializeTouchListener(): void { TouchListener = TouchListenerImpl; } +function deinitializeTouchListener(): void { + TouchListener = null; +} function initializeDialogFragment() { if (DialogFragment) { @@ -313,7 +316,7 @@ export class View extends ViewCommon { public _manager: androidx.fragment.app.FragmentManager; private _isClickable: boolean; private touchListenerIsSet: boolean; - private touchListener: android.view.View.OnTouchListener; + private touchListener: android.view.View.OnTouchListener | null = null; private layoutChangeListenerIsSet: boolean; private layoutChangeListener: android.view.View.OnLayoutChangeListener; private _rootManager: androidx.fragment.app.FragmentManager; @@ -334,16 +337,22 @@ export class View extends ViewCommon { this.on(View.loadedEvent, handler); } - // TODO: Implement unobserve that detach the touchListener. - _observe(type: GestureTypes, callback: (args: GestureEventData) => void, thisArg?: any): void { - super._observe(type, callback, thisArg); + protected _observe(type: GestureTypes, callback: (args: GestureEventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { + super._observe(type, callback, thisArg, options); if (this.isLoaded && !this.touchListenerIsSet) { this.setOnTouchListener(); } } - on(eventNames: string, callback: (data: EventData) => void, thisArg?: any) { - super.on(eventNames, callback, thisArg); + protected _disconnectGestureObservers(type: GestureTypes, callback: (args: GestureEventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void { + super._disconnectGestureObservers(type, callback, thisArg, options); + if (this.touchListenerIsSet) { + this.unsetOnTouchListener(); + } + } + + on(eventNames: string, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { + super.on(eventNames, callback, thisArg, options); const isLayoutEvent = typeof eventNames === 'string' ? eventNames.indexOf(ViewCommon.layoutChangedEvent) !== -1 : false; if (this.isLoaded && !this.layoutChangeListenerIsSet && isLayoutEvent) { @@ -351,8 +360,8 @@ export class View extends ViewCommon { } } - off(eventNames: string, callback?: any, thisArg?: any) { - super.off(eventNames, callback, thisArg); + off(eventNames: string, callback?: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { + super.off(eventNames, callback, thisArg, options); const isLayoutEvent = typeof eventNames === 'string' ? eventNames.indexOf(ViewCommon.layoutChangedEvent) !== -1 : false; // Remove native listener only if there are no more user listeners for LayoutChanged event @@ -449,9 +458,8 @@ export class View extends ViewCommon { public handleGestureTouch(event: android.view.MotionEvent): any { for (const type in this._gestureObservers) { - const list = this._gestureObservers[type]; - list.forEach((element) => { - element.androidOnTouchEvent(event); + this._gestureObservers[type].forEach((gesturesObserver) => { + gesturesObserver.observer.androidOnTouchEvent(event); }); } if (this.parent instanceof View) { @@ -460,7 +468,7 @@ export class View extends ViewCommon { } hasGestureObservers() { - return this._gestureObservers && Object.keys(this._gestureObservers).length > 0; + return Object.keys(this._gestureObservers).length > 0; } public initNativeView(): void { @@ -505,9 +513,15 @@ export class View extends ViewCommon { this.touchListenerIsSet = true; - if (this.nativeViewProtected.setClickable) { - this.nativeViewProtected.setClickable(this.isUserInteractionEnabled); - } + this.nativeViewProtected.setClickable?.(this.isUserInteractionEnabled); + } + + unsetOnTouchListener() { + deinitializeTouchListener(); + this.touchListener = null; + this.nativeViewProtected?.setOnTouchListener(null); + this.touchListenerIsSet = false; + this.nativeViewProtected?.setClickable?.(this._isClickable); } private setOnLayoutChangeListener() { diff --git a/packages/core/ui/core/view/index.d.ts b/packages/core/ui/core/view/index.d.ts index 7b4778ddd..43c7587f9 100644 --- a/packages/core/ui/core/view/index.d.ts +++ b/packages/core/ui/core/view/index.d.ts @@ -1,6 +1,6 @@ import { ViewBase } from '../view-base'; import { Property, InheritedProperty } from '../properties'; -import { EventData } from '../../../data/observable'; +import { EventData, ListenerEntry } from '../../../data/observable'; import { Color } from '../../../color'; import { Animation, AnimationDefinition, AnimationPromise } from '../../animation'; import { GestureTypes, GesturesObserver } from '../../gestures'; @@ -97,6 +97,10 @@ export interface ShownModallyData extends EventData { closeCallback?: Function; } +export interface ObserverEntry extends ListenerEntry { + observer: GesturesObserver; +} + /** * This class is the base class for all UI components. * A View occupies a rectangular area on the screen and is responsible for drawing and layouting of all UI components within. @@ -577,49 +581,56 @@ export abstract class View extends ViewCommon { */ public focus(): boolean; - public getGestureObservers(type: GestureTypes): Array; + /** + * @returns A readonly array of the observers for the given gesture type (or + * type combination), or an empty array if no gesture observers of that type + * had been registered at all. + */ + public getGestureObservers(type: GestureTypes): readonly ObserverEntry[]; /** * Removes listener(s) for the specified event name. * @param eventNames Comma delimited names of the events or gesture types the specified listener is associated with. * @param callback An optional parameter pointing to a specific listener. If not defined, all listeners for the event names will be removed. * @param thisArg An optional parameter which when set will be used to refine search of the correct callback which will be removed as event listener. + * @param options An optional parameter. If passed as a boolean, configures the useCapture value. Otherwise, specifies options. */ - off(eventNames: string | GestureTypes, callback?: (args: EventData) => void, thisArg?: any); + off(eventNames: string | GestureTypes, callback?: (args: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void; /** * A basic method signature to hook an event listener (shortcut alias to the addEventListener method). * @param eventNames - String corresponding to events (e.g. "propertyChange"). Optionally could be used more events separated by `,` (e.g. "propertyChange", "change") or you can use gesture types. * @param callback - Callback function which will be executed when event is raised. * @param thisArg - An optional parameter which will be used as `this` context for callback execution. + * @param options An optional parameter. If passed as a boolean, configures the useCapture value. Otherwise, specifies options. */ - on(eventNames: string | GestureTypes, callback: (args: EventData) => void, thisArg?: any); + on(eventNames: string | GestureTypes, callback: (args: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Raised when a loaded event occurs. */ - on(event: 'loaded', callback: (args: EventData) => void, thisArg?: any); + on(event: 'loaded', callback: (args: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Raised when an unloaded event occurs. */ - on(event: 'unloaded', callback: (args: EventData) => void, thisArg?: any); + on(event: 'unloaded', callback: (args: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Raised when a back button is pressed. * This event is raised only for android. */ - on(event: 'androidBackPressed', callback: (args: EventData) => void, thisArg?: any); + on(event: 'androidBackPressed', callback: (args: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Raised before the view is shown as a modal dialog. */ - on(event: 'showingModally', callback: (args: ShownModallyData) => void, thisArg?: any): void; + on(event: 'showingModally', callback: (args: ShownModallyData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Raised after the view is shown as a modal dialog. */ - on(event: 'shownModally', callback: (args: ShownModallyData) => void, thisArg?: any); + on(event: 'shownModally', callback: (args: ShownModallyData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void; /** * Returns the current modal view that this page is showing (is parent of), if any. @@ -721,10 +732,17 @@ export abstract class View extends ViewCommon { hasGestureObservers?(): boolean; /** - * Android only to set the touch listener + * @platform Android-only + * Set the touch listener. */ setOnTouchListener?(): void; + /** + * @platform Android-only + * Unset the touch listener. + */ + unsetOnTouchListener?(): void; + /** * Iterates over children of type View. * @param callback Called for each child of type View. Iteration stops if this method returns falsy value. @@ -764,10 +782,6 @@ export abstract class View extends ViewCommon { * @private */ isLayoutRequired: boolean; - /** - * @private - */ - _gestureObservers: any; /** * @private * androidx.fragment.app.FragmentManager diff --git a/packages/core/ui/core/view/view-common.ts b/packages/core/ui/core/view/view-common.ts index 437f62213..e61da0773 100644 --- a/packages/core/ui/core/view/view-common.ts +++ b/packages/core/ui/core/view/view-common.ts @@ -1,5 +1,5 @@ // Definitions. -import { View as ViewDefinition, Point, Size, ShownModallyData } from '.'; +import { View as ViewDefinition, Point, Size, ShownModallyData, ObserverEntry } from '.'; import { booleanConverter, ShowModalOptions, ViewBase } from '../view-base'; import { getEventOrGestureName } from '../bindable'; @@ -7,14 +7,14 @@ import { layout } from '../../../utils'; import { isObject } from '../../../utils/types'; import { Color } from '../../../color'; import { Property, InheritedProperty } from '../properties'; -import { EventData } from '../../../data/observable'; +import { EventData, normalizeEventOptions, eventDelimiterPattern } from '../../../data/observable'; import { Trace } from '../../../trace'; import { CoreTypes } from '../../../core-types'; import { ViewHelper } from './view-helper'; import { PercentLength } from '../../styling/style-properties'; -import { observe as gestureObserve, GesturesObserver, GestureTypes, GestureEventData, fromString as gestureFromString, TouchManager, TouchAnimationOptions } from '../../gestures'; +import { GesturesObserver, GestureTypes, GestureEventData, fromString as gestureFromString, toString as gestureToString, TouchManager, TouchAnimationOptions } from '../../gestures'; import { CSSUtils } from '../../../css/system-classes'; import { Builder } from '../../builder'; @@ -108,7 +108,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { _setMinWidthNative: (value: CoreTypes.LengthType) => void; _setMinHeightNative: (value: CoreTypes.LengthType) => void; - public _gestureObservers = {}; + protected readonly _gestureObservers: { [gestureName: string]: ObserverEntry[] } = {}; _androidContentDescriptionUpdated?: boolean; @@ -162,7 +162,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { onLoaded() { if (!this.isLoaded) { - const enableTapAnimations = TouchManager.enableGlobalTapAnimations && (this.hasListeners('tap') || this.hasListeners('tapChange') || this.getGestureObservers(GestureTypes.tap)); + const enableTapAnimations = TouchManager.enableGlobalTapAnimations && (this.hasListeners('tap') || this.hasListeners('tapChange')); if (!this.ignoreTouchAnimation && (this.touchAnimation || enableTapAnimations)) { // console.log('view:', Object.keys((this)._observers)); TouchManager.addAnimations(this); @@ -253,69 +253,94 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { } } - _observe(type: GestureTypes, callback: (args: GestureEventData) => void, thisArg?: any): void { + // TODO: I'm beginning to suspect we don't need anyting from the + // GesturesObserverBase, and only need what its iOS and Android subclasses + // implement. + // + // Currently, a View starts off with no GesturesObservers. For each gesture + // combo (e.g. tap | doubleTap), you can populate the array of observers. + // 1 View : N GesturesObservers + // 1 GesturesObserver : N gesture combos + // The GesturesObserver does not need a callback but does still need an + // identifiable key by which to remove itself from the array. + // + // Hoping to drop target and context. But not sure whether we can drop the + // gestureObservers array altogether. Would be nice if we could port it to + // Observable._observers (ListenerEntry). + protected _observe(type: GestureTypes, callback: (args: GestureEventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { if (!this._gestureObservers[type]) { this._gestureObservers[type] = []; } - this._gestureObservers[type].push(gestureObserve(this, type, callback, thisArg)); + if (ViewCommon._indexOfListener(this._gestureObservers[type], callback, thisArg, options) >= 0) { + // Prevent adding an identically-configured gesture observer twice. + return; + } + + const observer = new GesturesObserver(this, callback, thisArg); + observer.observe(type); + + this._gestureObservers[type].push({ + callback, + observer, + thisArg, + ...normalizeEventOptions(options), + }); } - public getGestureObservers(type: GestureTypes): Array { - return this._gestureObservers[type]; + public getGestureObservers(type: GestureTypes): readonly ObserverEntry[] { + return this._gestureObservers[type] || []; } - public addEventListener(arg: string | GestureTypes, callback: (data: EventData) => void, thisArg?: any) { - if (typeof arg === 'string') { - arg = getEventOrGestureName(arg); + public addEventListener(arg: string | GestureTypes, callback: (data: EventData) => void, thisArg?: any, options?: AddEventListenerOptions | boolean): void { + // To avoid a full refactor of the Gestures system when migrating to DOM + // Events, we mirror the this._gestureObservers record, creating + // corresponding DOM Event listeners for each gesture. + // + // The callback passed into this._observe() for constructing a + // GesturesObserver is *not* actually called by the GesturesObserver + // upon the gesture. It is merely used as a unique symbol by which add + // and remove the GesturesObserver from the this._gestureObservers + // record. + // + // Upon the gesture, the GesturesObserver actually fires a DOM event + // named after the gesture, which triggers our listener (registered at + // the same time). - const gesture = gestureFromString(arg); + if (typeof arg === 'number') { + this._observe(arg, callback, thisArg, options); + super.addEventListener(gestureToString(arg), callback, thisArg, options); + return; + } + + arg = getEventOrGestureName(arg); + + const events = arg.trim().split(eventDelimiterPattern); + + for (const event of events) { + const gesture = gestureFromString(event); if (gesture && !this._isEvent(arg)) { - this._observe(gesture, callback, thisArg); - } else { - const events = arg.split(','); - if (events.length > 0) { - for (let i = 0; i < events.length; i++) { - const evt = events[i].trim(); - const gst = gestureFromString(evt); - if (gst && !this._isEvent(arg)) { - this._observe(gst, callback, thisArg); - } else { - super.addEventListener(evt, callback, thisArg); - } - } - } else { - super.addEventListener(arg, callback, thisArg); - } + this._observe(gesture, callback, thisArg, options); } - } else if (typeof arg === 'number') { - this._observe(arg, callback, thisArg); + super.addEventListener(event, callback, thisArg, options); } } - public removeEventListener(arg: string | GestureTypes, callback?: any, thisArg?: any) { - if (typeof arg === 'string') { - const gesture = gestureFromString(arg); + public removeEventListener(arg: string | GestureTypes, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void { + if (typeof arg === 'number') { + this._disconnectGestureObservers(arg, callback, thisArg, options); + super.removeEventListener(gestureToString(arg), callback, thisArg, options); + return; + } + + const events = arg.trim().split(eventDelimiterPattern); + + for (const event of events) { + const gesture = gestureFromString(event); if (gesture && !this._isEvent(arg)) { - this._disconnectGestureObservers(gesture); - } else { - const events = arg.split(','); - if (events.length > 0) { - for (let i = 0; i < events.length; i++) { - const evt = events[i].trim(); - const gst = gestureFromString(evt); - if (gst && !this._isEvent(arg)) { - this._disconnectGestureObservers(gst); - } else { - super.removeEventListener(evt, callback, thisArg); - } - } - } else { - super.removeEventListener(arg, callback, thisArg); - } + this._disconnectGestureObservers(gesture, callback, thisArg, options); } - } else if (typeof arg === 'number') { - this._disconnectGestureObservers(arg); + super.removeEventListener(event, callback, thisArg, options); } } @@ -380,7 +405,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { } } - public get modal(): ViewCommon { + public get modal(): ViewDefinition { return this._modal; } @@ -453,12 +478,20 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { return this.constructor && `${name}Event` in this.constructor; } - private _disconnectGestureObservers(type: GestureTypes): void { - const observers = this.getGestureObservers(type); - if (observers) { - for (let i = 0; i < observers.length; i++) { - observers[i].disconnect(); - } + protected _disconnectGestureObservers(type: GestureTypes, callback?: (data: EventData) => void, thisArg?: any, options?: EventListenerOptions | boolean): void { + if (!this._gestureObservers[type]) { + return; + } + + const index = ViewCommon._indexOfListener(this._gestureObservers[type], callback, thisArg, options); + if (index === -1) { + return; + } + + this._gestureObservers[type][index].observer.disconnect(); + this._gestureObservers[type].splice(index, 1); + if (!this._gestureObservers[type].length) { + delete this._gestureObservers[type]; } } diff --git a/packages/core/ui/gestures/gestures-common.ts b/packages/core/ui/gestures/gestures-common.ts index 0e3d61750..1788a634b 100644 --- a/packages/core/ui/gestures/gestures-common.ts +++ b/packages/core/ui/gestures/gestures-common.ts @@ -81,7 +81,7 @@ export function toString(type: GestureTypes, separator?: string): string { // NOTE: toString could return the text of multiple GestureTypes. // Souldn't fromString do split on separator and return multiple GestureTypes? -export function fromString(type: string): GestureTypes { +export function fromString(type: string): GestureTypes | undefined { const t = type.trim().toLowerCase(); if (t === 'tap') { @@ -130,25 +130,18 @@ export abstract class GesturesObserverBase implements GesturesObserverDefinition this._context = context; } - public abstract androidOnTouchEvent(motionEvent: android.view.MotionEvent); + /** Android-only. android.view.MotionEvent */ + public abstract androidOnTouchEvent(motionEvent: unknown); + public abstract observe(type: GestureTypes); + /** + * Disconnect the observer (i.e. stop it observing for gesture events). + * + * Subclasses should override this method to additionally disconnect the + * observers natively. + */ public disconnect() { - // remove gesture observer from map - if (this.target) { - const list = this.target.getGestureObservers(this.type); - if (list && list.length > 0) { - for (let i = 0; i < list.length; i++) { - if (list[i].callback === this.callback) { - break; - } - } - list.length = 0; - - this.target._gestureObservers[this.type] = undefined; - delete this.target._gestureObservers[this.type]; - } - } this._target = null; this._callback = null; this._context = null; diff --git a/packages/core/ui/gestures/index.android.ts b/packages/core/ui/gestures/index.android.ts index 3d3e7e3e2..449693e0c 100644 --- a/packages/core/ui/gestures/index.android.ts +++ b/packages/core/ui/gestures/index.android.ts @@ -1,6 +1,7 @@ // Definitions. -import { GestureEventData, TapGestureEventData, SwipeGestureEventData, PanGestureEventData, RotationGestureEventData, GestureEventDataWithState } from '.'; +import { TapGestureEventData, SwipeGestureEventData, PanGestureEventData, RotationGestureEventData, GestureEventDataWithState } from '.'; import { View } from '../core/view'; +import { DOMEvent } from '../../data/dom-events/dom-event'; import { EventData } from '../../data/observable'; // Types. @@ -61,26 +62,33 @@ function initializeTapAndDoubleTapGestureListener() { } public onLongPress(motionEvent: android.view.MotionEvent): void { - if (this._type & GestureTypes.longPress) { - const args = _getLongPressArgs(GestureTypes.longPress, this._target, GestureStateTypes.began, motionEvent); - _executeCallback(this._observer, args); + if (this._type & GestureTypes.longPress && this._observer?.callback) { + new DOMEvent('longPress').dispatchTo({ + target: this._target, + data: _getLongPressArgs(GestureTypes.longPress, this._target, GestureStateTypes.began, motionEvent), + }); } } private _handleSingleTap(motionEvent: android.view.MotionEvent): void { - if (this._target.getGestureObservers(GestureTypes.doubleTap)) { + if (this._target.getGestureObservers(GestureTypes.doubleTap).length) { this._tapTimeoutId = timer.setTimeout(() => { - if (this._type & GestureTypes.tap) { - const args = _getTapArgs(GestureTypes.tap, this._target, motionEvent); - _executeCallback(this._observer, args); + if (this._type & GestureTypes.tap && this._observer?.callback) { + new DOMEvent('tap').dispatchTo({ + target: this._target, + data: _getTapArgs(GestureTypes.tap, this._target, motionEvent), + }); } timer.clearTimeout(this._tapTimeoutId); }, TapAndDoubleTapGestureListenerImpl.DoubleTapTimeout); - } else { - if (this._type & GestureTypes.tap) { - const args = _getTapArgs(GestureTypes.tap, this._target, motionEvent); - _executeCallback(this._observer, args); - } + return; + } + + if (this._type & GestureTypes.tap && this._observer?.callback) { + new DOMEvent('tap').dispatchTo({ + target: this._target, + data: _getTapArgs(GestureTypes.tap, this._target, motionEvent), + }); } } @@ -88,9 +96,11 @@ function initializeTapAndDoubleTapGestureListener() { if (this._tapTimeoutId) { timer.clearTimeout(this._tapTimeoutId); } - if (this._type & GestureTypes.doubleTap) { - const args = _getTapArgs(GestureTypes.doubleTap, this._target, motionEvent); - _executeCallback(this._observer, args); + if (this._type & GestureTypes.doubleTap && this._observer?.callback) { + new DOMEvent('doubleTap').dispatchTo({ + target: this._target, + data: _getTapArgs(GestureTypes.doubleTap, this._target, motionEvent), + }); } } } @@ -126,9 +136,12 @@ function initializePinchGestureListener() { public onScaleBegin(detector: android.view.ScaleGestureDetector): boolean { this._scale = detector.getScaleFactor(); - const args = new PinchGestureEventData(this._target, detector, this._scale, this._target, GestureStateTypes.began); - - _executeCallback(this._observer, args); + if (this._observer?.callback) { + new DOMEvent('pinch').dispatchTo({ + target: this._target, + data: new PinchGestureEventData(this._target, detector, this._scale, this._target, GestureStateTypes.began), + }); + } return true; } @@ -136,9 +149,12 @@ function initializePinchGestureListener() { public onScale(detector: android.view.ScaleGestureDetector): boolean { this._scale *= detector.getScaleFactor(); - const args = new PinchGestureEventData(this._target, detector, this._scale, this._target, GestureStateTypes.changed); - - _executeCallback(this._observer, args); + if (this._observer?.callback) { + new DOMEvent('pinch').dispatchTo({ + target: this._target, + data: new PinchGestureEventData(this._target, detector, this._scale, this._target, GestureStateTypes.changed), + }); + } return true; } @@ -146,9 +162,12 @@ function initializePinchGestureListener() { public onScaleEnd(detector: android.view.ScaleGestureDetector): void { this._scale *= detector.getScaleFactor(); - const args = new PinchGestureEventData(this._target, detector, this._scale, this._target, GestureStateTypes.ended); - - _executeCallback(this._observer, args); + if (this._observer?.callback) { + new DOMEvent('pinch').dispatchTo({ + target: this._target, + data: new PinchGestureEventData(this._target, detector, this._scale, this._target, GestureStateTypes.ended), + }); + } } } @@ -185,7 +204,6 @@ function initializeSwipeGestureListener() { public onFling(initialEvent: android.view.MotionEvent, currentEvent: android.view.MotionEvent, velocityX: number, velocityY: number): boolean { let result = false; - let args: SwipeGestureEventData; try { const deltaY = currentEvent.getY() - initialEvent.getY(); const deltaX = currentEvent.getX() - initialEvent.getX(); @@ -193,24 +211,41 @@ function initializeSwipeGestureListener() { if (Math.abs(deltaX) > Math.abs(deltaY)) { if (Math.abs(deltaX) > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { if (deltaX > 0) { - args = _getSwipeArgs(SwipeDirection.right, this._target, initialEvent, currentEvent); - _executeCallback(this._observer, args); + if (this._observer?.callback) { + new DOMEvent('swipe').dispatchTo({ + target: this._target, + data: _getSwipeArgs(SwipeDirection.right, this._target, initialEvent, currentEvent), + }); + } + result = true; } else { - args = _getSwipeArgs(SwipeDirection.left, this._target, initialEvent, currentEvent); - _executeCallback(this._observer, args); + if (this._observer?.callback) { + new DOMEvent('swipe').dispatchTo({ + target: this._target, + data: _getSwipeArgs(SwipeDirection.left, this._target, initialEvent, currentEvent), + }); + } result = true; } } } else { if (Math.abs(deltaY) > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) { if (deltaY > 0) { - args = _getSwipeArgs(SwipeDirection.down, this._target, initialEvent, currentEvent); - _executeCallback(this._observer, args); + if (this._observer?.callback) { + new DOMEvent('swipe').dispatchTo({ + target: this._target, + data: _getSwipeArgs(SwipeDirection.down, this._target, initialEvent, currentEvent), + }); + } result = true; } else { - args = _getSwipeArgs(SwipeDirection.up, this._target, initialEvent, currentEvent); - _executeCallback(this._observer, args); + if (this._observer?.callback) { + new DOMEvent('swipe').dispatchTo({ + target: this._target, + data: _getSwipeArgs(SwipeDirection.up, this._target, initialEvent, currentEvent), + }); + } result = true; } } @@ -231,13 +266,6 @@ const SWIPE_VELOCITY_THRESHOLD = 100; const INVALID_POINTER_ID = -1; const TO_DEGREES = 180 / Math.PI; -export function observe(target: View, type: GestureTypes, callback: (args: GestureEventData) => void, context?: any): GesturesObserver { - const observer = new GesturesObserver(target, callback, context); - observer.observe(type); - - return observer; -} - export class GesturesObserver extends GesturesObserverBase { private _notifyTouch: boolean; private _simpleGestureDetector: android.view.GestureDetector; @@ -342,28 +370,20 @@ export class GesturesObserver extends GesturesObserverBase { } this._eventData.prepare(this.target, motionEvent); - _executeCallback(this, this._eventData); + + if (this.callback) { + new DOMEvent('touch').dispatchTo({ + target: this.target, + data: this._eventData, + }); + } } - if (this._simpleGestureDetector) { - this._simpleGestureDetector.onTouchEvent(motionEvent); - } - - if (this._scaleGestureDetector) { - this._scaleGestureDetector.onTouchEvent(motionEvent); - } - - if (this._swipeGestureDetector) { - this._swipeGestureDetector.onTouchEvent(motionEvent); - } - - if (this._panGestureDetector) { - this._panGestureDetector.onTouchEvent(motionEvent); - } - - if (this._rotateGestureDetector) { - this._rotateGestureDetector.onTouchEvent(motionEvent); - } + this._simpleGestureDetector?.onTouchEvent(motionEvent); + this._scaleGestureDetector?.onTouchEvent(motionEvent); + this._swipeGestureDetector?.onTouchEvent(motionEvent); + this._panGestureDetector?.onTouchEvent(motionEvent); + this._rotateGestureDetector?.onTouchEvent(motionEvent); } } @@ -419,12 +439,6 @@ function _getPanArgs(deltaX: number, deltaY: number, view: View, state: GestureS }; } -function _executeCallback(observer: GesturesObserver, args: GestureEventData) { - if (observer && observer.callback) { - observer.callback.call((observer)._context, args); - } -} - class PinchGestureEventData implements PinchGestureEventData { public type = GestureTypes.pinch; public eventName = toString(GestureTypes.pinch); @@ -489,8 +503,12 @@ class CustomPanGestureDetector { private trackStop(currentEvent: android.view.MotionEvent, cacheEvent: boolean) { if (this.isTracking) { - const args = _getPanArgs(this.deltaX, this.deltaY, this.target, GestureStateTypes.ended, null, currentEvent); - _executeCallback(this.observer, args); + if (this.observer?.callback) { + new DOMEvent('pan').dispatchTo({ + target: this.target, + data: _getPanArgs(this.deltaX, this.deltaY, this.target, GestureStateTypes.ended, null, currentEvent), + }); + } this.deltaX = undefined; this.deltaY = undefined; @@ -510,8 +528,12 @@ class CustomPanGestureDetector { this.initialY = inital.y; this.isTracking = true; - const args = _getPanArgs(0, 0, this.target, GestureStateTypes.began, null, currentEvent); - _executeCallback(this.observer, args); + if (this.observer?.callback) { + new DOMEvent('pan').dispatchTo({ + target: this.target, + data: _getPanArgs(0, 0, this.target, GestureStateTypes.began, null, currentEvent), + }); + } } private trackChange(currentEvent: android.view.MotionEvent) { @@ -519,8 +541,12 @@ class CustomPanGestureDetector { this.deltaX = current.x - this.initialX; this.deltaY = current.y - this.initialY; - const args = _getPanArgs(this.deltaX, this.deltaY, this.target, GestureStateTypes.changed, null, currentEvent); - _executeCallback(this.observer, args); + if (this.observer?.callback) { + new DOMEvent('pan').dispatchTo({ + target: this.target, + data: _getPanArgs(this.deltaX, this.deltaY, this.target, GestureStateTypes.changed, null, currentEvent), + }); + } } private getEventCoordinates(event: android.view.MotionEvent): { x: number; y: number } { @@ -638,7 +664,12 @@ class CustomRotateGestureDetector { state: state, }; - _executeCallback(this.observer, args); + if (this.observer?.callback) { + new DOMEvent('rotation').dispatchTo({ + target: this.target, + data: args, + }); + } } private updateAngle(event: android.view.MotionEvent) { diff --git a/packages/core/ui/gestures/index.d.ts b/packages/core/ui/gestures/index.d.ts index 1f67a06f8..52a92f948 100644 --- a/packages/core/ui/gestures/index.d.ts +++ b/packages/core/ui/gestures/index.d.ts @@ -321,15 +321,6 @@ export class GesturesObserver { androidOnTouchEvent: (motionEvent: any /* android.view.MotionEvent */) => void; } -/** - * A short-hand function that is used to create a gesture observer for a view and gesture. - * @param target - View which will be watched for originating a specific gesture. - * @param type - Type of the gesture. - * @param callback - A function that will be executed when a gesture is received. - * @param context - this argument for the callback. - */ -export function observe(target: View, type: GestureTypes, callback: (args: GestureEventData) => void, context?: any): GesturesObserver; - /** * Returns a string representation of a gesture type. * @param type - Type of the gesture. @@ -341,4 +332,4 @@ export function toString(type: GestureTypes, separator?: string): string; * Returns a gesture type enum value from a string (case insensitive). * @param type - A string representation of a gesture type (e.g. Tap). */ -export function fromString(type: string): GestureTypes; +export function fromString(type: string): GestureTypes | undefined; diff --git a/packages/core/ui/gestures/index.ios.ts b/packages/core/ui/gestures/index.ios.ts index 82b2aa804..40edb8609 100644 --- a/packages/core/ui/gestures/index.ios.ts +++ b/packages/core/ui/gestures/index.ios.ts @@ -1,7 +1,7 @@ // Definitions. - import { GestureEventData, TapGestureEventData, GestureEventDataWithState, SwipeGestureEventData, PanGestureEventData, RotationGestureEventData, PinchGestureEventData } from '.'; import { View } from '../core/view'; +import { DOMEvent } from '../../data/dom-events/dom-event'; import { EventData } from '../../data/observable'; // Types. @@ -12,13 +12,6 @@ import { layout } from '../../utils'; export * from './gestures-common'; -export function observe(target: View, type: GestureTypes, callback: (args: GestureEventData) => void, context?: any): GesturesObserver { - const observer = new GesturesObserver(target, callback, context); - observer.observe(type); - - return observer; -} - @NativeClass class UIGestureRecognizerDelegateImpl extends NSObject implements UIGestureRecognizerDelegate { public static ObjCProtocols = [UIGestureRecognizerDelegate]; @@ -50,10 +43,10 @@ class UIGestureRecognizerImpl extends NSObject { private _owner: WeakRef; private _type: any; - private _callback: Function; + private _callback: (args: GestureEventData) => void; private _context: any; - public static initWithOwnerTypeCallback(owner: WeakRef, type: any, callback?: Function, thisArg?: any): UIGestureRecognizerImpl { + public static initWithOwnerTypeCallback(owner: WeakRef, type: any, callback?: (args: GestureEventData) => void, thisArg?: any): UIGestureRecognizerImpl { const handler = UIGestureRecognizerImpl.new(); handler._owner = owner; handler._type = type; @@ -91,7 +84,7 @@ class UIGestureRecognizerImpl extends NSObject { } export class GesturesObserver extends GesturesObserverBase { - private _recognizers: {}; + private _recognizers: { [name: string]: RecognizerCache }; private _onTargetLoaded: (data: EventData) => void; private _onTargetUnloaded: (data: EventData) => void; @@ -101,164 +94,203 @@ export class GesturesObserver extends GesturesObserverBase { this._recognizers = {}; } - public androidOnTouchEvent(motionEvent: android.view.MotionEvent): void { - // + public androidOnTouchEvent(motionEvent: unknown): void { + // Android-only, so no-op. } public observe(type: GestureTypes) { - if (this.target) { - this.type = type; - this._onTargetLoaded = (args) => { - this._attach(this.target, type); - }; - this._onTargetUnloaded = (args) => { - this._detach(); - }; + if (!this.target) { + return; + } - this.target.on('loaded', this._onTargetLoaded); - this.target.on('unloaded', this._onTargetUnloaded); + this.type = type; + this._onTargetLoaded = (args) => { + this._attach(this.target, type); + }; + this._onTargetUnloaded = (args) => { + this._detach(); + }; - if (this.target.isLoaded) { - this._attach(this.target, type); - } + this.target.on('loaded', this._onTargetLoaded); + this.target.on('unloaded', this._onTargetUnloaded); + + if (this.target.isLoaded) { + this._attach(this.target, type); } } private _attach(target: View, type: GestureTypes) { this._detach(); - if (target && target.nativeViewProtected && target.nativeViewProtected.addGestureRecognizer) { - const nativeView = target.nativeViewProtected; + if (!target?.nativeViewProtected?.addGestureRecognizer) { + return; + } - if (type & GestureTypes.tap) { - nativeView.addGestureRecognizer( - this._createRecognizer(GestureTypes.tap, (args) => { - if (args.view) { - this._executeCallback(_getTapData(args)); - } - }) - ); - } + const nativeView = target.nativeViewProtected as UIView; - if (type & GestureTypes.doubleTap) { - nativeView.addGestureRecognizer( - this._createRecognizer(GestureTypes.doubleTap, (args) => { - if (args.view) { - this._executeCallback(_getTapData(args)); - } - }) - ); - } + // For each of these gesture types (except for touch, as it's not very + // useful), we dispatch non-cancelable, non-bubbling DOM events (for + // consistency with the original behaviour of observers). In a breaking + // release, we may make them bubbling. - if (type & GestureTypes.pinch) { - nativeView.addGestureRecognizer( - this._createRecognizer(GestureTypes.pinch, (args) => { - if (args.view) { - this._executeCallback(_getPinchData(args)); - } - }) - ); - } + if (type & GestureTypes.tap) { + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.tap, + (args) => + args.view && + new DOMEvent('tap').dispatchTo({ + target: args.view as View, + data: _getTapData(args), + }) + ) + ); + } - if (type & GestureTypes.pan) { - nativeView.addGestureRecognizer( - this._createRecognizer(GestureTypes.pan, (args) => { - if (args.view) { - this._executeCallback(_getPanData(args, target.nativeViewProtected)); - } - }) - ); - } + if (type & GestureTypes.doubleTap) { + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.doubleTap, + (args) => + args.view && + new DOMEvent('doubleTap').dispatchTo({ + target: args.view as View, + data: _getTapData(args), + }) + ) + ); + } - if (type & GestureTypes.swipe) { - nativeView.addGestureRecognizer( - this._createRecognizer( - GestureTypes.swipe, - (args) => { - if (args.view) { - this._executeCallback(_getSwipeData(args)); - } - }, - UISwipeGestureRecognizerDirection.Down - ) - ); + if (type & GestureTypes.pinch) { + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.pinch, + (args) => + args.view && + new DOMEvent('pinch').dispatchTo({ + target: args.view as View, + data: _getPinchData(args), + }) + ) + ); + } - nativeView.addGestureRecognizer( - this._createRecognizer( - GestureTypes.swipe, - (args) => { - if (args.view) { - this._executeCallback(_getSwipeData(args)); - } - }, - UISwipeGestureRecognizerDirection.Left - ) - ); + if (type & GestureTypes.pan) { + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.pan, + (args) => + args.view && + new DOMEvent('pan').dispatchTo({ + target: args.view as View, + data: _getPanData(args, target.nativeViewProtected), + }) + ) + ); + } - nativeView.addGestureRecognizer( - this._createRecognizer( - GestureTypes.swipe, - (args) => { - if (args.view) { - this._executeCallback(_getSwipeData(args)); - } - }, - UISwipeGestureRecognizerDirection.Right - ) - ); + if (type & GestureTypes.swipe) { + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.swipe, + (args) => + args.view && + new DOMEvent('swipe').dispatchTo({ + target: args.view as View, + data: _getSwipeData(args), + }), + UISwipeGestureRecognizerDirection.Down + ) + ); - nativeView.addGestureRecognizer( - this._createRecognizer( - GestureTypes.swipe, - (args) => { - if (args.view) { - this._executeCallback(_getSwipeData(args)); - } - }, - UISwipeGestureRecognizerDirection.Up - ) - ); - } + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.swipe, + (args) => + args.view && + new DOMEvent('swipe').dispatchTo({ + target: args.view as View, + data: _getSwipeData(args), + }), + UISwipeGestureRecognizerDirection.Left + ) + ); - if (type & GestureTypes.rotation) { - nativeView.addGestureRecognizer( - this._createRecognizer(GestureTypes.rotation, (args) => { - if (args.view) { - this._executeCallback(_getRotationData(args)); - } - }) - ); - } + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.swipe, + (args) => + args.view && + new DOMEvent('swipe').dispatchTo({ + target: args.view as View, + data: _getSwipeData(args), + }), + UISwipeGestureRecognizerDirection.Right + ) + ); - if (type & GestureTypes.longPress) { - nativeView.addGestureRecognizer( - this._createRecognizer(GestureTypes.longPress, (args) => { - if (args.view) { - this._executeCallback(_getLongPressData(args)); - } - }) - ); - } + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.swipe, + (args) => + args.view && + new DOMEvent('swipe').dispatchTo({ + target: args.view as View, + data: _getSwipeData(args), + }), + UISwipeGestureRecognizerDirection.Up + ) + ); + } - if (type & GestureTypes.touch) { - nativeView.addGestureRecognizer(this._createRecognizer(GestureTypes.touch)); - } + if (type & GestureTypes.rotation) { + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.rotation, + (args) => + args.view && + new DOMEvent('rotation').dispatchTo({ + target: args.view as View, + data: _getRotationData(args), + }) + ) + ); + } + + if (type & GestureTypes.longPress) { + nativeView.addGestureRecognizer( + this._createRecognizer( + GestureTypes.longPress, + (args) => + args.view && + new DOMEvent('longPress').dispatchTo({ + target: args.view as View, + data: _getLongPressData(args), + }) + ) + ); + } + + if (type & GestureTypes.touch) { + nativeView.addGestureRecognizer(this._createRecognizer(GestureTypes.touch)); } } private _detach() { - if (this.target && this.target.nativeViewProtected) { - for (const name in this._recognizers) { - if (this._recognizers.hasOwnProperty(name)) { - const item = this._recognizers[name]; - this.target.nativeViewProtected.removeGestureRecognizer(item.recognizer); - - item.recognizer = null; - item.target = null; - } - } - this._recognizers = {}; + if (!this.target?.nativeViewProtected) { + return; } + + for (const name in this._recognizers) { + if (this._recognizers.hasOwnProperty(name)) { + const item = this._recognizers[name]; + this.target.nativeViewProtected.removeGestureRecognizer(item.recognizer); + + item.recognizer = null; + item.target = null; + } + } + this._recognizers = {}; } public disconnect() { @@ -275,62 +307,51 @@ export class GesturesObserver extends GesturesObserverBase { super.disconnect(); } - public _executeCallback(args: GestureEventData) { - if (this.callback) { - this.callback.call(this.context, args); - } - } - private _createRecognizer(type: GestureTypes, callback?: (args: GestureEventData) => void, swipeDirection?: UISwipeGestureRecognizerDirection): UIGestureRecognizer { - let recognizer: UIGestureRecognizer; let name = toString(type); - const target = _createUIGestureRecognizerTarget(this, type, callback, this.context); const recognizerType = _getUIGestureRecognizerType(type); - - if (recognizerType) { - recognizer = recognizerType.alloc().initWithTargetAction(target, 'recognize'); - - if (type === GestureTypes.swipe && swipeDirection) { - name = name + swipeDirection.toString(); - (recognizer).direction = swipeDirection; - } else if (type === GestureTypes.touch) { - (recognizer).observer = this; - } else if (type === GestureTypes.doubleTap) { - (recognizer).numberOfTapsRequired = 2; - } - - if (recognizer) { - recognizer.delegate = recognizerDelegateInstance; - this._recognizers[name] = { - recognizer: recognizer, - target: target, - }; - } - - this.target.notify({ - eventName: GestureEvents.gestureAttached, - object: this.target, - type, - view: this.target, - ios: recognizer, - }); + if (!recognizerType) { + return; } + const target = _createUIGestureRecognizerTarget(this, type, callback, this.context); + const recognizer = recognizerType.alloc().initWithTargetAction(target, 'recognize'); + + if (type === GestureTypes.swipe && swipeDirection) { + name = `${name}${swipeDirection}`; + (recognizer).direction = swipeDirection; + } else if (type === GestureTypes.touch) { + (recognizer).observer = this; + } else if (type === GestureTypes.doubleTap) { + (recognizer).numberOfTapsRequired = 2; + } + + recognizer.delegate = recognizerDelegateInstance; + this._recognizers[name] = { recognizer, target }; + + this.target.notify({ + eventName: GestureEvents.gestureAttached, + object: this.target, + type, + view: this.target, + ios: recognizer, + }); + return recognizer; } } -function _createUIGestureRecognizerTarget(owner: GesturesObserver, type: GestureTypes, callback?: (args: GestureEventData) => void, context?: any): any { +function _createUIGestureRecognizerTarget(owner: GesturesObserver, type: GestureTypes, callback?: (args: GestureEventData) => void, context?: any) { return UIGestureRecognizerImpl.initWithOwnerTypeCallback(new WeakRef(owner), type, callback, context); } interface RecognizerCache { recognizer: UIGestureRecognizer; - target: any; + target: UIGestureRecognizerImpl; } -function _getUIGestureRecognizerType(type: GestureTypes): any { - let nativeType = null; +function _getUIGestureRecognizerType(type: GestureTypes) { + let nativeType: typeof UIGestureRecognizer | null = null; if (type === GestureTypes.tap) { nativeType = UITapGestureRecognizer; @@ -478,30 +499,22 @@ class TouchGestureRecognizer extends UIGestureRecognizer { touchesBeganWithEvent(touches: NSSet, event: any): void { this.executeCallback(TouchAction.down, touches, event); - if (this.view) { - this.view.touchesBeganWithEvent(touches, event); - } + this.view?.touchesBeganWithEvent(touches, event); } touchesMovedWithEvent(touches: NSSet, event: any): void { this.executeCallback(TouchAction.move, touches, event); - if (this.view) { - this.view.touchesMovedWithEvent(touches, event); - } + this.view?.touchesMovedWithEvent(touches, event); } touchesEndedWithEvent(touches: NSSet, event: any): void { this.executeCallback(TouchAction.up, touches, event); - if (this.view) { - this.view.touchesEndedWithEvent(touches, event); - } + this.view?.touchesEndedWithEvent(touches, event); } touchesCancelledWithEvent(touches: NSSet, event: any): void { this.executeCallback(TouchAction.cancel, touches, event); - if (this.view) { - this.view.touchesCancelledWithEvent(touches, event); - } + this.view?.touchesCancelledWithEvent(touches, event); } private executeCallback(action: string, touches: NSSet, event: any): void { @@ -510,11 +523,11 @@ class TouchGestureRecognizer extends UIGestureRecognizer { } this._eventData.prepare(this.observer.target, action, touches, event); - this.observer._executeCallback(this._eventData); + this.observer.callback?.(this._eventData); } } -class Pointer implements Pointer { +class Pointer { public android: any = undefined; public ios: UITouch = undefined; @@ -544,7 +557,7 @@ class Pointer implements Pointer { } } -class TouchGestureEventData implements TouchGestureEventData { +class TouchGestureEventData { eventName: string = toString(GestureTypes.touch); type: GestureTypes = GestureTypes.touch; android: any = undefined;