Skip to content

Commit 93a6998

Browse files
ysamlanthymikee
andauthored
fix: rotate synthesized iOS taps into native screen space (#804)
* fix: rotate synthesized iOS taps into native screen space * fix: rotate synthesized iOS transform gestures --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 71db11b commit 93a6998

5 files changed

Lines changed: 136 additions & 14 deletions

File tree

examples/test-app/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "Agent Device Tester",
44
"slug": "agent-device-test-app",
55
"version": "1.0.0",
6-
"orientation": "portrait",
6+
"orientation": "default",
77
"userInterfaceStyle": "automatic",
88
"newArchEnabled": true,
99
"plugins": ["expo-router"],

examples/test-app/src/screens/GestureLab.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,13 @@ export function GestureLab() {
137137
const panChanged = Math.abs(transform.offsetX) > 0 || Math.abs(transform.offsetY) > 0;
138138
const pinchChanged = Math.abs(transform.scale - 1) > 0.01;
139139
const rotateChanged = rotationDegrees !== 0;
140+
const changeStatusLabel = `pan changed ${panChanged ? 'yes' : 'no'}, pinch changed ${
141+
pinchChanged ? 'yes' : 'no'
142+
}, rotate changed ${rotateChanged ? 'yes' : 'no'}`;
140143

141144
return (
142145
<SectionCard
143-
subtitle="Image target for pan, pinch, rotate, and fling."
146+
subtitle={`Image target for pan, pinch, rotate, and fling. ${changeStatusLabel}`}
144147
title="Gesture lab"
145148
testID="gesture-lab-card"
146149
>
@@ -228,8 +231,7 @@ export function GestureLab() {
228231
fling {counts.fling}
229232
</Text>
230233
<Text style={styles.metric} testID="gesture-change-status">
231-
pan changed {panChanged ? 'yes' : 'no'}, pinch changed{' '}
232-
{pinchChanged ? 'yes' : 'no'}, rotate changed {rotateChanged ? 'yes' : 'no'}
234+
{changeStatusLabel}
233235
</Text>
234236
</View>
235237
</SectionCard>

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ NS_ASSUME_NONNULL_BEGIN
2525
x:(double)x
2626
y:(double)y;
2727

28+
// UIInterfaceOrientation of the app (1 portrait, 2 upsideDown, 3 landscapeRight,
29+
// 4 landscapeLeft), or 0 if unreadable.
30+
+ (NSInteger)interfaceOrientationForApplication:(id)application;
31+
2832
@end
2933

3034
NS_ASSUME_NONNULL_END

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ + (NSString * _Nullable)synthesizeTapWithApplication:(id)application
131131
}
132132
}
133133

134+
+ (NSInteger)interfaceOrientationForApplication:(id)application {
135+
SEL selector = NSSelectorFromString(@"interfaceOrientation");
136+
if (![application respondsToSelector:selector]) {
137+
return 0; // UIInterfaceOrientationUnknown
138+
}
139+
return ((RunnerMsgSendInteger)objc_msgSend)(application, selector);
140+
}
141+
134142
+ (NSString * _Nullable)trySynthesizeTransformWithApplication:(id)application
135143
x:(double)x
136144
y:(double)y

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import XCTest
22

3+
private enum RunnerInterfaceOrientation {
4+
static let unknown = 0
5+
static let portrait = 1
6+
static let portraitUpsideDown = 2
7+
static let landscapeRight = 3
8+
static let landscapeLeft = 4
9+
}
10+
311
extension RunnerTests {
412
struct TouchVisualizationFrame {
513
let x: Double
@@ -634,6 +642,50 @@ extension RunnerTests {
634642
return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
635643
}
636644

645+
/// Rotates an interface-oriented point into the device-native (portrait) space the
646+
/// synthesized event path consumes — synthesized events skip XCTest's orientation
647+
/// handling, so without this a landscape tap lands in the wrong place.
648+
func nativeSynthesizedPoint(
649+
orientedX x: Double,
650+
orientedY y: Double,
651+
in frame: CGRect,
652+
interfaceOrientation: Int
653+
) -> CGPoint {
654+
let localX = x - Double(frame.minX)
655+
let localY = y - Double(frame.minY)
656+
let width = Double(frame.width)
657+
let height = Double(frame.height)
658+
switch interfaceOrientation {
659+
case RunnerInterfaceOrientation.landscapeRight:
660+
return CGPoint(x: height - localY, y: localX)
661+
case RunnerInterfaceOrientation.landscapeLeft:
662+
return CGPoint(x: localY, y: width - localX)
663+
case RunnerInterfaceOrientation.portraitUpsideDown:
664+
return CGPoint(x: width - localX, y: height - localY)
665+
default: // portrait or unknown
666+
return CGPoint(x: localX, y: localY)
667+
}
668+
}
669+
670+
/// Rotates an interface-oriented translation vector into the same native
671+
/// coordinate space as `nativeSynthesizedPoint`.
672+
func nativeSynthesizedVector(
673+
orientedDx dx: Double,
674+
orientedDy dy: Double,
675+
interfaceOrientation: Int
676+
) -> CGVector {
677+
switch interfaceOrientation {
678+
case RunnerInterfaceOrientation.landscapeRight:
679+
return CGVector(dx: -dy, dy: dx)
680+
case RunnerInterfaceOrientation.landscapeLeft:
681+
return CGVector(dx: dy, dy: -dx)
682+
case RunnerInterfaceOrientation.portraitUpsideDown:
683+
return CGVector(dx: -dx, dy: -dy)
684+
default: // portrait or unknown
685+
return CGVector(dx: dx, dy: dy)
686+
}
687+
}
688+
637689
func synthesizedDragAt(
638690
app: XCUIApplication,
639691
x: Double,
@@ -643,12 +695,16 @@ extension RunnerTests {
643695
durationMs: Double
644696
) -> RunnerInteractionOutcome {
645697
#if os(iOS)
698+
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
699+
let frame = app.frame
700+
let start = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: frame, interfaceOrientation: orientation)
701+
let end = nativeSynthesizedPoint(orientedX: x2, orientedY: y2, in: frame, interfaceOrientation: orientation)
646702
if let message = RunnerSynthesizedGesture.synthesizeSwipe(
647703
withApplication: app,
648-
x: x,
649-
y: y,
650-
x2: x2,
651-
y2: y2,
704+
x: Double(start.x),
705+
y: Double(start.y),
706+
x2: Double(end.x),
707+
y2: Double(end.y),
652708
durationMs: durationMs
653709
) {
654710
return .unsupported(
@@ -672,10 +728,12 @@ extension RunnerTests {
672728

673729
func synthesizedTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
674730
#if os(iOS)
731+
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
732+
let point = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: app.frame, interfaceOrientation: orientation)
675733
if let message = RunnerSynthesizedGesture.synthesizeTap(
676734
withApplication: app,
677-
x: x,
678-
y: y
735+
x: Double(point.x),
736+
y: Double(point.y)
679737
) {
680738
return .unsupported(
681739
message: message,
@@ -952,12 +1010,15 @@ extension RunnerTests {
9521010
) -> RunnerInteractionOutcome {
9531011
#if os(iOS)
9541012
let target = interactionRoot(app: app)
1013+
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
1014+
let point = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: app.frame, interfaceOrientation: orientation)
1015+
let vector = nativeSynthesizedVector(orientedDx: dx, orientedDy: dy, interfaceOrientation: orientation)
9551016
if let message = RunnerSynthesizedGesture.synthesizeTransform(
9561017
withApplication: app,
957-
x: x,
958-
y: y,
959-
dx: dx,
960-
dy: dy,
1018+
x: Double(point.x),
1019+
y: Double(point.y),
1020+
dx: Double(vector.dx),
1021+
dy: Double(vector.dy),
9611022
scale: scale,
9621023
degrees: degrees,
9631024
radius: transformGestureRadius(frame: target.frame, scale: scale),
@@ -1091,4 +1152,51 @@ extension RunnerTests {
10911152
let element = app.descendants(matching: .any).matching(predicate).firstMatch
10921153
return element.exists ? element : nil
10931154
}
1155+
1156+
// Identity in portrait/unknown, 90° per landscape, 180° upside-down.
1157+
func testNativeSynthesizedPointRotatesByInterfaceOrientation() {
1158+
let portrait = CGRect(x: 0, y: 0, width: 834, height: 1210)
1159+
let landscape = CGRect(x: 0, y: 0, width: 1210, height: 834)
1160+
let offsetLandscape = CGRect(x: 10, y: 20, width: 1210, height: 834)
1161+
// (frame, UIInterfaceOrientation, expected native point) for a tap at (170, 268).
1162+
let cases: [(CGRect, Int, CGPoint)] = [
1163+
(portrait, RunnerInterfaceOrientation.portrait, CGPoint(x: 170, y: 268)),
1164+
(landscape, RunnerInterfaceOrientation.landscapeRight, CGPoint(x: 566, y: 170)),
1165+
(landscape, RunnerInterfaceOrientation.landscapeLeft, CGPoint(x: 268, y: 1040)),
1166+
(portrait, RunnerInterfaceOrientation.portraitUpsideDown, CGPoint(x: 664, y: 942)),
1167+
(portrait, RunnerInterfaceOrientation.unknown, CGPoint(x: 170, y: 268)),
1168+
]
1169+
for (frame, orientation, expected) in cases {
1170+
XCTAssertEqual(
1171+
nativeSynthesizedPoint(orientedX: 170, orientedY: 268, in: frame, interfaceOrientation: orientation),
1172+
expected,
1173+
"interfaceOrientation \(orientation)"
1174+
)
1175+
}
1176+
XCTAssertEqual(
1177+
nativeSynthesizedPoint(
1178+
orientedX: 180,
1179+
orientedY: 288,
1180+
in: offsetLandscape,
1181+
interfaceOrientation: RunnerInterfaceOrientation.landscapeLeft
1182+
),
1183+
CGPoint(x: 268, y: 1040),
1184+
"non-zero frame origin is localized before rotation"
1185+
)
1186+
}
1187+
1188+
func testNativeSynthesizedVectorRotatesByInterfaceOrientation() {
1189+
let cases: [(Int, CGVector)] = [
1190+
(RunnerInterfaceOrientation.portrait, CGVector(dx: 40, dy: -20)),
1191+
(RunnerInterfaceOrientation.landscapeRight, CGVector(dx: 20, dy: 40)),
1192+
(RunnerInterfaceOrientation.landscapeLeft, CGVector(dx: -20, dy: -40)),
1193+
(RunnerInterfaceOrientation.portraitUpsideDown, CGVector(dx: -40, dy: 20)),
1194+
(RunnerInterfaceOrientation.unknown, CGVector(dx: 40, dy: -20)),
1195+
]
1196+
for (orientation, expected) in cases {
1197+
let vector = nativeSynthesizedVector(orientedDx: 40, orientedDy: -20, interfaceOrientation: orientation)
1198+
XCTAssertEqual(vector.dx, expected.dx, "dx interfaceOrientation \(orientation)")
1199+
XCTAssertEqual(vector.dy, expected.dy, "dy interfaceOrientation \(orientation)")
1200+
}
1201+
}
10941202
}

0 commit comments

Comments
 (0)