From 621fde999bac981c8526887e77f25c968013d290 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Wed, 9 Sep 2026 14:29:18 +0200 Subject: [PATCH] fix: use wdFrame instead of raw frame for scroll gesture anchor anchorElement.frame can be pre-scaled for compatibility-mode window mismatches or dimension-swapped in landscape, neither of which scrollingFrame (snapshot-derived) reflects - causing scroll gestures to land outside the scroll view. wdFrame matches scrollingFrame's coordinate space instead. Follow-up to #1249 per review discussion on that PR (appium/appium#16185). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01U6fXkU2kt87W6cmWPyoBgF --- .../Categories/XCUIElement+FBScrolling.m | 18 +++------ WebDriverAgentLib/Utilities/FBMathUtils.h | 25 ++++++++++++ WebDriverAgentLib/Utilities/FBMathUtils.m | 21 ++++++++++ .../UnitTests/FBMathUtilsTests.m | 38 +++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m b/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m index 6760e4fa1c..85a2386758 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m @@ -374,26 +374,18 @@ - (BOOL)fb_scrollAncestorScrollViewByVectorWithinScrollViewFrame:(CGVector)vecto error:(NSError **)error { CGRect scrollingFrame = self.scrollingFrame; - CGRect anchorFrame = anchorElement.frame; + // wdFrame matches scrollingFrame's coordinate space; raw .frame can be pre-scaled or + // dimension-swapped and drift out of sync with it (appium/appium#16185). + CGRect anchorFrame = anchorElement.wdFrame; if (CGRectIsEmpty(scrollingFrame) || CGRectIsEmpty(anchorFrame)) { return [[[FBErrorBuilder builder] withDescriptionFormat:@"Cannot compute a scroll gesture for '%@': its frame is empty", self.fb_description] buildError:error]; } - // Compute the touch-down/up points within the (possibly clipped) scrolling frame as - // before, then express them as fractions of the anchor element's own frame instead of - // raw points, which XCTest never rescales for compatibility-mode windows - // (appium/appium#16185). When scrollingFrame == anchorFrame this resolves to the exact - // same absolute point as before; it only differs once XCTest itself rescales anchorFrame. CGVector proportion = [self fb_normalizedHitPointOffsetForScrollingVector:vector]; - CGPoint startPoint = CGPointMake((CGFloat)floor(scrollingFrame.origin.x + scrollingFrame.size.width * proportion.dx), - (CGFloat)floor(scrollingFrame.origin.y + scrollingFrame.size.height * proportion.dy)); - CGPoint endPoint = CGPointMake((CGFloat)floor(startPoint.x + vector.dx), (CGFloat)floor(startPoint.y + vector.dy)); - CGVector startOffset = CGVectorMake((startPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, - (startPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); - CGVector endOffset = CGVectorMake((endPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, - (endPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); + CGVector startOffset, endOffset; + FBScrollGestureOffsets(scrollingFrame, anchorFrame, proportion, vector, &startOffset, &endOffset); XCUICoordinate *startCoordinate = [anchorElement coordinateWithNormalizedOffset:startOffset]; XCUICoordinate *endCoordinate = [anchorElement coordinateWithNormalizedOffset:endOffset]; diff --git a/WebDriverAgentLib/Utilities/FBMathUtils.h b/WebDriverAgentLib/Utilities/FBMathUtils.h index d0e1939efc..ac3a3423d2 100644 --- a/WebDriverAgentLib/Utilities/FBMathUtils.h +++ b/WebDriverAgentLib/Utilities/FBMathUtils.h @@ -61,4 +61,29 @@ XCUICoordinate * _Nullable FBCoordinateWithAnchorOffset(XCUIElement *element, NSError **error); #endif +/*! + Computes the normalized (0.0-1.0) start/end offsets of a scroll drag gesture whose + touch-down/up points fall within scrollingFrame, expressed relative to anchorFrame - + the coordinate space the resulting offsets get resolved against (e.g. via + -[XCUIElement coordinateWithNormalizedOffset:]). scrollingFrame and anchorFrame are + usually the same rect, but scrollingFrame may be clipped to a visible sub-region, and/or + the two may come from frame sources XCTest doesn't keep in sync (see appium/appium#16185) + - passing mismatched frames here reproduces that bug rather than fixing it. + + @param scrollingFrame the (possibly clipped) frame to compute the touch-down/up points within + @param anchorFrame the frame startOffset/endOffset get normalized against + @param proportion normalized touch-down position within scrollingFrame, e.g. from + -fb_normalizedHitPointOffsetForScrollingVector: + @param vector the scroll vector, in scrollingFrame's coordinate space + @param startOffset populated with the normalized start offset; untouched if NO is returned + @param endOffset populated with the normalized end offset; untouched if NO is returned + @return NO if either frame is empty + */ +BOOL FBScrollGestureOffsets(CGRect scrollingFrame, + CGRect anchorFrame, + CGVector proportion, + CGVector vector, + CGVector *startOffset, + CGVector *endOffset); + NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBMathUtils.m b/WebDriverAgentLib/Utilities/FBMathUtils.m index dacdc54aaf..5ae4bb269c 100644 --- a/WebDriverAgentLib/Utilities/FBMathUtils.m +++ b/WebDriverAgentLib/Utilities/FBMathUtils.m @@ -86,3 +86,24 @@ This verification is just to make sure the bug is still there (since height is n return [element coordinateWithNormalizedOffset:normalizedOffset]; } #endif + +BOOL FBScrollGestureOffsets(CGRect scrollingFrame, + CGRect anchorFrame, + CGVector proportion, + CGVector vector, + CGVector *startOffset, + CGVector *endOffset) +{ + if (CGRectIsEmpty(scrollingFrame) || CGRectIsEmpty(anchorFrame)) { + return NO; + } + + CGPoint startPoint = CGPointMake((CGFloat)floor(scrollingFrame.origin.x + scrollingFrame.size.width * proportion.dx), + (CGFloat)floor(scrollingFrame.origin.y + scrollingFrame.size.height * proportion.dy)); + CGPoint endPoint = CGPointMake((CGFloat)floor(startPoint.x + vector.dx), (CGFloat)floor(startPoint.y + vector.dy)); + *startOffset = CGVectorMake((startPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, + (startPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); + *endOffset = CGVectorMake((endPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, + (endPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); + return YES; +} diff --git a/WebDriverAgentTests/UnitTests/FBMathUtilsTests.m b/WebDriverAgentTests/UnitTests/FBMathUtilsTests.m index 8ca9b24dba..1e937e3ce0 100644 --- a/WebDriverAgentTests/UnitTests/FBMathUtilsTests.m +++ b/WebDriverAgentTests/UnitTests/FBMathUtilsTests.m @@ -110,4 +110,42 @@ - (void)testSizeInversion XCTAssertTrue(FBSizeFuzzyEqualToSize(screenSizeLandscape, FBAdjustDimensionsForApplication(screenSizeLandscape, UIInterfaceOrientationLandscapeRight), t)); } +- (void)testScrollGestureOffsetsWithMatchingFrames +{ + CGRect frame = CGRectMake(20, 200, 300, 400); + CGVector proportion = CGVectorMake(0.5, 0.75); + CGVector vector = CGVectorMake(0, -200); + CGVector startOffset, endOffset; + XCTAssertTrue(FBScrollGestureOffsets(frame, frame, proportion, vector, &startOffset, &endOffset)); + XCTAssertTrue(FBVectorFuzzyEqualToVector(startOffset, CGVectorMake(0.5, 0.75), 0.01)); + XCTAssertTrue(FBVectorFuzzyEqualToVector(endOffset, CGVectorMake(0.5, 0.25), 0.01)); +} + +- (void)testScrollGestureOffsetsWithRescaledAnchorFrame +{ + // Simulates a compatibility-mode window: anchorFrame is scrollingFrame scaled by ~2.19x, + // same origin - offsets should still land within [0, 1] instead of drifting outside it. + CGRect scrollingFrame = CGRectMake(20, 202, 335, 420); + CGRect anchorFrame = CGRectMake(20, 202, 733, 920); + CGVector proportion = CGVectorMake(0.5, 0.75); + CGVector vector = CGVectorMake(0, -250); + CGVector startOffset, endOffset; + XCTAssertTrue(FBScrollGestureOffsets(scrollingFrame, anchorFrame, proportion, vector, &startOffset, &endOffset)); + XCTAssertTrue(startOffset.dx >= 0 && startOffset.dx <= 1); + XCTAssertTrue(startOffset.dy >= 0 && startOffset.dy <= 1); + XCTAssertTrue(endOffset.dx >= 0 && endOffset.dx <= 1); + XCTAssertTrue(endOffset.dy >= 0 && endOffset.dy <= 1); +} + +- (void)testScrollGestureOffsetsWithEmptyFrame +{ + CGVector startOffset = CGVectorMake(-1, -1); + CGVector endOffset = CGVectorMake(-1, -1); + XCTAssertFalse(FBScrollGestureOffsets(CGRectZero, CGRectMake(0, 0, 100, 100), CGVectorMake(0.5, 0.5), CGVectorMake(0, -50), &startOffset, &endOffset)); + XCTAssertFalse(FBScrollGestureOffsets(CGRectMake(0, 0, 100, 100), CGRectZero, CGVectorMake(0.5, 0.5), CGVectorMake(0, -50), &startOffset, &endOffset)); + // Untouched on failure + XCTAssertTrue(startOffset.dx == -1 && startOffset.dy == -1); + XCTAssertTrue(endOffset.dx == -1 && endOffset.dy == -1); +} + @end