From 1e0e3d93b7e151fd71a5bd073479188e842ad8b3 Mon Sep 17 00:00:00 2001 From: Nicholas Entin Date: Wed, 24 Jan 2024 01:41:59 -0800 Subject: [PATCH] Support block-based accessibility properties These block-based variants of the UIAccessibility properties were introduced in iOS 17. Resolves #192. --- .../AccessibilityHierarchyParser.swift | 50 ++-- .../UIAccessibility+EffectiveProperties.swift | 227 ++++++++++++++++++ .../UIAccessibility+SnapshotAdditions.swift | 50 ++-- 3 files changed, 276 insertions(+), 51 deletions(-) create mode 100644 Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+EffectiveProperties.swift diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift index 77826f71..313742bb 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift @@ -191,8 +191,8 @@ public final class AccessibilityHierarchyParser { let userInputLabels: [String]? = { guard - element.object.accessibilityRespondsToUserInteraction, - let userInputLabels = element.object.accessibilityUserInputLabels, + element.object.effectiveAccessibilityRespondsToUserInteraction, + let userInputLabels = element.object.effectiveAccessibilityUserInputLabels, !userInputLabels.isEmpty else { return nil @@ -201,7 +201,7 @@ public final class AccessibilityHierarchyParser { return userInputLabels }() - let activationPoint = element.object.accessibilityActivationPoint + let activationPoint = element.object.effectiveAccessibilityActivationPoint return AccessibilityMarker( description: description, @@ -213,8 +213,8 @@ public final class AccessibilityHierarchyParser { defaultActivationPoint(for: element.object), tolerance: 1 / (root.window?.screen ?? UIScreen.main).scale ), - customActions: element.object.accessibilityCustomActions?.map { $0.name } ?? [], - accessibilityLanguage: element.object.accessibilityLanguage + customActions: element.object.effectiveAccessibilityCustomActions?.map { $0.name } ?? [], + accessibilityLanguage: element.object.effectiveAccessibilityLanguage ) } } @@ -345,14 +345,14 @@ public final class AccessibilityHierarchyParser { /// Returns the shape of the accessibility element in the root view's coordinate space. private func accessibilityShape(for element: NSObject, in root: UIView) -> AccessibilityMarker.Shape { - if let accessibilityPath = element.accessibilityPath { + if let accessibilityPath = element.effectiveAccessibilityPath { return .path(root.convert(accessibilityPath, from: nil)) } else if let element = element as? UIAccessibilityElement, let container = element.accessibilityContainer, !element.accessibilityFrameInContainerSpace.isNull { return .frame(container.convert(element.accessibilityFrameInContainerSpace, to: root)) } else { - return .frame(root.convert(element.accessibilityFrame, from: nil)) + return .frame(root.convert(element.effectiveAccessibilityFrame, from: nil)) } } @@ -369,7 +369,7 @@ public final class AccessibilityHierarchyParser { // By default, an element's activation point is the center of its accessibility frame, regardless of whether it // uses an accessibility path or frame as its shape. - let accessibilityFrame = element.accessibilityFrame + let accessibilityFrame = element.effectiveAccessibilityFrame return CGPoint(x: accessibilityFrame.midX, y: accessibilityFrame.midY) } @@ -412,7 +412,7 @@ public final class AccessibilityHierarchyParser { // Views that are not `UITabBar`s can use the `.tabBar` accessibility trait to have their elements treated // similarly to a `UITabBar`'s tabs (with a few differences). Unlike `UITabBar`s, all elements in the // hierarchy under the view are treated as tabs. - if view.accessibilityTraits.contains(.tabBar), let element = element as? UIView { + if view.effectiveAccessibilityTraits.contains(.tabBar), let element = element as? UIView { let accessibleElements: [NSObject] if let elements = viewToElementsMap[view] { accessibleElements = elements @@ -444,18 +444,18 @@ public final class AccessibilityHierarchyParser { if container is UISegmentedControl { return .series( index: elementIndex + 1, - count: container.accessibilityElementCount() + count: container.accessibilityElementCount() // TODO: Does this do the right thing when using blocks? ) } - if container.accessibilityTraits.contains(.tabBar) { + if container.effectiveAccessibilityTraits.contains(.tabBar) { return .tab( index: elementIndex + 1, count: container.accessibilityElementCount() ) } - if container.accessibilityContainerType == .list { + if container.effectiveAccessibilityContainerType == .list { if elementIndex == 0 { return .listStart } else if elementIndex == container.accessibilityElementCount() - 1 { @@ -463,7 +463,7 @@ public final class AccessibilityHierarchyParser { } } - if container.accessibilityContainerType == .landmark { + if container.effectiveAccessibilityContainerType == .landmark { if elementIndex == 0 { return .landmarkStart } else if elementIndex == container.accessibilityElementCount() - 1 { @@ -578,7 +578,7 @@ private extension NSObject { func recursiveAccessibilityHierarchy( contextProvider: AccessibilityHierarchyParser.ContextProvider? = nil ) -> [AccessibilityNode] { - guard !accessibilityElementsHidden else { + guard !effectiveAccessibilityElementsHidden else { return [] } @@ -592,10 +592,10 @@ private extension NSObject { var recursiveAccessibilityHierarchy: [AccessibilityNode] = [] - if isAccessibilityElement { + if effectiveIsAccessibilityElement { recursiveAccessibilityHierarchy.append(.element(self, contextProvider: contextProvider)) - } else if let accessibilityElements = accessibilityElements as? [NSObject] { + } else if let accessibilityElements = effectiveAccessibilityElements as? [NSObject] { var accessibilityHierarchyOfElements: [AccessibilityNode] = [] for element in accessibilityElements { accessibilityHierarchyOfElements.append( @@ -614,7 +614,7 @@ private extension NSObject { // If there is at least one modal subview, the last modal is the only subview parsed in the accessibility // hierarchy. Otherwise, parse all of the subviews. let subviewsToParse: [UIView] - if let lastModalView = self.subviews.last(where: { $0.accessibilityViewIsModal }) { + if let lastModalView = self.subviews.last(where: { $0.effectiveAccessibilityViewIsModal }) { subviewsToParse = [lastModalView] } else { subviewsToParse = self.subviews @@ -629,7 +629,7 @@ private extension NSObject { ) } - if shouldGroupAccessibilityChildren { + if effectiveShouldGroupAccessibilityChildren { recursiveAccessibilityHierarchy.append( .group(accessibilityHierarchyOfSubviews, explicitlyOrdered: false, frameOverrideProvider: nil) ) @@ -649,16 +649,16 @@ private extension NSObject { private var providesContext: Bool { return self is UISegmentedControl || self is UITabBar - || accessibilityTraits.contains(.tabBar) - || accessibilityContainerType == .list - || accessibilityContainerType == .landmark - || (self is UIAccessibilityContainerDataTable && accessibilityContainerType == .dataTable) + || effectiveAccessibilityTraits.contains(.tabBar) + || effectiveAccessibilityContainerType == .list + || effectiveAccessibilityContainerType == .landmark + || (self is UIAccessibilityContainerDataTable && effectiveAccessibilityContainerType == .dataTable) } /// The form of context provider the object acts as for elements beneath it in the hierarchy when the elements /// beneath it are part of the view hierarchy and the object is not an accessibility container. private func providedContextAsSuperview() -> AccessibilityHierarchyParser.ContextProvider { - if accessibilityContainerType == .dataTable, let self = self as? UIAccessibilityContainerDataTable { + if effectiveAccessibilityContainerType == .dataTable, let self = self as? UIAccessibilityContainerDataTable { return .dataTable(self) } @@ -668,7 +668,7 @@ private extension NSObject { /// The form of context provider the object acts as for elements beneath it in the hierarchy when the object is /// being used as an accessibility container. private func providedContextAsContainer() -> AccessibilityHierarchyParser.ContextProvider { - if accessibilityContainerType == .dataTable, let self = self as? UIAccessibilityContainerDataTable { + if effectiveAccessibilityContainerType == .dataTable, let self = self as? UIAccessibilityContainerDataTable { return .dataTable(self) } @@ -682,7 +682,7 @@ private extension NSObject { switch contextProvider { case let .superview(view): - return view.accessibilityTraits.contains(.tabBar) + return view.effectiveAccessibilityTraits.contains(.tabBar) case .accessibilityContainer, .dataTable: return false diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+EffectiveProperties.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+EffectiveProperties.swift new file mode 100644 index 00000000..ba469090 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+EffectiveProperties.swift @@ -0,0 +1,227 @@ +// +// Copyright 2024 Block 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 UIKit + +// TODO: Do the block variants automatically account for the attributed versions into the non-attributed versions? + +extension NSObject { + + var effectiveIsAccessibilityElement: Bool { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return isAccessibilityElementBlock?() ?? isAccessibilityElement + } else { + return isAccessibilityElement + } + #else + return isAccessibilityElement + #endif + } + + var effectiveAccessibilityLabel: String? { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityLabelBlock?() ?? accessibilityLabel + } else { + return accessibilityLabel + } + #else + return accessibilityLabel + #endif + } + + var effectiveAccessibilityValue: String? { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityValueBlock?() ?? accessibilityValue + } else { + return accessibilityValue + } + #else + return accessibilityValue + #endif + } + + var effectiveAccessibilityHint: String? { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityHintBlock?() ?? accessibilityHint + } else { + return accessibilityHint + } + #else + return accessibilityHint + #endif + } + + var effectiveAccessibilityTraits: UIAccessibilityTraits { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityTraitsBlock?() ?? accessibilityTraits + } else { + return accessibilityTraits + } + #else + return accessibilityTraits + #endif + } + + var effectiveAccessibilityLanguage: String? { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityLanguageBlock?() ?? accessibilityLanguage + } else { + return accessibilityLanguage + } + #else + return accessibilityLanguage + #endif + } + + var effectiveAccessibilityUserInputLabels: [String]! { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityUserInputLabelsBlock?() ?? accessibilityUserInputLabels + } else { + return accessibilityUserInputLabels + } + #else + return accessibilityUserInputLabels + #endif + } + + var effectiveAccessibilityElementsHidden: Bool { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityElementsHiddenBlock?() ?? accessibilityElementsHidden + } else { + return accessibilityElementsHidden + } + #else + return accessibilityElementsHidden + #endif + } + + var effectiveAccessibilityRespondsToUserInteraction: Bool { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityRespondsToUserInteractionBlock?() ?? accessibilityRespondsToUserInteraction + } else { + return accessibilityRespondsToUserInteraction + } + #else + return accessibilityRespondsToUserInteraction + #endif + } + + var effectiveAccessibilityViewIsModal: Bool { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityViewIsModalBlock?() ?? accessibilityViewIsModal + } else { + return accessibilityViewIsModal + } + #else + return accessibilityViewIsModal + #endif + } + + var effectiveShouldGroupAccessibilityChildren: Bool { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityShouldGroupAccessibilityChildrenBlock?() ?? shouldGroupAccessibilityChildren + } else { + return shouldGroupAccessibilityChildren + } + #else + return shouldGroupAccessibilityChildren + #endif + } + + var effectiveAccessibilityElements: [Any]? { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityElementsBlock?() ?? accessibilityElements + } else { + return accessibilityElements + } + #else + return accessibilityElements + #endif + } + + var effectiveAccessibilityContainerType: UIAccessibilityContainerType { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityContainerTypeBlock?() ?? accessibilityContainerType + } else { + return accessibilityContainerType + } + #else + return accessibilityContainerType + #endif + } + + var effectiveAccessibilityActivationPoint: CGPoint { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityActivationPointBlock?() ?? accessibilityActivationPoint + } else { + return accessibilityActivationPoint + } + #else + return accessibilityActivationPoint + #endif + } + + var effectiveAccessibilityFrame: CGRect { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityFrameBlock?() ?? accessibilityFrame + } else { + return accessibilityFrame + } + #else + return accessibilityFrame + #endif + } + + var effectiveAccessibilityPath: UIBezierPath? { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityPathBlock?() ?? accessibilityPath + } else { + return accessibilityPath + } + #else + return accessibilityPath + #endif + } + + var effectiveAccessibilityCustomActions: [UIAccessibilityCustomAction]? { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + return accessibilityCustomActionsBlock?() ?? accessibilityCustomActions + } else { + return accessibilityCustomActions + } + #else + return accessibilityCustomActions + #endif + } + +} diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+SnapshotAdditions.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+SnapshotAdditions.swift index ad0fa500..cf609c7f 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+SnapshotAdditions.swift +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIAccessibility+SnapshotAdditions.swift @@ -20,14 +20,14 @@ extension NSObject { /// Returns a tuple consisting of the `description` and (optionally) a `hint` that VoiceOver will read for the object. func accessibilityDescription(context: AccessibilityHierarchyParser.Context?) -> (description: String, hint: String?) { - var accessibilityDescription = accessibilityLabelOverride(for: context) ?? accessibilityLabel ?? "" + var accessibilityDescription = accessibilityLabelOverride(for: context) ?? effectiveAccessibilityLabel ?? "" - var hintDescription = accessibilityHint?.nonEmpty() + var hintDescription = effectiveAccessibilityHint?.nonEmpty() - let strings = Strings(locale: accessibilityLanguage) + let strings = Strings(locale: effectiveAccessibilityLanguage) let numberFormatter = NumberFormatter() - if let localeIdentifier = accessibilityLanguage { + if let localeIdentifier = effectiveAccessibilityLanguage { numberFormatter.locale = Locale(identifier: localeIdentifier) } @@ -36,7 +36,7 @@ extension NSObject { switch context { case let .dataTableCell(row: row, column: column, width: width, height: height, isFirstInRow: isFirstInRow, rowHeaders: rowHeaders, columnHeaders: columnHeaders): let headersDescription = (rowHeaders + columnHeaders).map { header -> String in - switch (header.accessibilityLabel?.nonEmpty(), header.accessibilityValue?.nonEmpty()) { + switch (header.effectiveAccessibilityLabel?.nonEmpty(), header.effectiveAccessibilityValue?.nonEmpty()) { case (nil, nil): return "" case let (.some(label), nil): @@ -74,7 +74,7 @@ extension NSObject { descriptionContainsContext = false } - if let accessibilityValue = accessibilityValue?.nonEmpty(), !hidesAccessibilityValue(for: context) { + if let accessibilityValue = effectiveAccessibilityValue?.nonEmpty(), !hidesAccessibilityValue(for: context) { if let existingDescription = accessibilityDescription.nonEmpty() { if descriptionContainsContext { accessibilityDescription += " \(accessibilityValue)" @@ -86,7 +86,7 @@ extension NSObject { } } - if accessibilityTraits.contains(.selected) { + if effectiveAccessibilityTraits.contains(.selected) { if let existingDescription = accessibilityDescription.nonEmpty() { accessibilityDescription = String(format: strings.selectedTraitFormat, existingDescription) } else { @@ -96,25 +96,25 @@ extension NSObject { var traitSpecifiers: [String] = [] - if accessibilityTraits.contains(.notEnabled) { + if effectiveAccessibilityTraits.contains(.notEnabled) { traitSpecifiers.append(strings.notEnabledTraitName) } let hidesButtonTraitInContext = context?.hidesButtonTrait ?? false - let hidesButtonTraitFromTraits = [UIAccessibilityTraits.keyboardKey, .switchButton, .tabBarItem].contains(where: { accessibilityTraits.contains($0) }) - if accessibilityTraits.contains(.button) && !hidesButtonTraitFromTraits && !hidesButtonTraitInContext { + let hidesButtonTraitFromTraits = [UIAccessibilityTraits.keyboardKey, .switchButton, .tabBarItem].contains(where: { effectiveAccessibilityTraits.contains($0) }) + if effectiveAccessibilityTraits.contains(.button) && !hidesButtonTraitFromTraits && !hidesButtonTraitInContext { traitSpecifiers.append(strings.buttonTraitName) } - if accessibilityTraits.contains(.switchButton) { - if accessibilityTraits.contains(.button) { + if effectiveAccessibilityTraits.contains(.switchButton) { + if effectiveAccessibilityTraits.contains(.button) { // An element can have the private switch button trait without being a UISwitch (for example, by passing // through the traits of a contained switch). In this case, VoiceOver will still read the "Switch // Button." trait, but only if the element's traits also include the `.button` trait. traitSpecifiers.append(strings.switchButtonTraitName) } - switch accessibilityValue { + switch effectiveAccessibilityValue { case "1": traitSpecifiers.append(strings.switchButtonOnStateName) case "0": @@ -128,27 +128,27 @@ extension NSObject { } let showsTabTraitInContext = context?.showsTabTrait ?? false - if accessibilityTraits.contains(.tabBarItem) || showsTabTraitInContext { + if effectiveAccessibilityTraits.contains(.tabBarItem) || showsTabTraitInContext { traitSpecifiers.append(strings.tabTraitName) } - if accessibilityTraits.contains(.header) { + if effectiveAccessibilityTraits.contains(.header) { traitSpecifiers.append(strings.headerTraitName) } - if accessibilityTraits.contains(.link) { + if effectiveAccessibilityTraits.contains(.link) { traitSpecifiers.append(strings.linkTraitName) } - if accessibilityTraits.contains(.adjustable) { + if effectiveAccessibilityTraits.contains(.adjustable) { traitSpecifiers.append(strings.adjustableTraitName) } - if accessibilityTraits.contains(.image) { + if effectiveAccessibilityTraits.contains(.image) { traitSpecifiers.append(strings.imageTraitName) } - if accessibilityTraits.contains(.searchField) { + if effectiveAccessibilityTraits.contains(.searchField) { traitSpecifiers.append(strings.searchFieldTraitName) } @@ -221,7 +221,7 @@ extension NSObject { } } - if accessibilityTraits.contains(.switchButton) && !accessibilityTraits.contains(.notEnabled) { + if effectiveAccessibilityTraits.contains(.switchButton) && !effectiveAccessibilityTraits.contains(.notEnabled) { if let existingHintDescription = hintDescription?.nonEmpty()?.strippingTrailingPeriod() { hintDescription = String(format: strings.switchButtonTraitHintFormat, existingHintDescription) } else { @@ -229,9 +229,9 @@ extension NSObject { } } - let hasHintOnly = (accessibilityHint?.nonEmpty() != nil) && (accessibilityLabel?.nonEmpty() == nil) && (accessibilityValue?.nonEmpty() == nil) - let hidesAdjustableHint = accessibilityTraits.contains(.notEnabled) || accessibilityTraits.contains(.switchButton) || hasHintOnly - if accessibilityTraits.contains(.adjustable) && !hidesAdjustableHint { + let hasHintOnly = (effectiveAccessibilityHint?.nonEmpty() != nil) && (effectiveAccessibilityLabel?.nonEmpty() == nil) && (effectiveAccessibilityValue?.nonEmpty() == nil) + let hidesAdjustableHint = effectiveAccessibilityTraits.contains(.notEnabled) || effectiveAccessibilityTraits.contains(.switchButton) || hasHintOnly + if effectiveAccessibilityTraits.contains(.adjustable) && !hidesAdjustableHint { if let existingHintDescription = hintDescription?.nonEmpty()?.strippingTrailingPeriod() { hintDescription = String(format: strings.adjustableTraitHintFormat, existingHintDescription) } else { @@ -259,7 +259,7 @@ extension NSObject { } private func hidesAccessibilityValue(for context: AccessibilityHierarchyParser.Context?) -> Bool { - if accessibilityTraits.contains(.switchButton) { + if effectiveAccessibilityTraits.contains(.switchButton) { return true } @@ -276,8 +276,6 @@ extension NSObject { } } - // MARK: - Private Static Properties - // MARK: - Private private struct Strings {