Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ jobs:

- name: Validate Package
run: swift build --sdk "`xcrun --sdk iphonesimulator --show-sdk-path`" --triple arm64-apple-ios13.0-simulator
build_macos15:
runs-on: macos-15
build_and_test:
runs-on: ${{ matrix.run-config['macos_image'] }}
strategy:
matrix:
run-config:
- { xcode_version: '16.4', simulator: 'name=iPad (10th generation),OS=18.5,arch=arm64', run_extra_validations: 'false' }
- { xcode_version: '16.4', simulator: 'name=iPhone 16,OS=18.5,arch=arm64', run_extra_validations: 'true' }
- { macos_image: 'macos-15', xcode_version: '16.4', simulator: 'name=iPad (10th generation),OS=18.5,arch=arm64', run_extra_validations: 'false' }
- { macos_image: 'macos-15', xcode_version: '16.4', simulator: 'name=iPhone 16,OS=18.5,arch=arm64', run_extra_validations: 'true' }
- { macos_image: 'macos-26', xcode_version: '26.6', simulator: 'name=iPhone 17 Pro,OS=26.5,arch=arm64', run_extra_validations: 'true' }
steps:
- name: Checkout Project
uses: actions/checkout@v1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,20 @@ - (void)enterValue2:(NSString *)value

- (void)setOperation:(NSString *)operation
{
[[viewTester usingLabel:operation] tap];
// A segmented control labels each of its segments with the title it displays, so the segment
// for an operation is found by its operator. Tests read better when they name the operation
// instead, which makes the robot the place where those two vocabularies meet.
NSDictionary<NSString *, NSString *> *operatorsByOperation = @{
@"Add": @"+",
@"Subtract": @"–",
@"Multiply": @"×",
@"Divide": @"÷",
};

NSString *operatorTitle = operatorsByOperation[operation];
NSAssert(operatorTitle != nil, @"Unknown operation (%@)", operation);

[[viewTester usingLabel:operatorTitle] tap];
}

- (void)waitForResult:(NSString *)result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,6 @@ - (id)init
return self;
}

- (void)viewDidLoad
{
for (int i = 0; i < self.operationInput.subviews.count; i++) {
UIView *segment = (UIView *)self.operationInput.subviews[i];
UIView *label = segment.subviews[0];
NSString *segmentText = [(id)label text];

if ([segmentText isEqualToString:@"+"]) {
segment.accessibilityLabel = @"Add";
} else if ([segmentText isEqualToString:@"–"]) {
segment.accessibilityLabel = @"Subtract";
} else if ([segmentText isEqualToString:@"×"]) {
segment.accessibilityLabel = @"Multiply";
} else if ([segmentText isEqualToString:@"÷"]) {
segment.accessibilityLabel = @"Divide";
} else {
@throw([NSException exceptionWithName:NSInvalidArgumentException reason:[NSString stringWithFormat:@"Invalid segment text value (%@)", segmentText] userInfo:nil]);
}
}
}

- (IBAction)recalculate
{
double value1 = self.input1.text.doubleValue;
Expand Down
48 changes: 47 additions & 1 deletion Sources/KIF/Additions/UITableView-KIFAdditions.m
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ - (BOOL)dragCell:(UITableViewCell *)cell toIndexPath:(NSIndexPath *)indexPath er
}
}
CGPoint destinationPoint = CGPointMake(sourcePoint.x, destinationDragY);


// Keep the whole gesture inside the visible area so the table does not auto-scroll under it.
[self scrollToKeepReorderDragBetweenY:sourcePoint.y andY:destinationDragY margin:destinationCellRect.size.height];

// Create the touch (there should only be one touch object for the whole drag)
UITouch *touch = [[UITouch alloc] initAtPoint:sourcePoint inView:self];
[touch setPhaseAndUpdateTimestamp:UITouchPhaseBegan];
Expand Down Expand Up @@ -95,4 +98,47 @@ - (BOOL)dragCell:(UITableViewCell *)cell toIndexPath:(NSIndexPath *)indexPath er
return YES;
}

/*!
@abstract Scrolls so that both ends of a reorder drag sit at least @c margin inside the
table's visible, inset adjusted viewport.
@discussion While a row is being reordered the table auto-scrolls whenever the touch is near
the edge of that viewport. The rows then move underneath the finger, so the cell is dropped
on a different index than the one the drag aimed at. How close to the edge is "near" varies
by iOS version, and a bar overlapping the bottom of the table is enough to pull the
destination into the zone. Scrolling both ends of the drag clear of the edges up front keeps
the table still for the whole gesture and makes the drop index depend only on where the
touch goes. If the two ends are too far apart to be on screen together then the drag has to
rely on auto-scroll as before, so the content offset is left alone.
*/
- (void)scrollToKeepReorderDragBetweenY:(CGFloat)startY andY:(CGFloat)endY margin:(CGFloat)margin;
{
UIEdgeInsets insets = self.adjustedContentInset;
CGFloat viewportHeight = self.bounds.size.height - insets.top - insets.bottom;
CGFloat lowestY = MIN(startY, endY) - margin;
CGFloat highestY = MAX(startY, endY) + margin;

if (viewportHeight <= 0 || highestY - lowestY > viewportHeight) {
return;
}

CGFloat viewportTop = self.contentOffset.y + insets.top;
CGFloat offsetY;
if (highestY > viewportTop + viewportHeight) {
offsetY = highestY - viewportHeight - insets.top;
} else if (lowestY < viewportTop) {
offsetY = lowestY - insets.top;
} else {
return;
}

CGFloat minimumOffsetY = -insets.top;
CGFloat maximumOffsetY = MAX(minimumOffsetY, self.contentSize.height + insets.bottom - self.bounds.size.height);
offsetY = MIN(MAX(offsetY, minimumOffsetY), maximumOffsetY);

[self setContentOffset:CGPointMake(self.contentOffset.x, offsetY) animated:NO];

// Let the table lay out the rows that just scrolled into view before the drag starts.
CFRunLoopRunInMode(UIApplicationCurrentRunMode, 0.1, false);
}

@end
66 changes: 65 additions & 1 deletion Sources/KIF/Classes/KIFUITestActor.m
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#import "UIAutomationHelper.h"
#import "UIScreen+KIFAdditions.h"
#import "UITableView-KIFAdditions.h"
#import "UITouch-KIFAdditions.h"
#import "UIView-KIFAdditions.h"
#import "UIWindow-KIFAdditions.h"
#import "UIDatePicker+KIFAdditions.h"
Expand Down Expand Up @@ -1122,7 +1123,70 @@ - (void)setValue:(float)value forSlider:(UISlider *)slider
finalPosition.x = slider.bounds.size.width;
}

[slider dragFromPoint:currentPosition toPoint:finalPosition steps:10];
[self dragThumbOfSlider:slider fromPoint:currentPosition toPoint:finalPosition toValue:value];
}

/*!
@abstract Drags a slider's thumb from @c startPoint to @c endPoint, closing any remaining
distance against the slider's own value before letting go.
@discussion @c thumbRectForBounds:trackRect:value: says where the thumb is drawn, but the
thumb does not necessarily end up underneath the finger. Since iOS 26 the slider only starts
following the touch after the first few points of movement, so a drag sized purely from the
reported geometry stops short of the requested value. Reading the slider back while the touch
is still down closes the rest of the distance without depending on how much slack a given
iOS version takes. The touch then holds still before lifting, because releasing mid-movement
lets the slider carry on past the target under its own momentum.
*/
- (void)dragThumbOfSlider:(UISlider *)slider fromPoint:(CGPoint)startPoint toPoint:(CGPoint)endPoint toValue:(float)targetValue
{
const NSTimeInterval touchDelay = 0.01;
const NSUInteger stepsPerMove = 10;
const NSUInteger maximumCorrections = 10;
const CGFloat toleranceInPoints = 0.5;

CGRect trackRect = [slider trackRectForBounds:slider.bounds];
CGFloat lowestThumbX = CGPointCenteredInRect([slider thumbRectForBounds:slider.bounds trackRect:trackRect value:slider.minimumValue]).x;
CGFloat highestThumbX = CGPointCenteredInRect([slider thumbRectForBounds:slider.bounds trackRect:trackRect value:slider.maximumValue]).x;
CGFloat valueRange = slider.maximumValue - slider.minimumValue;
CGFloat pointsPerUnit = valueRange > 0 ? (highestThumbX - lowestThumbX) / valueRange : 0;

UITouch *touch = [[UITouch alloc] initAtPoint:startPoint inView:slider];
[touch setPhaseAndUpdateTimestamp:UITouchPhaseBegan];
[[UIApplication sharedApplication] kif_sendEvent:[slider eventWithTouch:touch]];
CFRunLoopRunInMode(UIApplicationCurrentRunMode, touchDelay, false);

__block CGPoint location = startPoint;
void (^moveByX)(CGFloat) = ^(CGFloat distance) {
for (NSUInteger step = 0; step < stepsPerMove; step++) {
location.x += distance / stepsPerMove;
[touch setLocationInWindow:[slider.window convertPoint:location fromView:slider]];
[touch setPhaseAndUpdateTimestamp:UITouchPhaseMoved];
[[UIApplication sharedApplication] kif_sendEvent:[slider eventWithTouch:touch]];
CFRunLoopRunInMode(UIApplicationCurrentRunMode, touchDelay, false);
}
};

moveByX(endPoint.x - startPoint.x);

for (NSUInteger correction = 0; pointsPerUnit > 0 && correction < maximumCorrections; correction++) {
CGFloat remainingDistance = (targetValue - slider.value) * pointsPerUnit;
if (fabs(remainingDistance) < toleranceInPoints) {
break;
}
moveByX(remainingDistance);
}

// Hold still so that lifting the touch does not hand the slider any momentum.
for (NSUInteger step = 0; step < stepsPerMove; step++) {
[touch setLocationInWindow:[slider.window convertPoint:location fromView:slider]];
[touch setPhaseAndUpdateTimestamp:UITouchPhaseMoved];
[[UIApplication sharedApplication] kif_sendEvent:[slider eventWithTouch:touch]];
CFRunLoopRunInMode(UIApplicationCurrentRunMode, touchDelay, false);
}

[touch setPhaseAndUpdateTimestamp:UITouchPhaseEnded];
[[UIApplication sharedApplication] kif_sendEvent:[slider eventWithTouch:touch]];
CFRunLoopRunInMode(UIApplicationCurrentRunMode, touchDelay, false);
}

- (void)dismissPopover
Expand Down
7 changes: 6 additions & 1 deletion TestHost/Base.lproj/MainStoryboard.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,11 @@
<outlet property="delegate" destination="gLb-0u-HV7" id="au3-RC-Q05"/>
</connections>
</tableView>
<toolbarItems/>
<toolbarItems>
<barButtonItem title="Toolbar Item" id="Tbi-0d-Itm">
<accessibility key="accessibilityConfiguration" label="Toolbar Item"/>
</barButtonItem>
</toolbarItems>
<navigationItem key="navigationItem" title="TableViews" id="dc2-IR-8qH"/>
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>
<connections>
Expand Down Expand Up @@ -1806,6 +1810,7 @@ Line Break
</navigationItem>
<connections>
<outlet property="greetingTextField" destination="4Fd-CG-0ud" id="Pav-dh-PQb"/>
<outlet property="happySwitch" destination="BGR-Qm-fMh" id="Hsw-1t-Fnt"/>
<outlet property="lineBreakLabel" destination="B0B-JK-Dre" id="eOC-HO-sni"/>
<outlet property="memoryWarningLabel" destination="cOR-b3-gcc" id="xXg-Fh-Lpo"/>
<outlet property="otherTextField" destination="WsR-z2-y1y" id="rq9-9M-Czf"/>
Expand Down
23 changes: 19 additions & 4 deletions TestHost/Sources/GestureViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,30 @@ - (void)viewDidLoad
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
self.navigationController.interactivePopGestureRecognizer.enabled = NO;

[self setNavigationPopGesturesEnabled:NO];
}

- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];

self.navigationController.interactivePopGestureRecognizer.enabled = YES;

[self setNavigationPopGesturesEnabled:YES];
}

// This screen tests its own swipe recognizers, so the navigation controller must not claim
// those swipes for itself. iOS 26 added a second pop gesture that triggers anywhere in the
// content rather than just at the screen edge, which otherwise swallows every swipe to the
// right and pops back to the test suite list.
- (void)setNavigationPopGesturesEnabled:(BOOL)enabled
{
self.navigationController.interactivePopGestureRecognizer.enabled = enabled;

#if __IPHONE_26_0
if (@available(iOS 26.0, *)) {
self.navigationController.interactiveContentPopGestureRecognizer.enabled = enabled;
}
#endif
}

- (IBAction)swipedUp:(id)sender
Expand Down
7 changes: 7 additions & 0 deletions TestHost/Sources/TapViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ @interface TapViewController : UIViewController<UITextFieldDelegate, UIImagePick
@property (weak, nonatomic) IBOutlet UITextField *greetingTextField;
@property (weak, nonatomic) IBOutlet UIStepper *stepper;
@property (weak, nonatomic) IBOutlet UILabel *stepperValueLabel;
@property (weak, nonatomic) IBOutlet UISwitch *happySwitch;
@end

@implementation TapViewController
Expand All @@ -29,6 +30,12 @@ - (void)viewDidLoad
self.lineBreakLabel.accessibilityLabel = @"A\nB\nC\n\n";
self.stepper.isAccessibilityElement = YES;
self.stepper.accessibilityLabel = @"theStepper";

// This screen is laid out with autoresizing masks, so its subviews shift by different
// amounts as the navigation bar height changes between iOS versions. On iOS 26 the taller
// bar moves the inner scroll view up on top of the switch, which stops the switch being
// hittable. Keeping the switch in front leaves it tappable whatever the bar height is.
[self.view bringSubviewToFront:self.happySwitch];
}

- (void)memoryWarningNotification:(NSNotification *)notification
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ - (void)testTappingRowsByLabel
- (void)testTappingRowUnderToolbarByLabel
{
// Ensure the toolbar is visible
[[viewTester usingIdentifier:@"Toolbar"] waitForView];
[[viewTester usingLabel:@"Toolbar Item"] waitForView];

// Tap row 31, which will scroll so that cell 32 is precisely positioned under the toolbar
[[viewTester usingLabel:@"Cell 31"] tap];
Expand Down
12 changes: 8 additions & 4 deletions Tests/KIFTests/TableViewTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,10 @@ - (void)testTappingRowsByLabel

- (void)testTappingRowUnderToolbarByLabel
{
// Ensure the toolbar is visible
[tester waitForViewWithAccessibilityIdentifier:@"Toolbar"];
// Ensure the toolbar is visible. Looking for one of its items rather than the bar
// itself: iOS 26 hosts toolbar items in a floating bar instead of the navigation
// controller's UIToolbar, which is not in the view hierarchy there at all.
[tester waitForViewWithAccessibilityLabel:@"Toolbar Item"];

// Tap row 31, which will scroll so that cell 32 is precisely positioned under the toolbar
[tester tapViewWithAccessibilityLabel:@"Cell 31"];
Expand All @@ -150,8 +152,10 @@ - (void)testTappingRowUnderToolbarByLabelWithoutAnimation
{
[[tester class] setTestActorAnimationsEnabled:NO];

// Ensure the toolbar is visible
[tester waitForViewWithAccessibilityIdentifier:@"Toolbar"];
// Ensure the toolbar is visible. Looking for one of its items rather than the bar
// itself: iOS 26 hosts toolbar items in a floating bar instead of the navigation
// controller's UIToolbar, which is not in the view hierarchy there at all.
[tester waitForViewWithAccessibilityLabel:@"Toolbar Item"];

// Tap row 31, which will scroll so that cell 32 is precisely positioned under the toolbar
[tester tapViewWithAccessibilityLabel:@"Cell 31"];
Expand Down
Loading