Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion apps/automated/src/ui/view/view-tests-common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as TKUnit from '../../tk-unit';
import { View, eachDescendant, getViewById, InheritedProperty, CssProperty, CssAnimationProperty, ShorthandProperty, Property, Style, Frame, Page, Button, Label, Color, StackLayout, AbsoluteLayout, Observable, Utils, BindingOptions, isAndroid, LayoutBase } from '@nativescript/core';
import { View, eachDescendant, getViewById, InheritedProperty, CssProperty, CssAnimationProperty, ShorthandProperty, Property, Style, Frame, Page, Button, Label, Color, StackLayout, AbsoluteLayout, Observable, Utils, BindingOptions, isAndroid, LayoutBase, GestureTypes } from '@nativescript/core';
import * as helper from '../../ui-helper';

export function test_eachDescendant() {
Expand Down Expand Up @@ -1075,3 +1075,26 @@ export function test_shorthand_property_doesnt_cache() {
TKUnit.assertEqual(view.customCssA, 'left', "Expected customCssA to be 'left' the second time.");
TKUnit.assertEqual(view.customCssB, 'right', "Expected customCssA to be 'right' the second time.");
}

export function test_swapping_gesture_listener_reuses_observer() {
helper.buildUIAndRunTest(new StackLayout(), function (views: Array<View>) {
const layout = views[0];
const calls: string[] = [];
const first = () => calls.push('first');
const second = () => calls.push('second');

layout.on('pan', first);
const observer = layout.getGestureObservers(GestureTypes.pan)[0];

layout.off('pan', first);
layout.on('pan', second);

const observers = layout.getGestureObservers(GestureTypes.pan);
TKUnit.assertEqual(observers.length, 1, 'One observer after swapping the listener.');
TKUnit.assertEqual(observers[0], observer, 'The observer tracking the gesture should be kept.');
TKUnit.assertEqual(observers[0].callback, second, 'The kept observer should call the new listener.');

layout.off('pan', second);
TKUnit.assertNull(layout.getGestureObservers(GestureTypes.pan), 'No observers after removing the listener.');
});
}
24 changes: 24 additions & 0 deletions apps/automated/src/ui/view/view-tests.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,27 @@ export function test_automation_text_set_to_native() {
TKUnit.assertEqual((<UIView>newButton.ios).accessibilityIdentifier, 'Button1', 'accessibilityIdentifier not set to native view.');
TKUnit.assertEqual((<UIView>newButton.ios).accessibilityLabel, 'Button1', 'accessibilityLabel not set to native view.');
}

export function test_swapping_touch_listener_keeps_native_recognizer() {
const layout = new GridLayout();
helper.buildUIAndRunTest(layout, function () {
const nativeView = <UIView>layout.ios;
const count = () => nativeView.gestureRecognizers?.count ?? 0;
const initial = count();
const first = () => {};
const second = () => {};

layout.on('touch', first);
const recognizer = nativeView.gestureRecognizers.lastObject;

layout.off('touch', first);
layout.on('touch', second);
TKUnit.waitUntilReady(() => false, 0.1, false);

TKUnit.assertEqual(count(), initial + 1, 'Swapping the listener should not add a recognizer.');
TKUnit.assertEqual(nativeView.gestureRecognizers.lastObject, recognizer, 'Swapping the listener should keep the recognizer that is tracking the touch.');

layout.off('touch', second);
TKUnit.waitUntilReady(() => count() === initial);
});
}
134 changes: 134 additions & 0 deletions packages/core/ui/core/view/gesture-observers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { StackLayout } from '../../layouts/stack-layout';
import { GestureTypes } from '../../gestures';

function nextTurn() {
return new Promise((resolve) => setTimeout(resolve));
}

class FakeRecognizer {
delegate: any;
target: any;
initWithTargetAction(target: any) {
this.target = target;
return this;
}
}

function loadedView() {
const view = new StackLayout();
const added: FakeRecognizer[] = [];
const removed: FakeRecognizer[] = [];
(view as any).nativeViewProtected = {
addGestureRecognizer: (recognizer: FakeRecognizer) => added.push(recognizer),
removeGestureRecognizer: (recognizer: FakeRecognizer) => removed.push(recognizer),
};
(view as any)._isLoaded = true;
return { view, added, removed };
}

describe('gesture listeners', () => {
const NSObject = (global as any).NSObject;
const originalNew = NSObject.new;

beforeAll(() => {
(global as any).UITapGestureRecognizer = { alloc: () => new FakeRecognizer() };
NSObject.new = function () {
return new this();
};
});

afterAll(() => {
delete (global as any).UITapGestureRecognizer;
NSObject.new = originalNew;
});

it('keeps the recognizer when a listener is swapped in the same turn', async () => {
const { view, added, removed } = loadedView();
const first = vi.fn();
const second = vi.fn();

view.on('tap', first);
const observer = view.getGestureObservers(GestureTypes.tap)[0];
view.off('tap', first);
view.on('tap', second);
await nextTurn();

expect(added).toHaveLength(1);
expect(removed).toHaveLength(0);
expect(view.getGestureObservers(GestureTypes.tap)).toEqual([observer]);
expect(observer.callback).toBe(second);
});

it('calls only the new listener for the gesture in progress', () => {
const { view, added } = loadedView();
const first = vi.fn();
const second = vi.fn();
const context = {};

view.on('tap', first);
view.off('tap', first);
view.on('tap', second, context);

added[0].target.recognize({ locationInView: () => ({ x: 1, y: 2 }), numberOfTouches: 1 });

expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
expect(second.mock.contexts[0]).toBe(context);
expect(second.mock.calls[0][0].getX()).toBe(1);
});

it('stops calling a removed listener right away', () => {
const { view, added } = loadedView();
const listener = vi.fn();

view.on('tap', listener);
view.off('tap', listener);
added[0].target.recognize({ locationInView: () => ({ x: 0, y: 0 }), numberOfTouches: 1 });

expect(listener).not.toHaveBeenCalled();
expect(view.getGestureObservers(GestureTypes.tap)).toBeUndefined();
});

it('removes the recognizer in the next turn when no listener takes it over', async () => {
const { view, added, removed } = loadedView();
const listener = vi.fn();

view.on('tap', listener);
const observer = view.getGestureObservers(GestureTypes.tap)[0];
const disconnect = vi.spyOn(observer, 'disconnect');
view.off('tap', listener);

expect(removed).toHaveLength(0);
await nextTurn();

expect(disconnect).toHaveBeenCalledTimes(1);
expect(removed).toEqual(added);

view.on('tap', listener);
expect(added).toHaveLength(2);
});

it('does not hand a released recognizer to another gesture type', async () => {
const { view } = loadedView();
const tap = vi.fn();
const doubleTap = vi.fn();

view.on('tap', tap);
const tapObserver = view.getGestureObservers(GestureTypes.tap)[0];
view.off('tap', tap);
view.on('doubleTap', doubleTap);

expect(view.getGestureObservers(GestureTypes.doubleTap)[0]).not.toBe(tapObserver);
});

it('still ignores a listener that is already added', () => {
const { view, added } = loadedView();
const listener = vi.fn();

view.on('tap', listener);
view.on('tap', listener);

expect(view.getGestureObservers(GestureTypes.tap)).toHaveLength(1);
expect(added).toHaveLength(1);
});
});
33 changes: 32 additions & 1 deletion packages/core/ui/core/view/view-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ export abstract class ViewCommon extends ViewBase {

public readonly _gestureObservers = {} as Record<GestureTypes, Array<GesturesObserver>>;

private _releasedGestureObservers: Partial<Record<GestureTypes, Array<GesturesObserver>>>;
private _releaseGestureObserversTimer: any;

_androidContentDescriptionUpdated?: boolean;

get css(): string {
Expand Down Expand Up @@ -378,6 +381,14 @@ export abstract class ViewCommon extends ViewBase {
this._gestureObservers[type] = [];
}

const released = this._releasedGestureObservers?.[type];
if (released?.length) {
const observer = released.shift();
observer._setCallback(callback, thisArg);
this._gestureObservers[type].push(observer);
return;
}

this._gestureObservers[type].push(gestureObserve(this, type, callback, thisArg));
}

Expand Down Expand Up @@ -646,16 +657,36 @@ export abstract class ViewCommon extends ViewBase {
continue;
}

observer.disconnect();
observers.splice(i, 1);
i--;
this._releaseGestureObserver(type, observer);
}

if (!observers.length) {
delete this._gestureObservers[type];
}
}

private _releaseGestureObserver(type: GestureTypes, observer: GesturesObserver): void {
observer._setCallback(null, null);

const released = this._releasedGestureObservers || (this._releasedGestureObservers = {});
(released[type] || (released[type] = [])).push(observer);

if (!this._releaseGestureObserversTimer) {
this._releaseGestureObserversTimer = setTimeout(() => {
this._releaseGestureObserversTimer = null;
const pending = this._releasedGestureObservers;
this._releasedGestureObservers = null;
for (const key in pending) {
for (const observer of pending[key]) {
observer.disconnect();
}
}
});
}
}

// START Style property shortcuts
get flexFlow(): FlexFlow {
return this.style.flexFlow;
Expand Down
5 changes: 5 additions & 0 deletions packages/core/ui/gestures/gestures-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export abstract class GesturesObserverBase implements GesturesObserverDefinition
this._context = context;
}

public _setCallback(callback: (args: GestureEventData) => void, context?: any) {
this._callback = callback;
this._context = context;
}

public abstract androidOnTouchEvent(motionEvent: android.view.MotionEvent);
public abstract observe(type: GestureTypes);

Expand Down
3 changes: 3 additions & 0 deletions packages/core/ui/gestures/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export class GesturesObserver {
*/
context: any;

/** Points the observer at another listener while keeping its native recognizer. */
_setCallback(callback: (args: GestureEventData) => void, context?: any): void;

/**
* An internal Android specific method used to pass the motion event to the correct gesture observer.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/core/ui/gestures/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class UIGestureRecognizerImpl extends NSObject {
public recognize(recognizer: UIGestureRecognizer): void {
const owner = this._owner?.deref();
const callback = this._callback ? this._callback : owner ? owner.callback : null;
const context = this._callback ? this._context : owner?.context;
const typeParam = this._type;
const target = owner ? owner.target : undefined;

Expand All @@ -80,7 +81,7 @@ class UIGestureRecognizerImpl extends NSObject {
};

if (callback) {
callback.call(this._context, args);
callback.call(context, args);
}
}
}
Expand Down
Loading