@@ -30,12 +30,71 @@ enum RunnerScrollKeyboardClip: Equatable {
3030
3131/** Where one directional scroll may place its swipe, once the keyboard has taken its share. */
3232enum RunnerScrollViewport {
33- /** The frame to plan inside, plus the keyboard top when the swipe was clipped for one. */
34- case swipe( frame: CGRect , keyboardMinY: Double ? )
33+ /**
34+ * The band to plan the swipe inside, the frame to rotate its coordinates against, and the keyboard
35+ * top when the band was clipped for one. The two frames are separate on purpose: a clip shortens
36+ * only the band, while `nativeSynthesizedPoint` derives a `landscapeRight` native x from the
37+ * frame's HEIGHT, so rotating inside the band moves the dispatched path sideways off the planned
38+ * one.
39+ */
40+ case swipe( planFrame: CGRect , coordinateFrame: CGRect , keyboardMinY: Double ? )
3541 /** Nothing to swipe. The caller answers `occlusionRunnerCode` and performs no gesture. */
3642 case occluded( keyboardMinY: Double , visibleHeight: Double )
3743}
3844
45+ /// The gesture one directional scroll dispatches, built from a resolved viewport in one place so the
46+ /// band the plan was made inside and the frame its coordinates rotate against cannot be swapped.
47+ struct ScrollGestureDispatch {
48+ let plan : RunnerScrollGesturePlan
49+ let planFrame : CGRect
50+ let coordinateFrame : CGRect
51+ let keyboardMinY : Double ?
52+ }
53+
54+ /** What a resolved viewport turns into for the command: a gesture, or the reason there is none. */
55+ enum ScrollGestureOutcome {
56+ case gesture( ScrollGestureDispatch )
57+ case unusableFrame
58+ case unusablePlan
59+ case occluded( keyboardMinY: Double , visibleHeight: Double )
60+ }
61+
62+ extension RunnerScrollViewport {
63+ /// Plans the swipe inside the band the keyboard left and keeps the viewport as the coordinate basis,
64+ /// so a clip shortens the travel without moving the gesture's lane.
65+ func gestureDispatch(
66+ direction: RunnerScrollDirection ,
67+ amount: Double ? ,
68+ pixels: Double ?
69+ ) -> ScrollGestureOutcome {
70+ switch self {
71+ case . occluded( let keyboardMinY, let visibleHeight) :
72+ return . occluded( keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
73+ case . swipe( let planFrame, let coordinateFrame, let keyboardMinY) :
74+ guard planFrame. width > 0 , planFrame. height > 0 else {
75+ return . unusableFrame
76+ }
77+ guard let plan = runnerScrollGesturePlan (
78+ direction: direction,
79+ amount: amount,
80+ pixels: pixels,
81+ referenceWidth: planFrame. width,
82+ referenceHeight: planFrame. height
83+ ) else {
84+ return . unusablePlan
85+ }
86+ return . gesture(
87+ ScrollGestureDispatch (
88+ plan: plan,
89+ planFrame: planFrame,
90+ coordinateFrame: coordinateFrame,
91+ keyboardMinY: keyboardMinY
92+ )
93+ )
94+ }
95+ }
96+ }
97+
3998enum ScrollViewportPolicy {
4099 /** Below this fraction of the viewport, the clipped band cannot hold a reliable swipe. */
41100 static let minVisibleFraction : Double = 0.15
@@ -82,6 +141,20 @@ enum ScrollViewportPolicy {
82141 )
83142 }
84143
144+ /// Splits a clip verdict into the two frames a dispatch needs. The gesture planner runs inside the
145+ /// clipped band; the coordinate rotation keeps the frame the viewport was resolved against, because
146+ /// the rotation basis is a property of the screen, not of what the keyboard left free.
147+ static func frames( referenceFrame: CGRect , clip: RunnerScrollKeyboardClip ) -> RunnerScrollViewport {
148+ switch clip {
149+ case . unobstructed:
150+ return . swipe( planFrame: referenceFrame, coordinateFrame: referenceFrame, keyboardMinY: nil )
151+ case . avoided( let frame, let keyboardMinY) :
152+ return . swipe( planFrame: frame, coordinateFrame: referenceFrame, keyboardMinY: keyboardMinY)
153+ case . occluded( let keyboardMinY, let visibleHeight) :
154+ return . occluded( keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
155+ }
156+ }
157+
85158 private static func isUsable( _ rect: CGRect ) -> Bool {
86159 return [ rect. minX, rect. minY, rect. width, rect. height] . allSatisfy ( \. isFinite)
87160 && rect. width > 0 && rect. height > 0
@@ -102,35 +175,35 @@ extension RunnerTests {
102175 // all: a policy that forbids the probe, and a probe that finds no keyboard.
103176 guard context. allowsKeyboardProbe else {
104177 logScrollViewport ( decision: " probeSkipped " , keyboardMinY: nil , swipeHeight: context. referenceFrame. height, context: context)
105- return . swipe ( frame : context. referenceFrame, keyboardMinY : nil )
178+ return ScrollViewportPolicy . frames ( referenceFrame : context. referenceFrame, clip : . unobstructed )
106179 }
107180 guard let keyboardFrame = visibleKeyboardFrame ( app: app) else {
108181 logScrollViewport ( decision: " noKeyboard " , keyboardMinY: nil , swipeHeight: context. referenceFrame. height, context: context)
109- return . swipe ( frame : context. referenceFrame, keyboardMinY : nil )
182+ return ScrollViewportPolicy . frames ( referenceFrame : context. referenceFrame, clip : . unobstructed )
110183 }
111- switch ScrollViewportPolicy . clip ( viewport: context. referenceFrame, keyboard: keyboardFrame) {
184+ let clip = ScrollViewportPolicy . clip ( viewport: context. referenceFrame, keyboard: keyboardFrame)
185+ switch clip {
112186 case . unobstructed:
113187 logScrollViewport ( decision: " unobstructed " , keyboardMinY: nil , swipeHeight: context. referenceFrame. height, context: context)
114- return . swipe( frame: context. referenceFrame, keyboardMinY: nil )
115188 case . avoided( let frame, let keyboardMinY) :
116189 logScrollViewport (
117190 decision: " avoided " ,
118191 keyboardMinY: keyboardMinY,
119192 swipeHeight: frame. height,
120193 context: context
121194 )
122- return . swipe( frame: frame, keyboardMinY: keyboardMinY)
123195 case . occluded( let keyboardMinY, let visibleHeight) :
124196 logScrollViewport (
125197 decision: " occluded " ,
126198 keyboardMinY: keyboardMinY,
127199 swipeHeight: visibleHeight,
128200 context: context
129201 )
130- return . occluded( keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
131202 }
203+ return ScrollViewportPolicy . frames ( referenceFrame: context. referenceFrame, clip: clip)
132204#else
133- return . swipe( frame: resolvedTouchReferenceFrame ( app: app, appFrame: app. frame) , keyboardMinY: nil )
205+ let fallbackFrame = resolvedTouchReferenceFrame ( app: app, appFrame: app. frame)
206+ return ScrollViewportPolicy . frames ( referenceFrame: fallbackFrame, clip: . unobstructed)
134207#endif
135208 }
136209
@@ -237,6 +310,55 @@ extension RunnerTests {
237310 XCTAssertEqual ( constants. accessoryAllowance, ScrollViewportPolicy . accessoryAllowance)
238311 }
239312
313+ /// A clipped landscape band shortens the frame, and `nativeSynthesizedPoint` derives a
314+ /// `landscapeRight` native x from the frame's HEIGHT. Rotating inside the band therefore moves the
315+ /// dispatched path sideways by exactly what the keyboard took, off the lane the plan was built for,
316+ /// so the plan band and the coordinate basis stay separate values through dispatch (#2500).
317+ func testScrollViewportDispatchKeepsTheUnclippedFrameAsItsCoordinateRotationBasis( ) throws {
318+ let viewport = CGRect ( x: 0 , y: 0 , width: 1210 , height: 834 )
319+ let keyboard = CGRect ( x: 0 , y: 588 , width: 1210 , height: 246 )
320+ let clip = ScrollViewportPolicy . clip ( viewport: viewport, keyboard: keyboard)
321+ guard case . avoided( let band, let keyboardMinY) = clip else {
322+ return XCTFail ( " expected a landscape keyboard to be avoided, got \( clip) " )
323+ }
324+ XCTAssertEqual ( band. height, 576 )
325+
326+ guard case . gesture( let gesture) = ScrollViewportPolicy . frames (
327+ referenceFrame: viewport,
328+ clip: clip
329+ ) . gestureDispatch ( direction: . up, amount: nil , pixels: nil ) else {
330+ return XCTFail ( " expected a gesture inside the clipped band " )
331+ }
332+ XCTAssertEqual ( gesture. planFrame, band)
333+ XCTAssertEqual ( gesture. keyboardMinY, keyboardMinY)
334+ XCTAssertEqual ( gesture. coordinateFrame, viewport, " the rotation basis must survive the clip " )
335+ XCTAssertLessThanOrEqual (
336+ max ( gesture. plan. y1, gesture. plan. y2) ,
337+ keyboard. minY - ScrollViewportPolicy. accessoryAllowance,
338+ " a landscape swipe must stay clear of the keys "
339+ )
340+
341+ let orientedStartY = gesture. planFrame. minY + gesture. plan. y1
342+ let dispatched = nativeSynthesizedPoint (
343+ orientedX: gesture. planFrame. minX + gesture. plan. x1,
344+ orientedY: orientedStartY,
345+ in: gesture. coordinateFrame,
346+ interfaceOrientation: RunnerInterfaceOrientation . landscapeRight
347+ )
348+ let clippedBasis = nativeSynthesizedPoint (
349+ orientedX: gesture. planFrame. minX + gesture. plan. x1,
350+ orientedY: orientedStartY,
351+ in: gesture. planFrame,
352+ interfaceOrientation: RunnerInterfaceOrientation . landscapeRight
353+ )
354+ XCTAssertEqual (
355+ dispatched. x - clippedBasis. x,
356+ viewport. height - band. height,
357+ accuracy: 0.001 ,
358+ " rotating inside the clipped band would shift native x by what the keyboard took "
359+ )
360+ }
361+
240362 private func loadScrollViewportPolicyFixture( ) throws -> ScrollViewportPolicyFixture {
241363 let fixtureURL = URL ( fileURLWithPath: #filePath)
242364 . deletingLastPathComponent ( ) // AgentDeviceRunnerUITests
0 commit comments