blob: 0ea3c3ee5d945043eb1181527cfd53a28d212372 [file] [log] [blame]
//
// Copyright 2018 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
#import "GTXChecksCollection.h"
#import "GTXAssertions.h"
#import "GTXCheckBlock.h"
#import "GTXChecking.h"
#import "GTXImageAndColorUtils.h"
#import "NSError+GTXAdditions.h"
#import "UIColor+GTXAdditions.h"
#pragma mark - Externs
NSString *const kGTXCheckNameAccessibilityLabelPresent = @"Accessibility label";
NSString *const kGTXCheckNameAccessibilityLabelNotPunctuated = @"Accessibility label punctuation";
NSString *const kGTXCheckNameAccessibilityLabelIsNotRedundantWithTraits =
@"Accessibility label-trait overlap";
NSString *const kGTXCheckNameAccessibilityTraitsDontConflict = @"Accessibility traits";
NSString *const kGTXCheckNameMinimumTappableArea = @"Touch target size";
NSString *const kGTXCheckNameLabelMinimumContrastRatio = @"Contrast ratio (Label)";
NSString *const kGTXCheckNameTextViewMinimumContrastRatio = @"Contrast ratio (TextView)";
NSString *const kGTXCheckNameSupportsDynamicType = @"Supports Dynamic Type";
/**
* The text style attribute for fonts initialized by the system. Fonts with this text style do not
* scale with Dynamic Type.
*/
static NSString *const kGTXDefaultFontDescriptorTextStyle = @"CTFontRegularUsage";
#pragma mark - Globals
/**
* The minimum size (width or height) for a given element to be easily accessible. Please read
* Material design guidelines for more on touch targets:
* https://material.io/design/layout/spacing-methods.html#touch-click-targets
*/
static const float kGTXMinSizeForAccessibleElements = 44.0;
/**
* The minimum contrast ratio for any given text to be considered accessible. Note that smaller
* text has even stricter requirement of 4.5:1.
*/
static const float kGTXMinContrastRatioForAccessibleText = 3.0;
#pragma mark - Exposed Interfaces
/**
* Declares private selectors so they can be used with @c respondsToSelector and have the correct
* type information.
*/
@interface UIFont (GTXExposePrivateAPIs)
/**
* @return The text style associated with this font.
*/
- (NSString *)textStyleForScaling;
@end
#pragma mark - Implementations
@implementation GTXChecksCollection
+ (NSArray<id<GTXChecking>> *)allGTXChecksForVersion:(GTXVersion)version {
NSArray<id<GTXChecking>> *originalChecks = @[
[self checkForAXLabelPresent], [self checkForAXLabelNotPunctuated],
[self checkForAXLabelNotRedundantWithTraits], [self checkForAXTraitDontConflict],
[self checkForMinimumTappableArea], [self checkForSufficientContrastRatio],
[self checkForSupportsDynamicType]
];
switch (version) {
case GTXVersionLatest:
return originalChecks;
case GTXVersionPreRelease:
return originalChecks;
case GTXVersion_0:
return originalChecks;
}
}
+ (NSArray<id<GTXChecking>> *)allGTXChecks {
return [GTXChecksCollection allGTXChecksForVersion:GTXVersionLatest];
}
+ (NSArray<id<GTXChecking>> *)allGTXChecksForVersion:(GTXVersion)version
excludingChecksWithNames:(NSSet<NSString *> *)excludedCheckNames {
NSArray<id<GTXChecking>> *originalChecks = [GTXChecksCollection allGTXChecksForVersion:version];
[GTXChecksCollection gtx_assertChecks:originalChecks containChecksForNames:excludedCheckNames];
NSMutableArray<id<GTXChecking>> *checks = [[NSMutableArray alloc] init];
for (id<GTXChecking> check in originalChecks) {
if (![excludedCheckNames containsObject:[check name]]) {
[checks addObject:check];
}
}
return checks;
}
#pragma mark - GTXChecks
+ (id<GTXChecking>)checkForAXLabelPresent {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameAccessibilityLabelPresent
requiresWindow:NO
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
if ([self gtx_isTextDisplayingElement:element]) {
// Elements that display text can use its text as an accessibility value making
// the accessibility label optional.
return YES;
}
NSError *error;
NSString *label = [self stringValueOfAccessibilityLabelForElement:element
error:&error];
if (error) {
if (errorOrNil) {
*errorOrNil = error;
}
return NO;
}
label = [self gtx_trimmedStringFromString:label];
if ([label length] > 0) {
// Check passed.
return YES;
}
[NSError gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:kGTXCheckNameAccessibilityLabelPresent
description:@"This element doesn’t have an "
@"accessibility label. All "
@"accessibility elements must have "
@"accessibility labels."];
return NO;
}];
return check;
}
+ (id<GTXChecking>)checkForAXLabelNotPunctuated {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameAccessibilityLabelNotPunctuated
requiresWindow:NO
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
if ([self gtx_isTextDisplayingElement:element]) {
// This check is not applicable to text elements as accessibility labels can
// hold static text that can be punctuated and formatted like a string.
return YES;
}
NSError *error;
NSString *stringValue = [self stringValueOfAccessibilityLabelForElement:element
error:&error];
if (error) {
if (errorOrNil) {
*errorOrNil = error;
}
return NO;
}
NSString *label = [self gtx_trimmedStringFromString:stringValue];
// This check is not applicable for container elements that combine individual
// labels joined with commas.
if ([label rangeOfString:@","].location != NSNotFound) {
return YES;
}
if ([label length] > 0 && [label hasSuffix:@"."]) {
// Check failed.
NSString *errorDescription = [NSString
stringWithFormat:
@"This element has an accessibility label that ends in a period but "
@"doesn’t have a "
@"text trait. Accessibility labels aren’t sentences and don’t need "
@"periods. If the "
@"element visually displays text it should have the "
@"UIAccessibilityTraitStaticText "
@"trait similar to UITextView or UILabel.\n\nLabel found: %@.",
label];
[NSError
gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:
kGTXCheckNameAccessibilityLabelNotPunctuated
description:errorDescription];
return NO;
}
return YES;
}];
return check;
}
// @TODO Include all UIAccessibilityTraits that announce themselves (image, search
// field, etc.), and find a more robust way to determine if the label is redundant. Currently, the
// suffix is compared to a single string, which causes false positives when that string occurs
// elsewhere in the label or synonyms are used. Additionally, the hardcoded string only works in
// English. This method also needs to be updated for i18n.
+ (id<GTXChecking>)checkForAXLabelNotRedundantWithTraits {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameAccessibilityLabelIsNotRedundantWithTraits
requiresWindow:NO
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
UIAccessibilityTraits elementAXTraits = [element accessibilityTraits];
NSString *elementAXLabel = [element accessibilityLabel];
NSDictionary<NSNumber *, NSString *> const *redundantLabelsDictionary =
[self gtx_traitsToRedundantLabelsDictionary];
NSMutableArray<NSString *> *redundantTextList = [[NSMutableArray alloc] init];
NSMutableArray<NSString *> *redundantTraitNameList =
[[NSMutableArray alloc] init];
for (NSNumber *testTrait in redundantLabelsDictionary) {
NSString *redundantText = [redundantLabelsDictionary objectForKey:testTrait];
UIAccessibilityTraits testUITrait = [testTrait unsignedLongLongValue];
if ((BOOL)(elementAXTraits & testUITrait)) {
if ([GTXChecksCollection gtx_caseInsensitive:elementAXLabel
hasSuffix:redundantText]) {
if ([element isKindOfClass:[UIButton class]] &&
[GTXChecksCollection
gtx_caseInsensitive:((UIButton *)element).titleLabel.text
hasSuffix:redundantText]) {
// This is a button whose title itself has the word "button", we must
// ignore this kind of elements.
continue;
}
NSError *error;
NSString *stringValue =
[self stringValueOfUIAccessibilityTraits:testUITrait error:&error];
if (error) {
if (errorOrNil) {
*errorOrNil = error;
}
return NO;
}
[redundantTextList addObject:redundantText];
[redundantTraitNameList addObject:stringValue];
}
}
}
if ([redundantTraitNameList count] > 0) {
NSString *stringOfRedundantTextList =
[redundantTextList componentsJoinedByString:@", "];
NSString *stringOfRedundantTraitNameList =
[redundantTraitNameList componentsJoinedByString:@", "];
NSString *errorDescription = [NSString
stringWithFormat:@"This element has an accessibility label that isn't "
@"needed because the element "
@"already includes a trait. Traits such as the "
@"UIAccessibilityTraitButton cause "
@"VoiceOver to speak \"button\" along with the label, so "
@"you don't need to include "
@"the word \"button\".\n\nLabel found: %@\nRedundant "
@"label text found: %@\nRedundant "
@"Trait(s) found: %@",
elementAXLabel, stringOfRedundantTextList,
stringOfRedundantTraitNameList];
[NSError
gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:
kGTXCheckNameAccessibilityLabelIsNotRedundantWithTraits
description:errorDescription];
return NO;
}
return YES;
}];
return check;
}
+ (id<GTXChecking>)checkForAXTraitDontConflict {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameAccessibilityTraitsDontConflict
requiresWindow:NO
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
if ([NSStringFromClass([element class])
isEqualToString:@"UIAccessibilityElementKBKey"]) {
// iOS keyboard keys are known to have conflicting traits skip them.
return YES;
}
UIAccessibilityTraits elementAXTraits = [element accessibilityTraits];
// Even though we can check for valid accessibility traits, we are not doing that
// because some undocumented UIKit controls, e.g., UINavigationItemButtonView,
// are known to have unknown values. Check b/29226386 for more details. Check
// mutually exclusive conflicts for the element's accessibility traits.
for (NSArray<NSNumber *> *traitsConflictRule in
[self gtx_traitsMutuallyExclusiveRules]) {
NSMutableArray<NSString *> *conflictTraitsNameList =
[[NSMutableArray alloc] init];
for (NSNumber *testTrait in traitsConflictRule) {
UIAccessibilityTraits testUITrait = [testTrait unsignedLongLongValue];
if ((BOOL)(elementAXTraits & testUITrait)) {
NSError *error;
NSString *stringValue =
[self stringValueOfUIAccessibilityTraits:testUITrait error:&error];
if (error) {
if (errorOrNil) {
*errorOrNil = error;
}
return NO;
}
[conflictTraitsNameList addObject:stringValue];
}
}
if ([conflictTraitsNameList count] > 1) {
NSString *stringOfConflictTraitsNameList =
[conflictTraitsNameList componentsJoinedByString:@", "];
NSString *errorDescription = [NSString
stringWithFormat:@"This element has traits that are conflicting. These "
@"traits can’t occur at the same time: %@",
stringOfConflictTraitsNameList];
[NSError
gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:
kGTXCheckNameAccessibilityTraitsDontConflict
description:errorDescription];
return NO;
}
}
return YES;
}];
return check;
}
+ (id<GTXChecking>)checkForMinimumTappableArea {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameMinimumTappableArea
requiresWindow:NO
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
if (![self gtx_isTappableNonLinkElement:element]) {
// Element is not tappable or is a link, links follow the font size of the text
// on page and are exempt from this check.
return YES;
}
NSString *errorDescriptionTemplate =
[NSString stringWithFormat:@"This element has a small touch target. All "
@"tappable elements must have a minimum "
@"touch target size of %d by %d points.",
(int)kGTXMinSizeForAccessibleElements,
(int)kGTXMinSizeForAccessibleElements];
// If an element responds to the selectors needed to dynamically check for touch
// events, that should be the source of truth for touch target size.
// Accessibility services dispatch touch events using the same APIs, so
// responding to touch events in a wide enough area satisfies touch target
// guidelines. If they do not respond to those selectors, the only way to
// estimate touch target size is via frame or accessibilityFrame. Checking
// touchInside:withEvent must occur first, or else elements with too small frames
// that respond to touches in a larger area, or vice versa, will be fail the
// check when they should pass.
if ([GTXChecksCollection gtx_canElementRespondToTouches:element]) {
if (![GTXChecksCollection
gtx_elementRespondsToTouchesInSufficientArea:element]) {
NSString *errorDescription = [errorDescriptionTemplate
stringByAppendingString:
@"%@ The element does not respond to touches in the given range "
@"using pointInside:withEvent:."];
[NSError gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:kGTXCheckNameMinimumTappableArea
description:errorDescription];
return NO;
}
return YES;
}
NSMutableArray<NSString *> *errorDescriptions = [[NSMutableArray alloc] init];
if ([element respondsToSelector:@selector(accessibilityFrame)]) {
[GTXChecksCollection
gtx_errorDescriptionForMinimumTappableArea:[element accessibilityFrame]
propertyName:@"accessibilityFrame"
addToArray:errorDescriptions];
}
// In iOS 13, UIAccessibilityElement responds to the frame selector but always
// returns CGRectZero. This causes all UIAccessibilityElement instances to fail
// touch target guidelines. Since UIAccessibilityElement instances don't really
// have a frame independent of their accessibility frame, ignoring elements with
// frames that are not UIView subclasses solves this.
if ([element respondsToSelector:@selector(frame)] &&
[element isKindOfClass:[UIView class]]) {
[GTXChecksCollection
gtx_errorDescriptionForMinimumTappableArea:[element frame]
propertyName:@"frame"
addToArray:errorDescriptions];
}
if ([errorDescriptions count] > 0) {
NSString *joiner = @"\n\n";
NSString *propertyErrorDescription =
[errorDescriptions componentsJoinedByString:joiner];
NSString *errorDescription =
[NSString stringWithFormat:@"%@%@%@", errorDescriptionTemplate, joiner,
propertyErrorDescription];
[NSError gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:kGTXCheckNameMinimumTappableArea
description:errorDescription];
return NO;
}
return YES;
}];
return check;
}
+ (id<GTXChecking>)checkForSufficientContrastRatio {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameLabelMinimumContrastRatio
requiresWindow:YES
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
if (![element isKindOfClass:[UILabel class]]) {
return YES;
} else if ([[(UILabel *)element text] length] == 0) {
return YES;
}
UIColor *textColor, *backgroundColor;
CGFloat ratio = [GTXImageAndColorUtils contrastRatioOfUILabel:element
outAvgTextColor:&textColor
outAvgBackgroundColor:&backgroundColor];
BOOL hasSufficientContrast =
(ratio >= kGTXMinContrastRatioForAccessibleText - kGTXContrastRatioAccuracy);
if (!hasSufficientContrast) {
// @TODO include actual color values found in the error
// description as well.
NSString *errorDescription =
[NSString stringWithFormat:@"This element has a low contrast ratio. All "
@"text and icons must have a "
@"minimum contrast ratio of %.5f.\n\nContrast "
@"ratio: %.5f\nText color: "
@"%@\nBackground color: %@",
(float)kGTXMinContrastRatioForAccessibleText,
(float)ratio, [textColor gtx_description],
[backgroundColor gtx_description]];
[NSError gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:kGTXCheckNameLabelMinimumContrastRatio
description:errorDescription];
}
return hasSufficientContrast;
}];
return check;
}
+ (id<GTXChecking>)checkForSufficientTextViewContrastRatio {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameTextViewMinimumContrastRatio
requiresWindow:YES
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
if (![element isKindOfClass:[UITextView class]]) {
return YES;
} else if ([[(UITextView *)element text] length] == 0) {
return YES;
}
UIColor *textColor, *backgroundColor;
CGFloat ratio =
[GTXImageAndColorUtils contrastRatioOfUITextView:element
outAvgTextColor:&textColor
outAvgBackgroundColor:&backgroundColor];
BOOL hasSufficientContrast =
(ratio >= kGTXMinContrastRatioForAccessibleText - kGTXContrastRatioAccuracy);
if (!hasSufficientContrast) {
NSString *errorDescription =
[NSString stringWithFormat:@"This element has a low contrast ratio. All "
@"text and icons must have a "
@"minimum contrast ratio of %.5f.\n\nContrast "
@"ratio: %.5f\nText color: "
@"%@\nBackground color: %@",
(float)kGTXMinContrastRatioForAccessibleText,
(float)ratio, [textColor gtx_description],
[backgroundColor gtx_description]];
[NSError
gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:kGTXCheckNameTextViewMinimumContrastRatio
description:errorDescription];
}
return hasSufficientContrast;
}];
return check;
}
+ (id<GTXChecking>)checkForSupportsDynamicType {
id<GTXChecking> check = [GTXCheckBlock
GTXCheckWithName:kGTXCheckNameSupportsDynamicType
requiresWindow:NO
block:^BOOL(id element, GTXErrorRefType errorOrNil) {
BOOL respondsToAdjustsFont =
[element respondsToSelector:@selector(adjustsFontForContentSizeCategory)];
BOOL respondsToFont = [element respondsToSelector:@selector(font)];
if (!respondsToAdjustsFont || !respondsToFont) {
// Assume this element doesn't display text. This accounts for the standard
// text displaying views: UILabel, UITextView, and UITextField. Custom elements
// that implement these methods can be checked. Custom elements that don't
// implement these methods or name them differently are ignored.
return YES;
}
if (![element adjustsFontForContentSizeCategory]) {
// TODO: Localize all the strings in this method.
NSString *errorDescription = @"This element is a text displaying element, but "
@"adjustsFontForContentSizeCategory is NO. Set "
@"adjustsFontForContentSizeCategory to YES to "
@"automatically scale with Dynamic Type.";
[NSError gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:kGTXCheckNameSupportsDynamicType
description:errorDescription];
return NO;
}
UIFont *font = (UIFont *)[element font];
BOOL textStyleForScalingExists =
[font respondsToSelector:@selector(textStyleForScaling)] &&
([font textStyleForScaling] != nil);
BOOL isTextStyleAttributeScalable =
[GTXChecksCollection gtx_isFontTextStyleAttributeScalable:font];
if (textStyleForScalingExists || isTextStyleAttributeScalable) {
return YES;
}
NSString *errorDescription =
@"This element is a text displaying element, but its font does not scale "
@"automatically with Dynamic Type. Its font must be constructed using "
@"preferredFontForTextStyle or UIFontMetrics.";
[NSError gtx_logOrSetGTXCheckFailedError:errorOrNil
element:element
name:kGTXCheckNameSupportsDynamicType
description:errorDescription];
return NO;
}];
return check;
}
#pragma mark - Private
/**
* Asserts that @c checks contains a check with a name for every name in @c names. Crashes with an
* assertion if there is an element in @c names without a corresponding check in @c checks.
*
* @param checks An array of checks.
* @param names A set of names which must be present in @c checks.
*/
+ (void)gtx_assertChecks:(NSArray<id<GTXChecking>> *)checks
containChecksForNames:(NSSet<NSString *> *)names {
NSSet<NSString *> *originalNames =
[[NSSet alloc] initWithArray:[checks valueForKey:NSStringFromSelector(@selector(name))]];
BOOL isSubset = [names isSubsetOfSet:originalNames];
GTX_ASSERT(isSubset, @"Cannot exclude nonexistent check. Excluded names: %@. Valid names: %@",
names, checks);
// Reassign to isSubset because GTX_ASSERT is stripped in production builds, causing an unused
// variable warning.
isSubset = NO;
}
/**
* @return The NSArray contains the mutually exclusive rules for accessibility traits.
* For details, check go/gtx-ios.
*/
+ (NSArray<NSArray<NSNumber *> *> *)gtx_traitsMutuallyExclusiveRules {
// Each item below consists of a mutually exclusive traits rule.
return @[
// Conflicting Rule No. 1
@[
@(UIAccessibilityTraitButton), @(UIAccessibilityTraitLink),
@(UIAccessibilityTraitSearchField), @(UIAccessibilityTraitKeyboardKey)
],
// Conflicting Rule No. 2
@[ @(UIAccessibilityTraitButton), @(UIAccessibilityTraitAdjustable) ]
];
}
/**
* @return The UIAccessibilityTraits to NSString mapping dictionary as type
* NSDictionary<NSNumber *, NSString *> *.
*/
+ (NSDictionary<NSNumber *, NSString *> const *)gtx_traitsToStringDictionary {
// Each element below is an valid accessibility traits entity.
return @{
@(UIAccessibilityTraitNone) : @"UIAccessibilityTraitNone",
@(UIAccessibilityTraitButton) : @"UIAccessibilityTraitButton",
@(UIAccessibilityTraitLink) : @"UIAccessibilityTraitLink",
@(UIAccessibilityTraitSearchField) : @"UIAccessibilityTraitSearchField",
@(UIAccessibilityTraitImage) : @"UIAccessibilityTraitImage",
@(UIAccessibilityTraitSelected) : @"UIAccessibilityTraitSelected",
@(UIAccessibilityTraitPlaysSound) : @"UIAccessibilityTraitPlaysSound",
@(UIAccessibilityTraitKeyboardKey) : @"UIAccessibilityTraitKeyboardKey",
@(UIAccessibilityTraitStaticText) : @"UIAccessibilityTraitStaticText",
@(UIAccessibilityTraitSummaryElement) : @"UIAccessibilityTraitSummaryElement",
@(UIAccessibilityTraitNotEnabled) : @"UIAccessibilityTraitNotEnabled",
@(UIAccessibilityTraitUpdatesFrequently) : @"UIAccessibilityTraitUpdatesFrequently",
@(UIAccessibilityTraitStartsMediaSession) : @"UIAccessibilityTraitStartsMediaSession",
@(UIAccessibilityTraitAdjustable) : @"UIAccessibilityTraitAdjustable",
@(UIAccessibilityTraitAllowsDirectInteraction) : @"UIAccessibilityTraitAllowsDirectInteraction",
@(UIAccessibilityTraitCausesPageTurn) : @"UIAccessibilityTraitCausesPageTurn",
@(UIAccessibilityTraitHeader) : @"UIAccessibilityTraitHeader"
};
}
/**
* @return The NSString value of the specified accessibility traits.
*/
+ (NSString *)stringValueOfUIAccessibilityTraits:(UIAccessibilityTraits)traits
error:(GTXErrorRefType)errorOrNil {
NSString *stringValue = [[self gtx_traitsToStringDictionary]
objectForKey:[NSNumber numberWithUnsignedLongLong:traits]];
if (nil == stringValue) {
NSString *errorMessage =
[NSString stringWithFormat:@"This element defines accessibility traits 0x%016llx which may "
@"be invalid.",
traits];
[NSError gtx_logOrSetError:errorOrNil
description:errorMessage
code:GTXCheckErrorCodeGenericError
userInfo:nil];
return nil;
}
return stringValue;
}
/**
* @return The NSString value of the @c element's accessibility label or @c nil if label was not
* set or an error occurred extracting the label.
*/
+ (NSString *)stringValueOfAccessibilityLabelForElement:(id)element
error:(GTXErrorRefType)errorOrNil {
NSString *stringValue;
id accessibilityLabel = [element accessibilityLabel];
if ([accessibilityLabel isKindOfClass:[NSString class]]) {
stringValue = accessibilityLabel;
} else if ([accessibilityLabel respondsToSelector:@selector(string)]) {
stringValue = [accessibilityLabel string];
} else if (accessibilityLabel) {
NSString *errorMessage =
[NSString stringWithFormat:@"String value of accessibility label %@ of class"
@" %@ could not be extracted from element %@",
accessibilityLabel,
NSStringFromClass([accessibilityLabel class]), element];
[NSError gtx_logOrSetError:errorOrNil
description:errorMessage
code:GTXCheckErrorCodeGenericError
userInfo:nil];
return nil;
}
return stringValue;
}
/**
* @return Returns the string obtained by removing whitespace and newlines present at the beginning
* and the end of the specified @c string.
*/
+ (NSString *)gtx_trimmedStringFromString:(NSString *)string {
return [string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
}
/**
* @return The UIAccessibilityTraits to redundant NSString accessibility label mapping dictionary
* as type NSDictionary<NSNumber *, NSString *> const *.
*/
+ (NSDictionary<NSNumber *, NSString *> const *)gtx_traitsToRedundantLabelsDictionary {
return @{
@(UIAccessibilityTraitButton) : @"button",
};
}
/**
* @return @c YES if @c element is tappable (for ex button) @c NO otherwise.
*/
+ (BOOL)gtx_isTappableNonLinkElement:(id)element {
BOOL hasTappableTrait = NO;
if ([element respondsToSelector:@selector(accessibilityTraits)]) {
UIAccessibilityTraits traits = [element accessibilityTraits];
hasTappableTrait =
((traits & UIAccessibilityTraitButton) || (traits & UIAccessibilityTraitLink) ||
(traits & UIAccessibilityTraitSearchField) || (traits & UIAccessibilityTraitPlaysSound) ||
(traits & UIAccessibilityTraitKeyboardKey));
}
return hasTappableTrait;
}
/**
* @return @c YES if @c element displays text @c NO otherwise.
*/
+ (BOOL)gtx_isTextDisplayingElement:(id)element {
BOOL hasTextTrait = NO;
if ([element respondsToSelector:@selector(accessibilityTraits)]) {
UIAccessibilityTraits traits = [element accessibilityTraits];
hasTextTrait =
((traits & UIAccessibilityTraitStaticText) || (traits & UIAccessibilityTraitLink) ||
(traits & UIAccessibilityTraitSearchField) || (traits & UIAccessibilityTraitKeyboardKey));
}
BOOL isContainedInTextField = NO;
if ([element respondsToSelector:@selector(accessibilityContainer)]) {
UIAccessibilityElement *container = [element accessibilityContainer];
isContainedInTextField = [container isKindOfClass:[UITextField class]];
}
return ([element isKindOfClass:[UILabel class]] || [element isKindOfClass:[UITextView class]] ||
[element isKindOfClass:[UITextField class]] || hasTextTrait || isContainedInTextField);
}
/**
* Determines if @c frame satisfies minimum touch target guidelines.
*
* @param frame The frame of an interactable element. This can be its frame, accessibilityFrame, or
* some other bounding box.
* @param propertyName The name of the element's property being checked. This is included in the
* description.
* @return A string describing the error, or @c nil if @c frame satisfies minimum touch target
* guidelines.
*/
+ (nullable NSString *)gtx_errorDescriptionForMinimumTappableArea:(CGRect)frame
propertyName:(NSString *)propertyName {
CGFloat width = CGRectGetWidth(frame);
CGFloat height = CGRectGetHeight(frame);
if (width < kGTXMinSizeForAccessibleElements || height < kGTXMinSizeForAccessibleElements) {
return [NSString
stringWithFormat:@"\n\nElement %@: %dx%d.", propertyName, (int)width, (int)height];
}
return nil;
}
/**
* Determines if an element responds to the selectors required to handle touch events.
*
* @param element The element to check if it responds to selectors.
* @return @c YES if @c element responds to @c accessibilityFrame, @c window,
* @c convertPoint:fromView:, and @c pointInside:withEvent:, and if window is non nil, @c NO
* otherwise.
*/
+ (BOOL)gtx_canElementRespondToTouches:(id)element {
return [element respondsToSelector:@selector(accessibilityFrame)] &&
[element respondsToSelector:@selector(window)] &&
[element respondsToSelector:@selector(convertPoint:fromView:)] &&
[element respondsToSelector:@selector(pointInside:withEvent:)] && [element window] != nil;
}
/**
* Determines if an element responds to touches in a sufficient area. If so, it is considered
* to have a sufficient touch target size. This method dynamically checks which coordinates an
* element considers "inside" to determine if its touch target size is sufficient. @c element must
* respond to @c accessibilityFrame, @c window,
* @c convertPoint:fromView:, and @c pointInside:withEvent:, and window must be non nil. If not, the
* element is ignored and @c YES is returned.
*
* @param element The element of which to check which points are inside.
* @return @c YES if @c element responds to points far enough apart to have a sufficient touch
* target size. @c YES if @c element does not respond to the above selectors. @c NO otherwise.
*/
+ (BOOL)gtx_elementRespondsToTouchesInSufficientArea:(id)element {
if (![GTXChecksCollection gtx_canElementRespondToTouches:element]) {
return YES;
}
CGRect center = CGRectMake(CGRectGetMidX([element accessibilityFrame]),
CGRectGetMidY([element accessibilityFrame]), 0, 0);
CGRect minimumFrame = CGRectInset(center, -kGTXMinSizeForAccessibleElements / 2.0,
-kGTXMinSizeForAccessibleElements / 2.0);
const NSInteger cornerCount = 4;
CGPoint corners[] = {
CGPointMake(CGRectGetMinX(minimumFrame), CGRectGetMinY(minimumFrame)),
CGPointMake(CGRectGetMaxX(minimumFrame), CGRectGetMinY(minimumFrame)),
CGPointMake(CGRectGetMinX(minimumFrame), CGRectGetMaxY(minimumFrame)),
CGPointMake(CGRectGetMaxX(minimumFrame), CGRectGetMaxY(minimumFrame))};
UIWindow *window = [element window];
for (NSInteger i = 0; i < cornerCount; i++) {
CGPoint pointInElementCoordinates = [element convertPoint:corners[i] fromView:window];
if (![element pointInside:pointInElementCoordinates withEvent:nil]) {
return NO;
}
}
return YES;
}
/**
* Determines if @c frame satisfies minimum touch target guidelines, and if it doesn't, adds an
* error description to @c array.
*
* @param frame The frame of an interactable element. This can be its frame, accessibilityFrame, or
* some other bounding box.
* @param propertyName The name of the element's property being checked. This is included in the
* description.
* @param array The array to which to add the error description, if it exists.
*/
+ (void)gtx_errorDescriptionForMinimumTappableArea:(CGRect)frame
propertyName:(NSString *)propertyName
addToArray:(NSMutableArray *)array {
NSString *errorDescription =
[GTXChecksCollection gtx_errorDescriptionForMinimumTappableArea:frame
propertyName:propertyName];
if (errorDescription != nil) {
[array addObject:errorDescription];
}
}
/**
* @return @c YES if the last characters of @c string equals @c suffix. Comparison is done case
* insensitively.
*/
+ (BOOL)gtx_caseInsensitive:(NSString *)string hasSuffix:(NSString *)suffix {
return [[string lowercaseString] hasSuffix:suffix];
}
/**
* Determines if @c font has a text style attribute that scales with Dynamic Type.
*
* @param font The font to check the attributes of.
* @return @c YES if the font's text style attribute scales with Dynamic Type, @c NO if it does
* not, or if the attribute doesn't exist.
*/
+ (BOOL)gtx_isFontTextStyleAttributeScalable:(UIFont *)font {
if ([font.fontDescriptor objectForKey:UIFontDescriptorTextStyleAttribute] != nil) {
NSString *textStyleAttribute =
[font.fontDescriptor objectForKey:UIFontDescriptorTextStyleAttribute];
if ([textStyleAttribute isKindOfClass:[NSString class]]) {
return ![textStyleAttribute isEqualToString:kGTXDefaultFontDescriptorTextStyle];
}
}
return NO;
}
@end