Skip to content

Commit 36f44ca

Browse files
authored
docs: clarify iOS drag synthesis profiles (#1616)
* docs: clarify iOS drag synthesis profiles * refactor: consolidate Apple gesture event lifecycle
1 parent 9fc2663 commit 36f44ca

2 files changed

Lines changed: 108 additions & 88 deletions

File tree

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m

Lines changed: 82 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ typedef id (*RunnerDragPointerPathFactory)(
4040
static NSString * _Nullable RunnerRequireClass(Class cls, NSString *className);
4141
static NSString * _Nullable RunnerRequireSelector(Class cls, SEL selector, NSString *selectorName);
4242
static NSString * _Nullable RunnerRequireApplicationSelector(id application, SEL selector, NSString *selectorName);
43+
static NSString * _Nullable RunnerCreateEventRecord(
44+
id application,
45+
NSString *recordName,
46+
RunnerXCTestEventBridge *bridge,
47+
id *record
48+
);
49+
static NSString * _Nullable RunnerSynthesizeEventRecord(
50+
const RunnerXCTestEventBridge *bridge,
51+
id record
52+
);
4353
static id RunnerSwipePointerPath(
4454
const RunnerXCTestEventBridge *bridge,
4555
CGPoint start,
@@ -60,6 +70,7 @@ static id RunnerContinuousDragPointerPath(
6070
NSString *recordName,
6171
RunnerDragPointerPathFactory pathFactory
6272
);
73+
static NSString * _Nullable RunnerTrySynthesizeTap(id application, CGPoint point);
6374
// XCTest's proven swipe profile reaches the endpoint in 100 ms, then holds for the planned
6475
// fling duration. Fast movement is what lets UIKit distinguish a fling from a timed pan.
6576
static const NSTimeInterval RunnerSwipeMovementDurationSeconds = 0.1;
@@ -118,7 +129,7 @@ + (NSString * _Nullable)synthesizeTapWithApplication:(id)application
118129
x:(double)x
119130
y:(double)y {
120131
@try {
121-
return [self trySynthesizeTapWithApplication:application x:x y:y];
132+
return RunnerTrySynthesizeTap(application, CGPointMake(x, y));
122133
} @catch (NSException *exception) {
123134
NSString *name = exception.name ?: @"NSException";
124135
NSString *reason = exception.reason ?: @"private XCTest event synthesis failed";
@@ -130,27 +141,14 @@ + (NSString * _Nullable)synthesizeGestureWithApplication:(id)application
130141
pointerSamples:(NSArray<NSArray<NSDictionary<NSString *, NSNumber *> *> *> *)pointerSamples {
131142
@try {
132143
RunnerXCTestEventBridge bridge;
133-
NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge);
134-
if (missing != nil) return missing;
135-
136-
NSInteger interfaceOrientation =
137-
((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector);
138-
NSInteger targetProcessID =
139-
((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector);
140-
if (targetProcessID <= 0) {
141-
return @"private XCTest event synthesis unavailable: could not resolve target process ID";
142-
}
143-
144-
id record = ((RunnerMsgSendInitRecord)objc_msgSend)(
145-
[bridge.recordClass alloc],
146-
bridge.initRecordSelector,
144+
id record = nil;
145+
NSString *error = RunnerCreateEventRecord(
146+
application,
147147
@"agent-device-gesture-plan",
148-
interfaceOrientation
148+
&bridge,
149+
&record
149150
);
150-
if (record == nil) {
151-
return @"private XCTest event synthesis failed: could not create event record";
152-
}
153-
((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID);
151+
if (error != nil) return error;
154152

155153
for (NSArray<NSDictionary<NSString *, NSNumber *> *> *samples in pointerSamples) {
156154
NSDictionary<NSString *, NSNumber *> *first = samples.firstObject;
@@ -184,13 +182,7 @@ + (NSString * _Nullable)synthesizeGestureWithApplication:(id)application
184182
((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path);
185183
}
186184

187-
NSError *error = nil;
188-
BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error);
189-
if (!ok) {
190-
NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
191-
return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
192-
}
193-
return nil;
185+
return RunnerSynthesizeEventRecord(&bridge, record);
194186
} @catch (NSException *exception) {
195187
NSString *name = exception.name ?: @"NSException";
196188
NSString *reason = exception.reason ?: @"private XCTest event synthesis failed";
@@ -215,84 +207,36 @@ + (NSInteger)interfaceOrientationForApplication:(id)application {
215207
RunnerDragPointerPathFactory pathFactory
216208
) {
217209
RunnerXCTestEventBridge bridge;
218-
NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge);
219-
if (missing != nil) {
220-
return missing;
221-
}
222-
223-
NSInteger interfaceOrientation =
224-
((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector);
225-
NSInteger targetProcessID = ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector);
226-
if (targetProcessID <= 0) {
227-
return @"private XCTest event synthesis unavailable: could not resolve target process ID";
228-
}
229-
230-
id record = ((RunnerMsgSendInitRecord)objc_msgSend)(
231-
[bridge.recordClass alloc],
232-
bridge.initRecordSelector,
233-
recordName,
234-
interfaceOrientation
235-
);
236-
if (record == nil) {
237-
return @"private XCTest event synthesis failed: could not create event record";
238-
}
239-
((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID);
210+
id record = nil;
211+
NSString *error = RunnerCreateEventRecord(application, recordName, &bridge, &record);
212+
if (error != nil) return error;
240213

241214
id path = pathFactory(&bridge, start, end, durationMs);
242215
if (path == nil) {
243216
return @"private XCTest event synthesis failed: could not create pointer path";
244217
}
245218
((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path);
246219

247-
NSError *error = nil;
248-
BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error);
249-
if (!ok) {
250-
NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
251-
return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
252-
}
253-
return nil;
220+
return RunnerSynthesizeEventRecord(&bridge, record);
254221
}
255222

256-
+ (NSString * _Nullable)trySynthesizeTapWithApplication:(id)application
257-
x:(double)x
258-
y:(double)y {
223+
static NSString * _Nullable RunnerTrySynthesizeTap(id application, CGPoint point) {
259224
RunnerXCTestEventBridge bridge;
260-
NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge);
261-
if (missing != nil) {
262-
return missing;
263-
}
264-
265-
NSInteger interfaceOrientation =
266-
((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector);
267-
NSInteger targetProcessID = ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector);
268-
if (targetProcessID <= 0) {
269-
return @"private XCTest event synthesis unavailable: could not resolve target process ID";
270-
}
271-
272-
id record = ((RunnerMsgSendInitRecord)objc_msgSend)(
273-
[bridge.recordClass alloc],
274-
bridge.initRecordSelector,
225+
id record = nil;
226+
NSString *error = RunnerCreateEventRecord(
227+
application,
275228
@"agent-device-tap",
276-
interfaceOrientation
229+
&bridge,
230+
&record
277231
);
278-
if (record == nil) {
279-
return @"private XCTest event synthesis failed: could not create event record";
280-
}
281-
((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID);
232+
if (error != nil) return error;
282233

283-
id path = RunnerTapPointerPath(&bridge, CGPointMake(x, y));
234+
id path = RunnerTapPointerPath(&bridge, point);
284235
if (path == nil) {
285236
return @"private XCTest event synthesis failed: could not create pointer path";
286237
}
287238
((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path);
288-
289-
NSError *error = nil;
290-
BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error);
291-
if (!ok) {
292-
NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
293-
return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
294-
}
295-
return nil;
239+
return RunnerSynthesizeEventRecord(&bridge, record);
296240
}
297241

298242
static NSString * _Nullable RunnerResolveXCTestEventBridge(
@@ -382,6 +326,53 @@ + (NSString * _Nullable)trySynthesizeTapWithApplication:(id)application
382326
return nil;
383327
}
384328

329+
static NSString * _Nullable RunnerCreateEventRecord(
330+
id application,
331+
NSString *recordName,
332+
RunnerXCTestEventBridge *bridge,
333+
id *record
334+
) {
335+
NSString *missing = RunnerResolveXCTestEventBridge(application, bridge);
336+
if (missing != nil) return missing;
337+
338+
NSInteger interfaceOrientation =
339+
((RunnerMsgSendInteger)objc_msgSend)(application, bridge->interfaceOrientationSelector);
340+
NSInteger targetProcessID =
341+
((RunnerMsgSendInteger)objc_msgSend)(application, bridge->processIDSelector);
342+
if (targetProcessID <= 0) {
343+
return @"private XCTest event synthesis unavailable: could not resolve target process ID";
344+
}
345+
346+
id eventRecord = ((RunnerMsgSendInitRecord)objc_msgSend)(
347+
[bridge->recordClass alloc],
348+
bridge->initRecordSelector,
349+
recordName,
350+
interfaceOrientation
351+
);
352+
if (eventRecord == nil) {
353+
return @"private XCTest event synthesis failed: could not create event record";
354+
}
355+
((RunnerMsgSendSetInteger)objc_msgSend)(
356+
eventRecord,
357+
bridge->setTargetProcessIDSelector,
358+
targetProcessID
359+
);
360+
*record = eventRecord;
361+
return nil;
362+
}
363+
364+
static NSString * _Nullable RunnerSynthesizeEventRecord(
365+
const RunnerXCTestEventBridge *bridge,
366+
id record
367+
) {
368+
NSError *error = nil;
369+
BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge->synthesizeSelector, &error);
370+
if (!ok) {
371+
NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
372+
return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
373+
}
374+
return nil;
375+
}
385376

386377
static id RunnerSwipePointerPath(
387378
const RunnerXCTestEventBridge *bridge,
@@ -422,6 +413,9 @@ static id RunnerContinuousDragPointerPath(
422413
return nil;
423414
}
424415

416+
// This is velocity shaping, not just interpolation density: smoothstep's endpoint slope is zero,
417+
// while a planned linear segment reaches lift with nonzero velocity unless a destination hold
418+
// follows it. UIKit uses finger-up velocity for scroll deceleration. See ADR 0013 and issue #1586.
425419
int frameCount = MAX(3, (int)(durationMs / 16.0));
426420
NSTimeInterval durationSeconds = durationMs / 1000.0;
427421
for (int index = 1; index <= frameCount; index += 1) {

docs/adr/0013-unified-gesture-plans.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,32 @@ Platform adapters consume the canonical plan:
9696
macOS lowers a one-contact plan to its drag executor and tvOS lowers it to remote direction. Core
9797
admission and the Apple adapter both consume the same shared multi-touch support policy;
9898
multi-touch remains capability-gated to iOS simulators.
99+
100+
iOS deliberately retains three one-contact motion schedules behind the shared private-XCTest event
101+
bridge. Apple's public drag API models the phases independently as an
102+
[initial hold, movement velocity, and destination hold](https://developer.apple.com/documentation/xcuiautomation/xcuicoordinate/press%28forduration%3Athendragto%3Awithvelocity%3Athenholdforduration%3A%29).
103+
UIKit likewise reports the velocity at finger-up together with the scroll view's predicted
104+
[resting content offset](https://developer.apple.com/documentation/uikit/uiscrollviewdelegate/scrollviewwillenddragging%28_%3Awithvelocity%3Atargetcontentoffset%3A%29),
105+
then applies the scroll view's configured
106+
[post-lift deceleration rate](https://developer.apple.com/documentation/uikit/uiscrollview/decelerationrate-swift.property).
107+
The distribution of movement over time is therefore observable gesture input, not an adapter
108+
implementation detail.
109+
110+
The three schedules shape that velocity differently. `endpoint-hold` moves quickly for 100 ms and
111+
becomes stationary before lift. `timed-pan` and target-authored drag submit the authored samples
112+
unchanged, preserving piecewise-linear movement plus explicit source and destination holds. The
113+
runner's coordinate `drag` and fused `scroll` compatibility path instead expands the movement to
114+
roughly 16 ms samples using smoothstep `s(t) = 3t² - 2t³`. A linear segment has constant movement
115+
velocity through its endpoint unless a destination hold follows it; smoothstep has zero slope at
116+
both endpoints and a peak velocity 1.5 times its average. Identical endpoints and total durations
117+
can consequently produce different recognizer and deceleration outcomes.
118+
119+
Live iOS characterization in [issue #1586](https://github.com/callstack/agent-device/issues/1586)
120+
confirmed that distinction: the schedules crossed the same fling-recognizer thresholds in the
121+
tested range but produced materially different post-release ScrollView positions and
122+
long-duration recognition behavior. The distinction is intentional policy at the Apple adapter
123+
boundary, not a second interpretation of a `GesturePlan`; changes require live evidence for both
124+
recognizer activation and post-release content movement.
99125
- WebDriver lowers a supported plan to synchronized W3C pointer action sources. A one-contact
100126
endpoint plan becomes pointer down, one timed W3C `pointerMove` from start to end, and pointer up;
101127
the driver owns interpolation across that W3C tick. Multi-touch remains capability-gated until a

0 commit comments

Comments
 (0)