Pewarta Proxy

📋 Server Request & Proxy Logs

Loading active proxy settings...
Developed by Khairul Adha (adhexa.id)
Mengambil log server...

⚙️ Pengaturan Proxy Target

Skip to content

Commit 23fecd1

Browse files
committed
fix(core): keep a gesture's recognizer when its listener is swapped
Renderers such as React and DOMiNATIVE swap an event handler by removing the old listener and adding the new one, which for an inline arrow happens on every render, often in response to the gesture itself. Each gesture listener owned its own GesturesObserver, so removing it tore down the native recognizer (the UIGestureRecognizer on iOS, the gesture detector on Android) and adding the new one created a fresh recognizer that had never seen the touch in progress. A handler that set state on `down` got no `move` or `up` after it, so sliders and charts only ever saw the first event; pan, pinch and the other detectors lost the gesture the same way. Removing a gesture listener now stops calling it at once but keeps its observer, and the native recognizer, until the next turn. A listener for the same gesture added before then takes over that observer instead of creating a new one, so it receives the rest of the gesture. Observers that are not taken over are disconnected in the next turn as before.
1 parent aeac327 commit 23fecd1

7 files changed

Lines changed: 252 additions & 3 deletions

File tree

‎apps/automated/src/ui/view/view-tests-common.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as TKUnit from '../../tk-unit';
2-
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';
2+
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';
33
import * as helper from '../../ui-helper';
44

55
export function test_eachDescendant() {
@@ -1075,3 +1075,27 @@ export function test_shorthand_property_doesnt_cache() {
10751075
TKUnit.assertEqual(view.customCssA, 'left', "Expected customCssA to be 'left' the second time.");
10761076
TKUnit.assertEqual(view.customCssB, 'right', "Expected customCssA to be 'right' the second time.");
10771077
}
1078+
1079+
export function test_swapping_gesture_listener_reuses_observer() {
1080+
helper.buildUIAndRunTest(new StackLayout(), function (views: Array<View>) {
1081+
const layout = views[0];
1082+
const calls: string[] = [];
1083+
const first = () => calls.push('first');
1084+
const second = () => calls.push('second');
1085+
1086+
layout.on('pan', first);
1087+
const observer = layout.getGestureObservers(GestureTypes.pan)[0];
1088+
1089+
// Renderers swap handlers like this on every render, often mid-gesture.
1090+
layout.off('pan', first);
1091+
layout.on('pan', second);
1092+
1093+
const observers = layout.getGestureObservers(GestureTypes.pan);
1094+
TKUnit.assertEqual(observers.length, 1, 'One observer after swapping the listener.');
1095+
TKUnit.assertEqual(observers[0], observer, 'The observer tracking the gesture should be kept.');
1096+
TKUnit.assertEqual(observers[0].callback, second, 'The kept observer should call the new listener.');
1097+
1098+
layout.off('pan', second);
1099+
TKUnit.assertNull(layout.getGestureObservers(GestureTypes.pan), 'No observers after removing the listener.');
1100+
});
1101+
}

‎apps/automated/src/ui/view/view-tests.ios.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,28 @@ export function test_automation_text_set_to_native() {
128128
TKUnit.assertEqual((<UIView>newButton.ios).accessibilityIdentifier, 'Button1', 'accessibilityIdentifier not set to native view.');
129129
TKUnit.assertEqual((<UIView>newButton.ios).accessibilityLabel, 'Button1', 'accessibilityLabel not set to native view.');
130130
}
131+
132+
export function test_swapping_touch_listener_keeps_native_recognizer() {
133+
const layout = new GridLayout();
134+
helper.buildUIAndRunTest(layout, function () {
135+
const nativeView = <UIView>layout.ios;
136+
const count = () => nativeView.gestureRecognizers?.count ?? 0;
137+
const initial = count();
138+
const first = () => {};
139+
const second = () => {};
140+
141+
layout.on('touch', first);
142+
const recognizer = nativeView.gestureRecognizers.lastObject;
143+
144+
// Renderers swap handlers like this on every render, often mid-gesture.
145+
layout.off('touch', first);
146+
layout.on('touch', second);
147+
TKUnit.waitUntilReady(() => false, 0.1, false);
148+
149+
TKUnit.assertEqual(count(), initial + 1, 'Swapping the listener should not add a recognizer.');
150+
TKUnit.assertEqual(nativeView.gestureRecognizers.lastObject, recognizer, 'Swapping the listener should keep the recognizer that is tracking the touch.');
151+
152+
layout.off('touch', second);
153+
TKUnit.waitUntilReady(() => count() === initial);
154+
});
155+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { StackLayout } from '../../layouts/stack-layout';
2+
import { GestureTypes } from '../../gestures';
3+
4+
function nextTurn() {
5+
return new Promise((resolve) => setTimeout(resolve));
6+
}
7+
8+
class FakeRecognizer {
9+
delegate: any;
10+
target: any;
11+
initWithTargetAction(target: any) {
12+
this.target = target;
13+
return this;
14+
}
15+
}
16+
17+
// A loaded view whose native view records the recognizers core adds and removes.
18+
function loadedView() {
19+
const view = new StackLayout();
20+
const added: FakeRecognizer[] = [];
21+
const removed: FakeRecognizer[] = [];
22+
(view as any).nativeViewProtected = {
23+
addGestureRecognizer: (recognizer: FakeRecognizer) => added.push(recognizer),
24+
removeGestureRecognizer: (recognizer: FakeRecognizer) => removed.push(recognizer),
25+
};
26+
(view as any)._isLoaded = true;
27+
return { view, added, removed };
28+
}
29+
30+
describe('gesture listeners', () => {
31+
const NSObject = (global as any).NSObject;
32+
const originalNew = NSObject.new;
33+
34+
beforeAll(() => {
35+
(global as any).UITapGestureRecognizer = { alloc: () => new FakeRecognizer() };
36+
// Let recognizer targets (NSObject subclasses) be created as themselves.
37+
NSObject.new = function () {
38+
return new this();
39+
};
40+
});
41+
42+
afterAll(() => {
43+
delete (global as any).UITapGestureRecognizer;
44+
NSObject.new = originalNew;
45+
});
46+
47+
it('keeps the recognizer when a listener is swapped in the same turn', async () => {
48+
const { view, added, removed } = loadedView();
49+
const first = vi.fn();
50+
const second = vi.fn();
51+
52+
view.on('tap', first);
53+
const observer = view.getGestureObservers(GestureTypes.tap)[0];
54+
view.off('tap', first);
55+
view.on('tap', second);
56+
await nextTurn();
57+
58+
expect(added).toHaveLength(1);
59+
expect(removed).toHaveLength(0);
60+
expect(view.getGestureObservers(GestureTypes.tap)).toEqual([observer]);
61+
expect(observer.callback).toBe(second);
62+
});
63+
64+
it('calls only the new listener for the gesture in progress', () => {
65+
const { view, added } = loadedView();
66+
const first = vi.fn();
67+
const second = vi.fn();
68+
const context = {};
69+
70+
view.on('tap', first);
71+
view.off('tap', first);
72+
view.on('tap', second, context);
73+
74+
added[0].target.recognize({ locationInView: () => ({ x: 1, y: 2 }), numberOfTouches: 1 });
75+
76+
expect(first).not.toHaveBeenCalled();
77+
expect(second).toHaveBeenCalledTimes(1);
78+
expect(second.mock.contexts[0]).toBe(context);
79+
expect(second.mock.calls[0][0].getX()).toBe(1);
80+
});
81+
82+
it('stops calling a removed listener right away', () => {
83+
const { view, added } = loadedView();
84+
const listener = vi.fn();
85+
86+
view.on('tap', listener);
87+
view.off('tap', listener);
88+
added[0].target.recognize({ locationInView: () => ({ x: 0, y: 0 }), numberOfTouches: 1 });
89+
90+
expect(listener).not.toHaveBeenCalled();
91+
expect(view.getGestureObservers(GestureTypes.tap)).toBeUndefined();
92+
});
93+
94+
it('removes the recognizer in the next turn when no listener takes it over', async () => {
95+
const { view, added, removed } = loadedView();
96+
const listener = vi.fn();
97+
98+
view.on('tap', listener);
99+
const observer = view.getGestureObservers(GestureTypes.tap)[0];
100+
const disconnect = vi.spyOn(observer, 'disconnect');
101+
view.off('tap', listener);
102+
103+
expect(removed).toHaveLength(0);
104+
await nextTurn();
105+
106+
expect(disconnect).toHaveBeenCalledTimes(1);
107+
expect(removed).toEqual(added);
108+
109+
// A later listener gets a fresh recognizer.
110+
view.on('tap', listener);
111+
expect(added).toHaveLength(2);
112+
});
113+
114+
it('does not hand a released recognizer to another gesture type', async () => {
115+
const { view } = loadedView();
116+
const tap = vi.fn();
117+
const doubleTap = vi.fn();
118+
119+
view.on('tap', tap);
120+
const tapObserver = view.getGestureObservers(GestureTypes.tap)[0];
121+
view.off('tap', tap);
122+
view.on('doubleTap', doubleTap);
123+
124+
expect(view.getGestureObservers(GestureTypes.doubleTap)[0]).not.toBe(tapObserver);
125+
});
126+
127+
it('still ignores a listener that is already added', () => {
128+
const { view, added } = loadedView();
129+
const listener = vi.fn();
130+
131+
view.on('tap', listener);
132+
view.on('tap', listener);
133+
134+
expect(view.getGestureObservers(GestureTypes.tap)).toHaveLength(1);
135+
expect(added).toHaveLength(1);
136+
});
137+
});

‎packages/core/ui/core/view/view-common.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,15 @@ export abstract class ViewCommon extends ViewBase {
180180

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

183+
/**
184+
* Observers whose listener was removed in this turn. They keep their native
185+
* recognizer until the next turn, so a listener added in the meantime can
186+
* take one over instead of attaching a recognizer that has not seen the
187+
* gesture in progress.
188+
*/
189+
private _releasedGestureObservers: Partial<Record<GestureTypes, Array<GesturesObserver>>>;
190+
private _releaseGestureObserversTimer: any;
191+
183192
_androidContentDescriptionUpdated?: boolean;
184193

185194
get css(): string {
@@ -378,6 +387,17 @@ export abstract class ViewCommon extends ViewBase {
378387
this._gestureObservers[type] = [];
379388
}
380389

390+
// Renderers swap handlers by removing the old one and adding the new one.
391+
// Re-use a recognizer released in this turn so the new handler receives
392+
// the rest of a gesture that is in progress.
393+
const released = this._releasedGestureObservers?.[type];
394+
if (released?.length) {
395+
const observer = released.shift();
396+
observer._setCallback(callback, thisArg);
397+
this._gestureObservers[type].push(observer);
398+
return;
399+
}
400+
381401
this._gestureObservers[type].push(gestureObserve(this, type, callback, thisArg));
382402
}
383403

@@ -646,16 +666,41 @@ export abstract class ViewCommon extends ViewBase {
646666
continue;
647667
}
648668

649-
observer.disconnect();
650669
observers.splice(i, 1);
651670
i--;
671+
this._releaseGestureObserver(type, observer);
652672
}
653673

654674
if (!observers.length) {
655675
delete this._gestureObservers[type];
656676
}
657677
}
658678

679+
/**
680+
* Stops calling the observer's listener right away, but disconnects its
681+
* native recognizer only in the next turn, unless `_observe` takes it over
682+
* first.
683+
*/
684+
private _releaseGestureObserver(type: GestureTypes, observer: GesturesObserver): void {
685+
observer._setCallback(null, null);
686+
687+
const released = this._releasedGestureObservers || (this._releasedGestureObservers = {});
688+
(released[type] || (released[type] = [])).push(observer);
689+
690+
if (!this._releaseGestureObserversTimer) {
691+
this._releaseGestureObserversTimer = setTimeout(() => {
692+
this._releaseGestureObserversTimer = null;
693+
const pending = this._releasedGestureObservers;
694+
this._releasedGestureObservers = null;
695+
for (const key in pending) {
696+
for (const observer of pending[key]) {
697+
observer.disconnect();
698+
}
699+
}
700+
});
701+
}
702+
}
703+
659704
// START Style property shortcuts
660705
get flexFlow(): FlexFlow {
661706
return this.style.flexFlow;

‎packages/core/ui/gestures/gestures-common.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ export abstract class GesturesObserverBase implements GesturesObserverDefinition
9292
this._context = context;
9393
}
9494

95+
/**
96+
* @private
97+
* Points the observer at another listener while keeping its native
98+
* recognizer, so the new listener receives a gesture already in progress.
99+
*/
100+
public _setCallback(callback: (args: GestureEventData) => void, context?: any) {
101+
this._callback = callback;
102+
this._context = context;
103+
}
104+
95105
public abstract androidOnTouchEvent(motionEvent: android.view.MotionEvent);
96106
public abstract observe(type: GestureTypes);
97107

‎packages/core/ui/gestures/index.d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export class GesturesObserver {
4545
*/
4646
context: any;
4747

48+
/**
49+
* @private
50+
* Points the observer at another listener while keeping its native
51+
* recognizer, so the new listener receives a gesture already in progress.
52+
*/
53+
_setCallback(callback: (args: GestureEventData) => void, context?: any): void;
54+
4855
/**
4956
* An internal Android specific method used to pass the motion event to the correct gesture observer.
5057
*/

‎packages/core/ui/gestures/index.ios.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class UIGestureRecognizerImpl extends NSObject {
6767
public recognize(recognizer: UIGestureRecognizer): void {
6868
const owner = this._owner?.deref();
6969
const callback = this._callback ? this._callback : owner ? owner.callback : null;
70+
const context = this._callback ? this._context : owner?.context;
7071
const typeParam = this._type;
7172
const target = owner ? owner.target : undefined;
7273

@@ -80,7 +81,7 @@ class UIGestureRecognizerImpl extends NSObject {
8081
};
8182

8283
if (callback) {
83-
callback.call(this._context, args);
84+
callback.call(context, args);
8485
}
8586
}
8687
}

0 commit comments

Comments
 (0)