diff --git a/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj b/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj index 92ba40c4..e96cc752 100644 --- a/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj +++ b/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ 66E2CD14CD63946657E17B15 /* Pods_SnapshotTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3192D7B9B16BD10FB517A2 /* Pods_SnapshotTests.framework */; }; 83A295842AC22D9D00DFBE4F /* UserInputLabelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A295832AC22D9D00DFBE4F /* UserInputLabelsViewController.swift */; }; 83A295862AC22EEE00DFBE4F /* UserInputLabelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A295852AC22EEE00DFBE4F /* UserInputLabelsTests.swift */; }; + AC725B842B06D07E009AD59B /* AccessibilityCustomContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC725B832B06D07E009AD59B /* AccessibilityCustomContentViewController.swift */; }; C47F6C5316FB0C043BEB59F3 /* Pods_AccessibilitySnapshotDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6A886964D2E787399E137105 /* Pods_AccessibilitySnapshotDemo.framework */; }; D2F76EED2945C879000A453F /* HitTargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F76EEC2945C879000A453F /* HitTargetTests.swift */; }; D38F6F4E508A3D067D677F69 /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BFCB4FD6BC17AB232B26E72 /* Pods_UnitTests.framework */; }; @@ -148,6 +149,7 @@ 83A295832AC22D9D00DFBE4F /* UserInputLabelsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInputLabelsViewController.swift; sourceTree = ""; }; 83A295852AC22EEE00DFBE4F /* UserInputLabelsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInputLabelsTests.swift; sourceTree = ""; }; 88C33CBF672C290CE1EE86AF /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; + AC725B832B06D07E009AD59B /* AccessibilityCustomContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityCustomContentViewController.swift; sourceTree = ""; }; C78F90CE7A2A315AADF80144 /* Pods-SnapshotTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SnapshotTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests.debug.xcconfig"; sourceTree = ""; }; CCFF2A604706B71DC0CBD38B /* Pods-AccessibilitySnapshotDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AccessibilitySnapshotDemo.release.xcconfig"; path = "Pods/Target Support Files/Pods-AccessibilitySnapshotDemo/Pods-AccessibilitySnapshotDemo.release.xcconfig"; sourceTree = ""; }; D2F76EEC2945C879000A453F /* HitTargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTargetTests.swift; sourceTree = ""; }; @@ -211,6 +213,7 @@ 3DDE7FF524C6D6BF00999ABA /* AccessibilityCustomActionsViewController.swift */, 1104A8D02B595FF600B6715F /* TextFieldViewController.swift */, 112909BB2B63E57B00B4EBEB /* TextViewViewController.swift */, + AC725B832B06D07E009AD59B /* AccessibilityCustomContentViewController.swift */, ); name = "Accessibility Screens"; sourceTree = ""; @@ -643,6 +646,7 @@ 112909BC2B63E57B00B4EBEB /* TextViewViewController.swift in Sources */, 3DDE7FF624C6D6BF00999ABA /* AccessibilityCustomActionsViewController.swift in Sources */, 3DBAC28F2242E7C700EF4D0A /* ListContainerViewController.swift in Sources */, + AC725B842B06D07E009AD59B /* AccessibilityCustomContentViewController.swift in Sources */, 3DBEAA5B2222953E00FAE61D /* SwitchControlViewController.swift in Sources */, 3DBAC2912242F9B200EF4D0A /* LandmarkContainerViewController.swift in Sources */, 1104A8CF2B580AC500B6715F /* SwiftUITextEntry.swift in Sources */, diff --git a/Example/AccessibilitySnapshot/AccessibilityCustomContentViewController.swift b/Example/AccessibilitySnapshot/AccessibilityCustomContentViewController.swift new file mode 100644 index 00000000..f5c2ff3e --- /dev/null +++ b/Example/AccessibilitySnapshot/AccessibilityCustomContentViewController.swift @@ -0,0 +1,116 @@ +// +// 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 Accessibility +import Paralayout +import UIKit + +@available(iOS 14.0, *) +final class AccessibilityCustomContentViewController: AccessibilityViewController { + + // MARK: - UIViewController + + override func loadView() { + view = View( + views: [ + .init(includeLabel: true, includeHint: true), + .init(includeLabel: true, includeHint: false), + .init(includeLabel: false, includeHint: true), + .init(includeLabel: false, includeHint: false), + ] + ) + } + +} + +// MARK: - +@available(iOS 14.0, *) +private extension AccessibilityCustomContentViewController { + + final class View: UIView { + + // MARK: - Life Cycle + + init(views: [CustomContentView], frame: CGRect = .zero) { + self.views = views + + super.init(frame: frame) + + views.forEach(addSubview) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private Properties + + private let views: [CustomContentView] + + // MARK: - UIView + + override func layoutSubviews() { + views.forEach { $0.bounds.size = .init(width: bounds.width / 2, height: 50) } + + let statusBarHeight = window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 + + var distributionSpecifiers: [ViewDistributionSpecifying] = [ statusBarHeight.fixed, 1.flexible ] + for subview in views { + distributionSpecifiers.append(subview) + distributionSpecifiers.append(1.flexible) + } + applyVerticalSubviewDistribution(distributionSpecifiers) + } + + } + +} + +// MARK: - +@available(iOS 14.0, *) +private extension AccessibilityCustomContentViewController { + + final class CustomContentView: UIView, AXCustomContentProvider { + // MARK: - Life Cycle + + init(includeLabel: Bool, includeHint: Bool) { + super.init(frame: .zero) + + backgroundColor = .gray + + isAccessibilityElement = true + + accessibilityLabel = includeLabel ? "Label" : nil + accessibilityHint = includeHint ? "Hint" : nil + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIAccessibility + var accessibilityCustomContent: [AXCustomContent]! = { + let customContent = AXCustomContent(label: "Custom Content Label", value: "Custom Content Value") + + let highImportance = AXCustomContent(label: "High Importance Label", value: "High Importance Value") + highImportance.importance = .high + + return [customContent, highImportance] + }() + } +} diff --git a/Example/AccessibilitySnapshot/RootViewController.swift b/Example/AccessibilitySnapshot/RootViewController.swift index 16438fea..4e1b020f 100644 --- a/Example/AccessibilitySnapshot/RootViewController.swift +++ b/Example/AccessibilitySnapshot/RootViewController.swift @@ -26,7 +26,7 @@ final class RootViewController: UITableViewController { // MARK: - Life Cycle init() { - self.accessibilityScreens = [ + var accessibilityScreens = [ ("View Accessibility Properties", { _ in return ViewAccessibilityPropertiesViewController() }), ("Label Accessibility Properties", { _ in return LabelAccessibilityPropertiesViewController() }), ("Button Accessibility Traits", { _ in return ButtonAccessibilityTraitsViewController() }), @@ -66,7 +66,11 @@ final class RootViewController: UITableViewController { ("Text View", { _ in return TextViewViewController() }), ("SwiftUI Text Entry", { _ in return UIHostingController(rootView: SwiftUITextEntry()) }), ] - + if #available(iOS 14.0, *) { + accessibilityScreens.append( ("Accessibility Custom Content", { _ in return AccessibilityCustomContentViewController() })) + } + self.accessibilityScreens = accessibilityScreens + super.init(nibName: nil, bundle: nil) } diff --git a/Example/AccessibilitySnapshot/SwiftUIView.swift b/Example/AccessibilitySnapshot/SwiftUIView.swift index 0c347223..4349b8ea 100644 --- a/Example/AccessibilitySnapshot/SwiftUIView.swift +++ b/Example/AccessibilitySnapshot/SwiftUIView.swift @@ -28,54 +28,75 @@ fileprivate struct Circle: View { struct SwiftUIView: View { var body: some View { VStack(spacing: 30) { - // View with nothing. - Circle() - .accessibility(label: Text("")) - .accessibility(value: Text("")) - .accessibility(hint: Text("")) - - // View with label. - Circle() - .accessibility(label: Text("Label")) - .accessibility(value: Text("")) - .accessibility(hint: Text("")) - - // View with value. - Circle() - .accessibility(label: Text("")) - .accessibility(value: Text("Value")) - .accessibility(hint: Text("")) - - // View with hint. - Circle() - .accessibility(label: Text("")) - .accessibility(value: Text("")) - .accessibility(hint: Text("Hint")) - - // View with label and value. - Circle() - .accessibility(label: Text("Label")) - .accessibility(value: Text("Value")) - .accessibility(hint: Text("")) - - // View with label and hint. - Circle() - .accessibility(label: Text("Label")) - .accessibility(value: Text("")) - .accessibility(hint: Text("Hint")) - - // View with value and hint. - Circle() - .accessibility(label: Text("")) - .accessibility(value: Text("Value")) - .accessibility(hint: Text("Hint")) - - // View with label, value, and hint. - Circle() - .accessibility(label: Text("Label")) - .accessibility(value: Text("Value")) - .accessibility(hint: Text("Hint")) - + Group { + // View with nothing. + Circle() + .accessibility(label: Text("")) + .accessibility(value: Text("")) + .accessibility(hint: Text("")) + + // View with label. + Circle() + .accessibility(label: Text("Label")) + .accessibility(value: Text("")) + .accessibility(hint: Text("")) + + // View with value. + Circle() + .accessibility(label: Text("")) + .accessibility(value: Text("Value")) + .accessibility(hint: Text("")) + + // View with hint. + Circle() + .accessibility(label: Text("")) + .accessibility(value: Text("")) + .accessibility(hint: Text("Hint")) + + // View with label and value. + Circle() + .accessibility(label: Text("Label")) + .accessibility(value: Text("Value")) + .accessibility(hint: Text("")) + + // View with label and hint. + Circle() + .accessibility(label: Text("Label")) + .accessibility(value: Text("")) + .accessibility(hint: Text("Hint")) + + // View with value and hint. + Circle() + .accessibility(label: Text("")) + .accessibility(value: Text("Value")) + .accessibility(hint: Text("Hint")) + + // View with label, value, and hint. + Circle() + .accessibility(label: Text("Label")) + .accessibility(value: Text("Value")) + .accessibility(hint: Text("Hint")) + + if #available(iOS 14.0, *) { + // View with label, value, hint, and Custom Actions. + Circle() + .accessibility(label: Text("Label")) + .accessibility(value: Text("Value")) + .accessibility(hint: Text("Hint")) + .accessibilityAction(named: "Custom") {} + } + + if #available(iOS 15.0, *) { + // View with label, value, hint, and Custom Content. + Circle() + .accessibility(label: Text("Label")) + .accessibility(value: Text("Value")) + .accessibility(hint: Text("Hint")) + .accessibilityCustomContent("Key", "Value") + .accessibilityCustomContent("Important Key", "Important Value", importance: .high) + } + } + Spacer() } } diff --git a/Example/SnapshotTests/AccessibilityPropertiesTests.swift b/Example/SnapshotTests/AccessibilityPropertiesTests.swift index be274103..6985db4c 100644 --- a/Example/SnapshotTests/AccessibilityPropertiesTests.swift +++ b/Example/SnapshotTests/AccessibilityPropertiesTests.swift @@ -69,6 +69,18 @@ final class AccessibilitySnapshotTests: SnapshotTestCase { customActionsViewController.view.frame = UIScreen.main.bounds SnapshotVerifyAccessibility(customActionsViewController.view) } + + @available(iOS 14.0, *) + func testCustomContent() throws { + try XCTSkipUnless( + ProcessInfo().operatingSystemVersion.majorVersion >= 14, + "This test only supports iOS 14 and later" + ) + + let customContentViewController = AccessibilityCustomContentViewController() + customContentViewController.view.frame = UIScreen.main.bounds + SnapshotVerifyAccessibility(customContentViewController.view) + } func testLargeView() throws { let view = UIView(frame: CGRect(x: 0, y: 0, width: 1400, height: 1400)) diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__14_5_390x844@3x.png new file mode 100644 index 00000000..c087a103 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__16_4_393x852@3x.png new file mode 100644 index 00000000..547d8550 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__17_2_393x852@3x.png new file mode 100644 index 00000000..547d8550 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContentAndReturnError__17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_14_5_390x844@3x.png index 63c0781d..009b8004 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_14_5_390x844@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_16_4_393x852@3x.png index 354a10f9..7d94317c 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_17_2_393x852@3x.png index 1ecef7ca..2c24990c 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleViewAtSizeThatFits_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_14_5_390x844@3x.png index 4f49c852..5b42f917 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_14_5_390x844@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_16_4_393x852@3x.png index 7dd02626..a8978ee2 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_17_2_393x852@3x.png index 37a82de6..13436370 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.SwiftUISnapshotTests/testSimpleView_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.390x844-14-5-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.390x844-14-5-3x.png index 4f49c852..5b42f917 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.390x844-14-5-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.390x844-14-5-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-16-4-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-16-4-3x.png index 5e76e862..c6a0dba1 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-16-4-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-16-4-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-17-2-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-17-2-3x.png index f86180c0..20a6f925 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-17-2-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIConfiguration.393x852-17-2-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.390x844-14-5-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.390x844-14-5-3x.png index 4f49c852..5b42f917 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.390x844-14-5-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.390x844-14-5-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-16-4-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-16-4-3x.png index 5e76e862..c6a0dba1 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-16-4-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-16-4-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-17-2-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-17-2-3x.png index f86180c0..20a6f925 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-17-2-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testSimpleSwiftUIWithScrollViewConfiguration.393x852-17-2-3x.png differ diff --git a/Scripts/ValidateLocalizedStrings.swift b/Scripts/ValidateLocalizedStrings.swift index e65030e7..49371915 100755 --- a/Scripts/ValidateLocalizedStrings.swift +++ b/Scripts/ValidateLocalizedStrings.swift @@ -66,10 +66,18 @@ let translationRegex = Regex { var allValid = true for filePath in stringsFiles { - let translationKeys = try String(contentsOfFile: filePath).matches(of: translationRegex).map { $0.1 } + let translationKeys = try Set(String(contentsOfFile: filePath).matches(of: translationRegex).map { $0.1 }) - if Set(translationKeys) != localizedKeysSet { + if translationKeys != localizedKeysSet { print("❌ \(filePath) does not match expected set of localized string keys") + let expected = translationKeys.subtracting(localizedKeysSet) + if expected.count != 0 { + print("Expected Keys not found: \(expected)") + } + let found = localizedKeysSet.subtracting(translationKeys) + if found.count != 0 { + print("No translation found for keys: \(found)") + } allValid = false } } diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Assets/de.lproj/Localizable.strings b/Sources/AccessibilitySnapshot/Core/Swift/Assets/de.lproj/Localizable.strings index 55d33a30..000de578 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Assets/de.lproj/Localizable.strings +++ b/Sources/AccessibilitySnapshot/Core/Swift/Assets/de.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* Description for an accessibility element indicating that it has custom actions available */ "custom_actions.description" = "Aktionen verfügbar"; +/* Description for an accessibility element indicating that it has additional custom content available */ +"custom_content.description" = "Weiterhin inhalte verfügbar"; + /* Description for the 'text entry' accessibility trait */ "trait.text_field.description" = "Textfeld."; diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Assets/en.lproj/Localizable.strings b/Sources/AccessibilitySnapshot/Core/Swift/Assets/en.lproj/Localizable.strings index 56db8fa1..f046f757 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Assets/en.lproj/Localizable.strings +++ b/Sources/AccessibilitySnapshot/Core/Swift/Assets/en.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* Description for an accessibility element indicating that it has custom actions available */ "custom_actions.description" = "Actions Available"; +/* Description for an accessibility element indicating that it has additional custom content available */ +"custom_content.description" = "More Content Available"; + /* Description for the 'text entry' accessibility trait */ "trait.text_field.description" = "Text Field."; diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Assets/ru.lproj/Localizable.strings b/Sources/AccessibilitySnapshot/Core/Swift/Assets/ru.lproj/Localizable.strings index 730ac4ad..c27e17d7 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Assets/ru.lproj/Localizable.strings +++ b/Sources/AccessibilitySnapshot/Core/Swift/Assets/ru.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* Description for an accessibility element indicating that it has custom actions available */ "custom_actions.description" = "Доступны действия"; +/* Description for an accessibility element indicating that it has additional custom content available */ +"custom_content.description" = "Доступно больше контента"; + /* Description for the 'text entry' accessibility trait */ "trait.text_field.description" = "текстовое поле."; diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift index 77826f71..83373cb6 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Accessibility import UIKit public struct AccessibilityMarker { @@ -57,6 +58,9 @@ public struct AccessibilityMarker { /// The names of the custom actions supported by the element. public var customActions: [String] + + /// Any custom content included by the element. + public var customContent: [(label: String, value: String, isImportant:Bool)] /// The language code of the language used to localize strings in the description. public var accessibilityLanguage: String? @@ -214,6 +218,7 @@ public final class AccessibilityHierarchyParser { tolerance: 1 / (root.window?.screen ?? UIScreen.main).scale ), customActions: element.object.accessibilityCustomActions?.map { $0.name } ?? [], + customContent: element.object.customContent, accessibilityLanguage: element.object.accessibilityLanguage ) } @@ -706,6 +711,38 @@ extension UIView { } +fileprivate extension NSObject { + var customContent: [(label: String, value: String, isImportant:Bool)] { + // Github runs tests on specific iOS versions against specific versions of Xcode in CI. + // Forward deployment on old versions of Xcode require a compile time check which require diferentiation by swift version rather than iOS SDK. + // See https://swiftversion.net/ for mapping swift version to Xcode versions. + + if #available(iOS 14.0, *) { + if let provider = self as? AXCustomContentProvider { + + // Swift 5.9 ships with Xcode 15 and the iOS 17 SDK. + #if swift(>=5.9) + if #available(iOS 17.0, *) { + if let customContentBlock = provider.accessibilityCustomContentBlock { + if let content = customContentBlock?() { + return content.map { content in + return (content.label, content.value, content.importance == .high) + } + } + } + } + #endif //swift(>=5.9) + if let content = provider.accessibilityCustomContent { + return content.map { content in + return (content.label, content.value, content.importance == .high) + } + } + } + } + return [] + } +} + // MARK: - private extension CGPoint { @@ -713,5 +750,4 @@ private extension CGPoint { func approximatelyEquals(_ other: CGPoint, tolerance: CGFloat) -> Bool { return abs(self.x - other.x) < tolerance && abs(self.y - other.y) < tolerance } - } diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift index 5bbe13bf..b91b969d 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift @@ -283,6 +283,21 @@ private extension AccessibilitySnapshotView { ) }() + // If our description and hint are both empty, and we dont have custom actions, but we do have custom content, we'll use the description label + // to show the "More Content Available" text, since this makes our layout simpler when we align to the marker. + let showCustomContentInDescription = (marker.description.isEmpty && !showActionsAvailableInDescription && !marker.customContent.isEmpty) + + self.customContentView = { + guard !marker.customContent.isEmpty else { return nil } + + return .init( + customContentText: showCustomContentInDescription + ? nil + : Strings.moreContentAvailableText(for: marker.accessibilityLanguage), + customContent: marker.customContent + ) + }() + self.userInputLabelsView = { guard showUserInputLabels, let userInputLabels = marker.userInputLabels, userInputLabels.count > 0 else { return nil } @@ -294,9 +309,13 @@ private extension AccessibilitySnapshotView { markerView.backgroundColor = color.withAlphaComponent(0.3) addSubview(markerView) - descriptionLabel.text = showActionsAvailableInDescription + descriptionLabel.text = + showCustomContentInDescription + ? Strings.moreContentAvailableText(for: marker.accessibilityLanguage) + : showActionsAvailableInDescription ? Strings.actionsAvailableText(for: marker.accessibilityLanguage) : marker.description + descriptionLabel.font = Metrics.descriptionLabelFont descriptionLabel.textColor = .black descriptionLabel.numberOfLines = 0 @@ -304,6 +323,7 @@ private extension AccessibilitySnapshotView { hintLabel.map(addSubview) customActionsView.map(addSubview) + customContentView.map(addSubview) userInputLabelsView.map(addSubview) } @@ -321,7 +341,9 @@ private extension AccessibilitySnapshotView { private let hintLabel: UILabel? private let customActionsView: CustomActionsView? - + + private let customContentView: CustomContentView? + private let userInputLabelsView: PillsView? // MARK: - UIView @@ -343,6 +365,8 @@ private extension AccessibilitySnapshotView { let customActionsSize = customActionsView?.sizeThatFits(labelSizeToFit) ?? .zero + let customContentSize = customContentView?.sizeThatFits(labelSizeToFit) ?? .zero + let userInputLabelsViewSize = userInputLabelsView?.sizeThatFits(labelSizeToFit) ?? .zero let widthComponents = [ @@ -352,6 +376,7 @@ private extension AccessibilitySnapshotView { descriptionLabelSize.width, hintLabelSize.width, customActionsSize.width, + customContentSize.width, userInputLabelsViewSize.width ), ] @@ -361,6 +386,7 @@ private extension AccessibilitySnapshotView { descriptionLabelSize.height, (hintLabelSize.height == 0 ? 0 : hintLabelSize.height + Metrics.interSectionSpacing), (customActionsSize.height == 0 ? 0 : customActionsSize.height + Metrics.interSectionSpacing), + (customContentSize.height == 0 ? 0 : customContentSize.height + Metrics.interSectionSpacing), (userInputLabelsViewSize.height == 0 ? 0 : userInputLabelsViewSize.height + Metrics.interSectionSpacing) ] @@ -412,8 +438,18 @@ private extension AccessibilitySnapshotView { ) } + if let customContentView = customContentView { + let alignmentLabel = customActionsView ?? hintLabel ?? descriptionLabel + + customContentView.bounds.size = customContentView.sizeThatFits(labelSizeToFit) + customContentView.frame.origin = .init( + x: alignmentLabel.frame.minX, + y: alignmentLabel.frame.maxY + Metrics.interSectionSpacing + ) + } + if let userInputLabelsView = userInputLabelsView { - let alignmentControl = customActionsView ?? hintLabel ?? descriptionLabel + let alignmentControl = customContentView ?? customActionsView ?? hintLabel ?? descriptionLabel userInputLabelsView.bounds.size = userInputLabelsView.sizeThatFits(labelSizeToFit) userInputLabelsView.frame.origin = CGPoint( @@ -447,11 +483,18 @@ private extension AccessibilitySnapshotView { locale: locale ) } - + + static func moreContentAvailableText(for locale: String?) -> String { + return "More Content Available".localized( + key: "custom_content.description", + comment: "Description for an accessibility element indicating that it has additional custom content available", + locale: locale + ) + } } } - + // MARK: - private final class CustomActionsView: UIView { @@ -594,6 +637,148 @@ private extension AccessibilitySnapshotView { } + +// MARK: - + +private final class CustomContentView: UIView { + + // MARK: - Life Cycle + + init(customContentText: String?, customContent: [(String, String, Bool)]) { + + contentLabels = customContent.map { (label, value, isImportant) in + let iconLabel = UILabel() + iconLabel.text = "↓" + iconLabel.font = Metrics.font + iconLabel.numberOfLines = 0 + + let customContentLabel = UILabel() + customContentLabel.font = isImportant ? Metrics.boldFont : Metrics.font + customContentLabel.numberOfLines = 0 + customContentLabel.text = { + guard !value.isEmpty else { return label } + return "\(label): \(value)" + }() + + return (iconLabel, customContentLabel) + } + + if let customContentText = customContentText { + let label = UILabel() + label.text = customContentText + label.font = Metrics.font + self.customContentLabel = label + + } else { + self.customContentLabel = nil + } + + super.init(frame: .zero) + + customContentLabel.map(addSubview) + + contentLabels.forEach { + addSubview($0) + addSubview($1) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private Properties + + private let customContentLabel: UILabel? + + private let contentLabels: [(UILabel, UILabel)] + + // MARK: - UIView + + override func sizeThatFits(_ size: CGSize) -> CGSize { + let actionsAvailableHeight = customContentLabel?.sizeThatFits(size).height ?? -Metrics.verticalSpacing + + guard let (firstIconLabel, _) = contentLabels.first else { + return .init(width: max(size.width, 0), height: actionsAvailableHeight) + } + + let descriptionWidthToFit = [ + Metrics.contentIconInset, + firstIconLabel.sizeThatFits(size).width, + Metrics.iconToDescriptionSpacing, + ].reduce(size.width, -) + let descriptionSizeToFit = CGSize(width: descriptionWidthToFit, height: .greatestFiniteMagnitude) + + let height = contentLabels + .map { $1.sizeThatFits(descriptionSizeToFit).height } + .reduce(actionsAvailableHeight) { + $0 + Metrics.verticalSpacing + $1 + } + + return .init(width: size.width, height: height) + } + + override func layoutSubviews() { + let firstPairYPosition: CGFloat + if let actionsAvailableLabel = customContentLabel { + actionsAvailableLabel.bounds.size = actionsAvailableLabel.sizeThatFits(bounds.size) + actionsAvailableLabel.frame.origin = .zero + + firstPairYPosition = actionsAvailableLabel.frame.maxY + Metrics.verticalSpacing + + } else { + firstPairYPosition = 0 + } + + guard let (firstIconLabel, firstDescriptionLabel) = contentLabels.first else { + return + } + + firstIconLabel.sizeToFit() + + // All of the icon labels should be the same size, so we only need to calculate the description width once. + let descriptionWidthToFit = [ + Metrics.contentIconInset, + firstIconLabel.bounds.width, + Metrics.iconToDescriptionSpacing, + ].reduce(bounds.width, -) + let descriptionSizeToFit = CGSize(width: descriptionWidthToFit, height: .greatestFiniteMagnitude) + + firstDescriptionLabel.bounds.size = firstDescriptionLabel.sizeThatFits(descriptionSizeToFit) + + firstIconLabel.frame.origin = .init(x: Metrics.contentIconInset, y: firstPairYPosition) + + let descriptionXPosition = firstIconLabel.frame.maxX + Metrics.iconToDescriptionSpacing + + firstDescriptionLabel.frame.origin = .init(x: descriptionXPosition, y: firstPairYPosition) + + let zippedActionLabels = zip(contentLabels.dropFirst(), contentLabels) + for ((iconLabel, descriptionLabel), (_, previousDescriptionLabel)) in zippedActionLabels { + iconLabel.sizeToFit() + descriptionLabel.bounds.size = descriptionLabel.sizeThatFits(descriptionSizeToFit) + + let yPosition = previousDescriptionLabel.frame.maxY + Metrics.verticalSpacing + + iconLabel.frame.origin = .init(x: Metrics.contentIconInset, y: yPosition) + descriptionLabel.frame.origin = .init(x: descriptionXPosition, y: yPosition) + } + } + + // MARK: - Private Types + + private enum Metrics { + + static let verticalSpacing: CGFloat = 4 + static let contentIconInset: CGFloat = 4 + static let iconToDescriptionSpacing: CGFloat = 4 + + static let font: UIFont = .systemFont(ofSize: 12) + static let boldFont: UIFont = .boldSystemFont(ofSize: 12) + + } +} + // MARK: - private extension Bundle {