diff --git a/Example/AccessibilitySnapshot/ButtonAccessibilityTraitsViewController.swift b/Example/AccessibilitySnapshot/ButtonAccessibilityTraitsViewController.swift index 25c98a32..c01b610b 100644 --- a/Example/AccessibilitySnapshot/ButtonAccessibilityTraitsViewController.swift +++ b/Example/AccessibilitySnapshot/ButtonAccessibilityTraitsViewController.swift @@ -101,6 +101,7 @@ final class ButtonAccessibilityTraitsViewController: AccessibilityViewController button.setTitle(numberFormatter.string(from: NSNumber(value: (index + 1))), for: .normal) button.setTitleColor(.black, for: .normal) button.isAccessibilityElement = true + button.accessibilityIdentifier = "button-\(index + 1)" } view.accessibilityElements = buttons diff --git a/Example/SnapshotTests/HitTargetTests.swift b/Example/SnapshotTests/HitTargetTests.swift index 207c7ab6..495034dd 100644 --- a/Example/SnapshotTests/HitTargetTests.swift +++ b/Example/SnapshotTests/HitTargetTests.swift @@ -25,7 +25,11 @@ final class HitTargetTests: SnapshotTestCase { func testButtonHitTarget() { let buttonTraitsViewController = ButtonAccessibilityTraitsViewController() buttonTraitsViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyWithHitTargets(buttonTraitsViewController.view) + SnapshotVerifyWithHitTargets( + buttonTraitsViewController.view, + maxPermissibleMissedRegionWidth: 1, + maxPermissibleMissedRegionHeight: 1 + ) } @available(iOS 14, *) @@ -37,7 +41,11 @@ final class HitTargetTests: SnapshotTestCase { let viewController = TableViewController() viewController.view.frame = UIScreen.main.bounds - SnapshotVerifyWithHitTargets(viewController.view) + SnapshotVerifyWithHitTargets( + viewController.view, + maxPermissibleMissedRegionWidth: 1, + maxPermissibleMissedRegionHeight: 1 + ) } func testPerformance() throws { diff --git a/Example/SnapshotTests/ObjectiveCTests.m b/Example/SnapshotTests/ObjectiveCTests.m index e7754f97..a47a83e0 100644 --- a/Example/SnapshotTests/ObjectiveCTests.m +++ b/Example/SnapshotTests/ObjectiveCTests.m @@ -140,4 +140,16 @@ - (void)testViewWithInvertedColors; SnapshotVerifyWithInvertedColors(view, nil); } +- (void)testHitTargets; +{ + UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200, 100)]; + [view setBackgroundColor:[UIColor whiteColor]]; + + UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(50, 25, 100, 50)]; + [button setBackgroundColor:[UIColor redColor]]; + [view addSubview:button]; + + SnapshotVerifyWithHitTargets(view, nil, YES, 1, 1); +} + @end diff --git a/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_13_7_375x812@3x.png b/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_13_7_375x812@3x.png new file mode 100644 index 00000000..7fc49485 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_13_7_375x812@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_14_5_390x844@3x.png new file mode 100644 index 00000000..7fc49485 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_16_4_393x852@3x.png new file mode 100644 index 00000000..f8ab9ce6 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/ObjectiveCTests/testHitTargets_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_13_7_375x812@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_13_7_375x812@3x.png index e4bd3417..f374da60 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_13_7_375x812@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_13_7_375x812@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_16_4_393x852@3x.png index 6bc4c0ad..cdd1b2de 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithHeaders_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_13_7_375x812@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_13_7_375x812@3x.png index 019b91d4..8fb9efbf 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_13_7_375x812@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_13_7_375x812@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_16_4_393x852@3x.png index c6e19945..88e3b183 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilityContainersTests/testDataTableWithUndefinedRows_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testButtonTraits_14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testButtonTraits_14_5_390x844@3x.png index 98cf7613..7854ec9b 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testButtonTraits_14_5_390x844@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testButtonTraits_14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_13_7_375x812@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_13_7_375x812@3x.png index 60f0fc19..79091334 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_13_7_375x812@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_13_7_375x812@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_14_5_390x844@3x.png index cea6f761..8043d12e 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_14_5_390x844@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_16_4_393x852@3x.png index 5a99db81..3194ec85 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__14_5_390x844@3x.png index df227dc8..6ac4f9b2 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__14_5_390x844@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__16_4_393x852@3x.png index 182bf3e3..6bb49cc1 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_13_7_375x812@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_13_7_375x812@3x.png index b5014308..63819e75 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_13_7_375x812@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_13_7_375x812@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_14_5_390x844@3x.png index 7b284a5d..8caae987 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_14_5_390x844@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.LayoutTests/testFullScreenWithManyManyMarkers_14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.375x812-13-7-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.375x812-13-7-3x.png index 60f0fc19..79091334 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.375x812-13-7-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.375x812-13-7-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.390x844-14-5-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.390x844-14-5-3x.png index cea6f761..8043d12e 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.390x844-14-5-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.390x844-14-5-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.393x852-16-4-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.393x852-16-4-3x.png index d0e11f0e..c332fbf0 100644 Binary files a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.393x852-16-4-3x.png and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.393x852-16-4-3x.png differ diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotUtility.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotUtility.swift new file mode 100644 index 00000000..ac555782 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotUtility.swift @@ -0,0 +1,242 @@ +// +// 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 CoreImage +import UIKit + +public enum HitTargetSnapshotUtility { + + /// Generates an image of the provided `view` with hit target regions highlighted. + /// + /// The hit target regions are highlighted using the following rules: + /// + /// * Regions that hit test to the base view (`view`) will not be highlighted. + /// * Regions that hit test to `nil` will be darkened. + /// * Regions that hit test to another view will be highlighted using one of the specified `colors`. + /// + /// By default this snapshot is very slow (on the order of 50 seconds for a full screen snapshot) since it hit tests + /// every pixel in the view to achieve a perfectly accurate result. As a performance optimization, you can trade off + /// greatly increased performance for the possibility of missing very thin views by defining the maximum width and + /// height of a region you are okay with missing (`maxPermissibleMissedRegion{Width,Height}`). In particular, this + /// might miss hit regions of the specified width/height or less **which have the same hit target both above and + /// below the region**. Note these are independent controls - a region could be missed if it falls beneath either of + /// these thresholds, not both. Setting the either value alone to 1 pt improves the run time by almost (1 / scale + /// factor), i.e. a 65% improvement for a 3x scale device, and setting both to 1 pt improves the run time by an + /// additional (1 / scale factor), i.e. an ~88% improvement for a 3x scale device, so this trade-off is often worth + /// it. Increasing the value from there will continue to decrease the run time, but you quickly get diminishing + /// returns, so you likely won't ever want to go above 2-4 pt and should stick to 0 or 1 pt unless you have a large + /// number of snapshots. + /// + /// - parameter view: The base view to be tested against. + /// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a + /// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to + /// read certain views. + /// - parameter viewRenderingMode: The rendering method to use when snapshotting the `view`. + /// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order, + /// repeating through the array as necessary and avoiding adjacent regions using the same color when possible. + /// - parameter maxPermissibleMissedRegionWidth: The maximum width for which it is permissible to "miss" a view. + /// Value must be a positive integer. + /// - parameter maxPermissibleMissedRegionHeight: The maximum height for which it is permissible to "miss" a view. + /// Value must be a positive integer. + public static func generateSnapshotImage( + for view: UIView, + useMonochromeSnapshot: Bool, + viewRenderingMode: AccessibilitySnapshotView.ViewRenderingMode, + colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors, + maxPermissibleMissedRegionWidth: CGFloat = 0, + maxPermissibleMissedRegionHeight: CGFloat = 0 + ) throws -> (snapshot: UIImage, orderedViewColorPairs: [(UIColor, UIView)]) { + let colors = colors.map { $0.withAlphaComponent(0.2) } + + let bounds = view.bounds + let renderer = UIGraphicsImageRenderer(bounds: bounds) + + let viewImage = try view.renderToImage( + monochrome: useMonochromeSnapshot, + viewRenderingMode: viewRenderingMode + ) + + guard view.bounds.width > 0 && view.bounds.height > 0 else { + throw ImageRenderingError.containedViewHasZeroSize(viewSize: view.bounds.size) + } + + var orderedViewColorPairs: [(UIColor, UIView)] = [] + + let image = renderer.image { context in + viewImage.draw(in: bounds) + + var viewToColorMap: [UIView: UIColor] = [:] + let pixelWidth: CGFloat = 1 / UIScreen.main.scale + + let maxPermissibleMissedRegionWidth = max(pixelWidth, floor(maxPermissibleMissedRegionWidth)) + let maxPermissibleMissedRegionHeight = max(pixelWidth, floor(maxPermissibleMissedRegionHeight)) + + func drawScanLineSegment( + for hitView: UIView?, + startingAtX: CGFloat, + endingAtX: CGFloat, + y: CGFloat, + lineHeight: CGFloat + ) { + // Only draw hit areas for views other than the base view we're testing. + guard hitView !== view else { + return + } + + let color: UIColor + if let hitView = hitView, let existingColor = viewToColorMap[hitView] { + color = existingColor + } else if let hitView = hitView { + // As a future enhancement, this could be smarter about checking above/left colors to make sure they + // aren't the same. + color = colors[viewToColorMap.count % colors.count] + viewToColorMap[hitView] = color + orderedViewColorPairs.append((color, hitView)) + } else { + color = .lightGray + } + + context.cgContext.setFillColor(color.cgColor) + context.cgContext.beginPath() + context.cgContext.addRect( + CGRect( + x: startingAtX, + y: y, + width: (endingAtX - startingAtX), + height: lineHeight + ) + ) + context.cgContext.drawPath(using: .fill) + } + + let touchOffset = pixelWidth / 2 + + typealias ScanLine = [(xRange: ClosedRange, view: UIView?)] + + // In some cases striding by 1/3 can result in the `to` value being included due to a floating point rouding + // error, in particular when dealing with bounds with a negative y origin. By striding to a value slightly + // less than the desired stop (small enough to be less than the density of any screen in the foreseeable + // future), we can avoid this rounding problem. + let stopEpsilon: CGFloat = 0.0001 + + func scanLine(y: CGFloat) -> ScanLine { + var scanLine: ScanLine = [] + var lastHit: (CGFloat, UIView?) = ( + bounds.minX, + view.hitTest(CGPoint(x: bounds.minX + touchOffset, y: y), with: nil) + ) + + func updateForHit(_ hitView: UIView?, at x: CGFloat) { + if hitView == lastHit.1 { + // We're still hitting the same view. Nothing to update. + return + + } else { + // We've moved on to a new view, so draw the scan line for the previous view. + scanLine.append(((lastHit.0...x), lastHit.1)) + lastHit = (x, hitView) + + } + } + + // Step through every pixel along the X axis. + for x in stride(from: bounds.minX, to: bounds.maxX, by: maxPermissibleMissedRegionWidth) { + let hitView = view.hitTest(CGPoint(x: x + touchOffset, y: y), with: nil) + + if hitView == lastHit.1 { + // We're still hitting the same view. Keep scanning. + continue + + } else { + // The last iteration of the loop hit test at (x - maxPermissibleMissedRegionWidth), so we want + // to start one pixel in front of that. + let startX = x - maxPermissibleMissedRegionWidth + pixelWidth + + for stepX in stride(from: startX, through: x, by: pixelWidth) { + let stepHitView = view.hitTest(CGPoint(x: stepX + touchOffset, y: y), with: nil) + updateForHit(stepHitView, at: stepX) + } + } + } + + // Finish the scan line if necessary. + if lastHit.0 != bounds.maxX { + scanLine.append(((lastHit.0...bounds.maxX), lastHit.1)) + } + + return scanLine + } + + func drawScanLine(_ scanLine: ScanLine, y: CGFloat, lineHeight: CGFloat) { + for segment in scanLine { + drawScanLineSegment( + for: segment.view, + startingAtX: segment.xRange.lowerBound, + endingAtX: segment.xRange.upperBound, + y: y, + lineHeight: lineHeight + ) + } + } + + func scanLinesEqual(_ a: ScanLine, _ b: ScanLine) -> Bool { + return a.count == b.count + && zip(a, b).allSatisfy { aSegment, bSegment in + aSegment.xRange == bSegment.xRange && aSegment.view === bSegment.view + } + } + + // Step through every full point along the Y axis and check if it's equal to the above line. If so, draw the + // line at a full point width. If not, step through the pixel lines and draw each individually. + var previousScanLine: (y: CGFloat, scanLine: ScanLine)? = nil + for y in stride(from: bounds.minY, to: bounds.maxY, by: maxPermissibleMissedRegionHeight) { + let fullScanLine = scanLine(y: y + touchOffset) + + if let previousScanLine = previousScanLine, scanLinesEqual(fullScanLine, previousScanLine.scanLine) { + drawScanLine( + previousScanLine.scanLine, + y: previousScanLine.y, + lineHeight: maxPermissibleMissedRegionHeight + ) + + } else if let previousScanLine = previousScanLine { + drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth) + for lineY in stride(from: previousScanLine.y + pixelWidth, to: y - stopEpsilon, by: pixelWidth) { + drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth) + } + + } else { + // No-op. We'll draw this on the next iteration. + } + + previousScanLine = (y, fullScanLine) + } + + // Draw the final full scan line and any trailing pixel lines (if the bounds.height isn't divisible by the + // maxPermissibleMissedRegionHeight). + if let previousScanLine = previousScanLine { + drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth) + + for lineY in stride(from: previousScanLine.y + pixelWidth, to: bounds.maxY, by: pixelWidth) { + drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth) + } + } + } + + return (image, orderedViewColorPairs) + } + +} diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift index caee84c2..b49bee98 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift @@ -1,5 +1,5 @@ // -// Copyright 2023 Block Inc. +// 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. @@ -14,224 +14,160 @@ // limitations under the License. // -import CoreImage import UIKit -public enum HitTargetSnapshotUtility { - - /// Generates an image of the provided `view` with hit target regions highlighted. - /// - /// The hit target regions are highlighted using the following rules: - /// - /// * Regions that hit test to the base view (`view`) will not be highlighted. - /// * Regions that hit test to `nil` will be darkened. - /// * Regions that hit test to another view will be highlighted using one of the specified `colors`. - /// - /// By default this snapshot is very slow (on the order of 50 seconds for a full screen snapshot) since it hit tests - /// every pixel in the view to achieve a perfectly accurate result. As a performance optimization, you can trade off - /// greatly increased performance for the possibility of missing very thin views by defining the maximum width and - /// height of a region you are okay with missing (`maxPermissibleMissedRegion{Width,Height}`). In particular, this - /// might miss hit regions of the specified width/height or less **which have the same hit target both above and - /// below the region**. Note these are independent controls - a region could be missed if it falls beneath either of - /// these thresholds, not both. Setting the either value alone to 1 pt improves the run time by almost (1 / scale - /// factor), i.e. a 65% improvement for a 3x scale device, and setting both to 1 pt improves the run time by an - /// additional (1 / scale factor), i.e. an ~88% improvement for a 3x scale device, so this trade-off is often worth - /// it. Increasing the value from there will continue to decrease the run time, but you quickly get diminishing - /// returns, so you likely won't ever want to go above 2-4 pt and should stick to 0 or 1 pt unless you have a large - /// number of snapshots. - /// - /// - parameter view: The base view to be tested against. - /// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a - /// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to - /// read certain views. - /// - parameter viewRenderingMode: The rendering method to use when snapshotting the `view`. - /// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order, - /// repeating through the array as necessary and avoiding adjacent regions using the same color when possible. - /// - parameter maxPermissibleMissedRegionWidth: The maximum width for which it is permissible to "miss" a view. - /// Value must be a positive integer. - /// - parameter maxPermissibleMissedRegionHeight: The maximum height for which it is permissible to "miss" a view. - /// Value must be a positive integer. - public static func generateSnapshotImage( - for view: UIView, +public final class HitTargetSnapshotView: SnapshotAndLegendView { + + // MARK: - Life Cycle + + public init( + baseView: UIView, useMonochromeSnapshot: Bool, viewRenderingMode: AccessibilitySnapshotView.ViewRenderingMode, colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors, maxPermissibleMissedRegionWidth: CGFloat = 0, maxPermissibleMissedRegionHeight: CGFloat = 0 - ) throws -> UIImage { - let colors = colors.map { $0.withAlphaComponent(0.2) } + ) throws { + // Some implementations of hit testing rely on the window, so install the view in a window if needed. + let requiresWindow = (baseView.window == nil && !(baseView is UIWindow)) + if requiresWindow { + let window = UIApplication.shared.firstKeyWindow ?? UIWindow(frame: UIScreen.main.bounds) + window.addSubview(baseView) + } - let bounds = view.bounds - let renderer = UIGraphicsImageRenderer(bounds: bounds) + baseView.layoutIfNeeded() - let viewImage = try view.renderToImage( - monochrome: useMonochromeSnapshot, - viewRenderingMode: viewRenderingMode + let (snapshotImage, orderedViewColorPairs) = try HitTargetSnapshotUtility.generateSnapshotImage( + for: baseView, + useMonochromeSnapshot: useMonochromeSnapshot, + viewRenderingMode: viewRenderingMode, + colors: colors, + maxPermissibleMissedRegionWidth: maxPermissibleMissedRegionWidth, + maxPermissibleMissedRegionHeight: maxPermissibleMissedRegionHeight ) - guard view.bounds.width > 0 && view.bounds.height > 0 else { - throw ImageRenderingError.containedViewHasZeroSize(viewSize: view.bounds.size) + if requiresWindow { + baseView.removeFromSuperview() } - return renderer.image { context in - viewImage.draw(in: bounds) - - var viewToColorMap: [UIView: UIColor] = [:] - let pixelWidth: CGFloat = 1 / UIScreen.main.scale - - let maxPermissibleMissedRegionWidth = max(pixelWidth, floor(maxPermissibleMissedRegionWidth)) - let maxPermissibleMissedRegionHeight = max(pixelWidth, floor(maxPermissibleMissedRegionHeight)) - - func drawScanLineSegment( - for hitView: UIView?, - startingAtX: CGFloat, - endingAtX: CGFloat, - y: CGFloat, - lineHeight: CGFloat - ) { - // Only draw hit areas for views other than the base view we're testing. - guard hitView !== view else { - return - } - - let color: UIColor - if let hitView = hitView, let existingColor = viewToColorMap[hitView] { - color = existingColor - } else if let hitView = hitView { - // As a future enhancement, this could be smarter about checking above/left colors to make sure they - // aren't the same. - color = colors[viewToColorMap.count % colors.count] - viewToColorMap[hitView] = color - } else { - color = .lightGray - } - - context.cgContext.setFillColor(color.cgColor) - context.cgContext.beginPath() - context.cgContext.addRect( - CGRect( - x: startingAtX, - y: y, - width: (endingAtX - startingAtX), - height: lineHeight - ) - ) - context.cgContext.drawPath(using: .fill) - } + self._legendViews = orderedViewColorPairs.map { (color, hitView) in + LegendView(markerColor: color, hitView: hitView) + } - let touchOffset = pixelWidth / 2 - - typealias ScanLine = [(xRange: ClosedRange, view: UIView?)] - - // In some cases striding by 1/3 can result in the `to` value being included due to a floating point rouding - // error, in particular when dealing with bounds with a negative y origin. By striding to a value slightly - // less than the desired stop (small enough to be less than the density of any screen in the foreseeable - // future), we can avoid this rounding problem. - let stopEpsilon: CGFloat = 0.0001 - - func scanLine(y: CGFloat) -> ScanLine { - var scanLine: ScanLine = [] - var lastHit: (CGFloat, UIView?) = ( - bounds.minX, - view.hitTest(CGPoint(x: bounds.minX + touchOffset, y: y), with: nil) - ) - - func updateForHit(_ hitView: UIView?, at x: CGFloat) { - if hitView == lastHit.1 { - // We're still hitting the same view. Nothing to update. - return - - } else { - // We've moved on to a new view, so draw the scan line for the previous view. - scanLine.append(((lastHit.0...x), lastHit.1)) - lastHit = (x, hitView) - - } - } - - // Step through every pixel along the X axis. - for x in stride(from: bounds.minX, to: bounds.maxX, by: maxPermissibleMissedRegionWidth) { - let hitView = view.hitTest(CGPoint(x: x + touchOffset, y: y), with: nil) - - if hitView == lastHit.1 { - // We're still hitting the same view. Keep scanning. - continue - - } else { - // The last iteration of the loop hit test at (x - maxPermissibleMissedRegionWidth), so we want - // to start one pixel in front of that. - let startX = x - maxPermissibleMissedRegionWidth + pixelWidth - - for stepX in stride(from: startX, through: x, by: pixelWidth) { - let stepHitView = view.hitTest(CGPoint(x: stepX + touchOffset, y: y), with: nil) - updateForHit(stepHitView, at: stepX) - } - } - } - - // Finish the scan line if necessary. - if lastHit.0 != bounds.maxX { - scanLine.append(((lastHit.0...bounds.maxX), lastHit.1)) - } - - return scanLine - } + super.init(frame: .zero) - func drawScanLine(_ scanLine: ScanLine, y: CGFloat, lineHeight: CGFloat) { - for segment in scanLine { - drawScanLineSegment( - for: segment.view, - startingAtX: segment.xRange.lowerBound, - endingAtX: segment.xRange.upperBound, - y: y, - lineHeight: lineHeight - ) - } - } + snapshotView.image = snapshotImage + snapshotView.bounds.size = baseView.bounds.size - func scanLinesEqual(_ a: ScanLine, _ b: ScanLine) -> Bool { - return a.count == b.count - && zip(a, b).allSatisfy { aSegment, bSegment in - aSegment.xRange == bSegment.xRange && aSegment.view === bSegment.view - } - } + legendViews.forEach { addSubview($0) } - // Step through every full point along the Y axis and check if it's equal to the above line. If so, draw the - // line at a full point width. If not, step through the pixel lines and draw each individually. - var previousScanLine: (y: CGFloat, scanLine: ScanLine)? = nil - for y in stride(from: bounds.minY, to: bounds.maxY, by: maxPermissibleMissedRegionHeight) { - let fullScanLine = scanLine(y: y + touchOffset) - - if let previousScanLine = previousScanLine, scanLinesEqual(fullScanLine, previousScanLine.scanLine) { - drawScanLine( - previousScanLine.scanLine, - y: previousScanLine.y, - lineHeight: maxPermissibleMissedRegionHeight - ) - - } else if let previousScanLine = previousScanLine { - drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth) - for lineY in stride(from: previousScanLine.y + pixelWidth, to: y - stopEpsilon, by: pixelWidth) { - drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth) - } - - } else { - // No-op. We'll draw this on the next iteration. - } - - previousScanLine = (y, fullScanLine) - } + backgroundColor = .init(white: 0.9, alpha: 1.0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - SnapshotAndLegendView + + override var legendViews: [UIView] { + return _legendViews + } + + override var minimumLegendWidth: CGFloat { + return LegendView.Metrics.minimumWidth + } + + // MARK: - Private Properties + + private let _legendViews: [LegendView] + +} + +// MARK: - + +private extension HitTargetSnapshotView { - // Draw the final full scan line and any trailing pixel lines (if the bounds.height isn't divisible by the - // maxPermissibleMissedRegionHeight). - if let previousScanLine = previousScanLine { - drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth) + final class LegendView: UIView { - for lineY in stride(from: previousScanLine.y + pixelWidth, to: bounds.maxY, by: pixelWidth) { - drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth) - } + // MARK: - Life Cycle + + init(markerColor: UIColor, hitView: UIView) { + super.init(frame: .zero) + + markerView.backgroundColor = markerColor.withAlphaComponent(0.3) + addSubview(markerView) + + if let accessibilityIdentifier = hitView.accessibilityIdentifier, !accessibilityIdentifier.isEmpty { + descriptionLabel.text = accessibilityIdentifier + descriptionLabel.textColor = .black + descriptionLabel.font = .systemFont(ofSize: 12) + } else { + descriptionLabel.text = "<\(String(describing: type(of: hitView)))>" + descriptionLabel.textColor = .black + descriptionLabel.font = .italicSystemFont(ofSize: 12) } + descriptionLabel.numberOfLines = 0 + addSubview(descriptionLabel) } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private Properties + + private let markerView: UIView = .init() + + private let descriptionLabel: UILabel = .init() + + // MARK: - UIView + + override func layoutSubviews() { + markerView.frame = CGRect(x: 0, y: 0, width: Metrics.markerSize, height: Metrics.markerSize) + + let descriptionLayoutBounds = bounds.inset( + by: .init(top: 0, left: Metrics.markerSize + Metrics.markerToLabelSpacing, bottom: 0, right: 0) + ) + let firstLineHeight = descriptionLabel + .textRect(forBounds: descriptionLayoutBounds, limitedToNumberOfLines: 1) + .height + let labelVerticalInset = (Metrics.markerSize - firstLineHeight) / 2 + + descriptionLabel.bounds.size = descriptionLabel.sizeThatFits(descriptionLayoutBounds.size) + descriptionLabel.frame.origin = .init( + x: descriptionLayoutBounds.minX, + y: descriptionLayoutBounds.minY + labelVerticalInset + ) + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + let descriptionLayoutBounds = CGRect(origin: .zero, size: size).inset( + by: .init(top: 0, left: Metrics.markerSize + Metrics.markerToLabelSpacing, bottom: 0, right: 0) + ) + let firstLineHeight = descriptionLabel + .textRect(forBounds: descriptionLayoutBounds, limitedToNumberOfLines: 1) + .height + let labelVerticalInset = (Metrics.markerSize - firstLineHeight) / 2 + let labelSize = descriptionLabel.sizeThatFits(descriptionLayoutBounds.size) + + return CGSize(width: size.width, height: max(Metrics.markerSize, labelVerticalInset + labelSize.height)) + } + + // MARK: - Private Types + + fileprivate enum Metrics { + + static let minimumWidth: CGFloat = 240 + + static let markerSize: CGFloat = 14 + + static let markerToLabelSpacing: CGFloat = 16 + + } + } } diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/SnapshotAndLegendView.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/SnapshotAndLegendView.swift index 68100e0c..93c7fed8 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Classes/SnapshotAndLegendView.swift +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/SnapshotAndLegendView.swift @@ -77,7 +77,7 @@ public class SnapshotAndLegendView: UIView { y: Metrics.legendInsets.top ) - let maxYBoundary = bounds.minY + availableLegendHeight + let maxYBoundary = bounds.minY + Metrics.legendInsets.top + availableLegendHeight for legendView in legendViews { legendView.bounds.size = legendView.sizeThatFits( diff --git a/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift b/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift index 642dbd93..d65c1d9e 100644 --- a/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift +++ b/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift @@ -1,5 +1,5 @@ // -// Copyright 2020 Square Inc. +// 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. @@ -181,54 +181,41 @@ extension Snapshotting where Value == UIView, Format == UIImage { file: StaticString = #file, line: UInt = #line ) -> Snapshotting { - return Snapshotting.image.pullback { view in - // Some implementations of hit testing rely on the window, so install the view in a window if needed. - let requiresWindow = (view.window == nil && !(view is UIWindow)) - if requiresWindow { - let window = UIApplication.shared.firstKeyWindow ?? UIWindow(frame: UIScreen.main.bounds) - window.addSubview(view) - } - - view.layoutIfNeeded() - - do { - let image = try HitTargetSnapshotUtility.generateSnapshotImage( - for: view, - useMonochromeSnapshot: useMonochromeSnapshot, - viewRenderingMode: (drawHierarchyInKeyWindow ? .drawHierarchyInRect : .renderLayerInContext), - colors: colors, - maxPermissibleMissedRegionWidth: maxPermissibleMissedRegionWidth, - maxPermissibleMissedRegionHeight: maxPermissibleMissedRegionHeight - ) - - if requiresWindow { - view.removeFromSuperview() + return Snapshotting + .image(drawHierarchyInKeyWindow: drawHierarchyInKeyWindow) + .pullback { view in + do { + return try HitTargetSnapshotView( + baseView: view, + useMonochromeSnapshot: useMonochromeSnapshot, + viewRenderingMode: drawHierarchyInKeyWindow ? .drawHierarchyInRect : .renderLayerInContext, + colors: colors, + maxPermissibleMissedRegionWidth: maxPermissibleMissedRegionWidth, + maxPermissibleMissedRegionHeight: maxPermissibleMissedRegionHeight + ) + } catch ImageRenderingError.containedViewExceedsMaximumSize { + fatalError( + """ + View is too large to render monochrome snapshot. Try setting useMonochromeSnapshot to false or \ + use a different iOS version. In particular, this is known to fail on iOS 13, but was fixed in \ + iOS 14. + """, + file: file, + line: line + ) + } catch ImageRenderingError.containedViewHasUnsupportedTransform { + fatalError( + """ + View has an unsupported transform for the specified snapshot parameters. Try using an identity \ + transform or changing the view rendering mode to render the layer in the graphics context. + """, + file: file, + line: line + ) + } catch { + fatalError("Failed to render snapshot image", file: file, line: line) } - - return image - } catch ImageRenderingError.containedViewExceedsMaximumSize { - fatalError( - """ - View is too large to render monochrome snapshot. Try setting useMonochromeSnapshot to false or \ - use a different iOS version. In particular, this is known to fail on iOS 13, but was fixed in \ - iOS 14. - """, - file: file, - line: line - ) - } catch ImageRenderingError.containedViewHasUnsupportedTransform { - fatalError( - """ - View has an unsupported transform for the specified snapshot parameters. Try using an identity \ - transform or changing the view rendering mode to render the layer in the graphics context. - """, - file: file, - line: line - ) - } catch { - fatalError("Failed to render snapshot image", file: file, line: line) } - } } // MARK: - Internal Properties diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h index dba92c63..25e6ef7e 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h @@ -1,5 +1,5 @@ // -// Copyright 2023 Block Inc. +// 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. @@ -70,7 +70,7 @@ _Pragma("clang diagnostic pop")\ typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat, CGFloat, CGFloat);\ SnapshotMethod snapshotVerifyWithHitTargets = (SnapshotMethod)[self methodForSelector:selector];\ - NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, maxPermissibleMissedRegionWidth__, maxPermissibleMissedRegionHeight__, 0, 0);\ + NSString *errorDescription = snapshotVerifyWithHitTargets(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, maxPermissibleMissedRegionWidth__, maxPermissibleMissedRegionHeight__, 0, 0);\ if (errorDescription == nil) {\ XCTAssertTrue(YES);\ } else {\ diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h index 39745b59..19dbcca0 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h @@ -1,5 +1,5 @@ // -// Copyright 2023 Block Inc. +// 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. @@ -70,7 +70,7 @@ _Pragma("clang diagnostic pop")\ typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat, CGFloat, CGFloat);\ SnapshotMethod snapshotVerifyWithHitTargets = (SnapshotMethod)[self methodForSelector:selector];\ - NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, maxPermissibleMissedRegionWidth__, maxPermissibleMissedRegionHeight__, perPixelTolerance__, overallTolerance__);\ + NSString *errorDescription = snapshotVerifyWithHitTargets(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, maxPermissibleMissedRegionWidth__, maxPermissibleMissedRegionHeight__, perPixelTolerance__, overallTolerance__);\ if (errorDescription == nil) {\ XCTAssertTrue(YES);\ } else {\ diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift index 6b55c169..b2bd8d44 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift @@ -1,5 +1,5 @@ // -// Copyright 2023 Block Inc. +// 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. @@ -235,42 +235,30 @@ extension FBSnapshotTestCase { file: StaticString = #file, line: UInt = #line ) { - // Some implementations of hit testing rely on the window, so install the view in a window if needed. - let requiresWindow = (view.window == nil && !(view is UIWindow)) - if requiresWindow { - let window = UIApplication.shared.firstKeyWindow ?? UIWindow(frame: UIScreen.main.bounds) - window.addSubview(view) - } - - view.layoutIfNeeded() - - let image: UIImage do { - image = try HitTargetSnapshotUtility.generateSnapshotImage( - for: view, - useMonochromeSnapshot: useMonochromeSnapshot, + let containerView = try HitTargetSnapshotView( + baseView: view, + useMonochromeSnapshot: true, viewRenderingMode: (usesDrawViewHierarchyInRect ? .drawHierarchyInRect : .renderLayerInContext), colors: colors, maxPermissibleMissedRegionWidth: maxPermissibleMissedRegionWidth, maxPermissibleMissedRegionHeight: maxPermissibleMissedRegionHeight ) - } catch { - XCTFail(ErrorMessageFactory.errorMessageForAccessibilityParsingError(error), file: file, line: line) - return - } - FBSnapshotVerifyView( - UIImageView(image: image), - identifier: identifier, - suffixes: suffixes, - perPixelTolerance: perPixelTolerance, - overallTolerance: overallTolerance, - file: file, - line: line - ) + containerView.sizeToFit() - if requiresWindow { - view.removeFromSuperview() + FBSnapshotVerifyView( + containerView, + identifier: identifier, + suffixes: suffixes, + perPixelTolerance: perPixelTolerance, + overallTolerance: overallTolerance, + file: file, + line: line + ) + + } catch { + XCTFail(ErrorMessageFactory.errorMessageForAccessibilityParsingError(error), file: file, line: line) } } diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift index a143f295..d2e289f1 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift @@ -1,5 +1,5 @@ // -// Copyright 2023 Block Inc. +// 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. @@ -171,43 +171,31 @@ extension FBSnapshotTestCase { perPixelTolerance: CGFloat, overallTolerance: CGFloat ) -> String? { - // Some implementations of hit testing rely on the window, so install the view in a window if needed. - let requiresWindow = (view.window == nil && !(view is UIWindow)) - if requiresWindow { - let window = UIApplication.shared.firstKeyWindow ?? UIWindow(frame: UIScreen.main.bounds) - window.addSubview(view) - } - - view.layoutIfNeeded() - - let image: UIImage do { - image = try HitTargetSnapshotUtility.generateSnapshotImage( - for: view, + let containerView = try HitTargetSnapshotView( + baseView: view, useMonochromeSnapshot: useMonochromeSnapshot, viewRenderingMode: (usesDrawViewHierarchyInRect ? .drawHierarchyInRect : .renderLayerInContext), + colors: AccessibilitySnapshotView.defaultMarkerColors, maxPermissibleMissedRegionWidth: maxPermissibleMissedRegionWidth, maxPermissibleMissedRegionHeight: maxPermissibleMissedRegionHeight ) - } catch { - return ErrorMessageFactory.errorMessageForAccessibilityParsingError(error) - } - let errorDescription = snapshotVerifyViewOrLayer( - UIImageView(image: image), - identifier: identifier, - suffixes: FBSnapshotTestCaseDefaultSuffixes(), - perPixelTolerance: perPixelTolerance, - overallTolerance: overallTolerance, - defaultReferenceDirectory: FB_REFERENCE_IMAGE_DIR, - defaultImageDiffDirectory: IMAGE_DIFF_DIR - ) + containerView.sizeToFit() - if requiresWindow { - view.removeFromSuperview() - } + return snapshotVerifyViewOrLayer( + containerView, + identifier: identifier, + suffixes: FBSnapshotTestCaseDefaultSuffixes(), + perPixelTolerance: perPixelTolerance, + overallTolerance: overallTolerance, + defaultReferenceDirectory: FB_REFERENCE_IMAGE_DIR, + defaultImageDiffDirectory: IMAGE_DIFF_DIR + ) - return errorDescription + } catch { + return ErrorMessageFactory.errorMessageForAccessibilityParsingError(error) + } } } diff --git a/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj b/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj index aeaae7d5..bf72bce7 100644 --- a/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj +++ b/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 3D29F3EE2B59FB0D0053E048 /* HitTargetSnapshotUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D29F3ED2B59FB0D0053E048 /* HitTargetSnapshotUtility.swift */; }; 3D63420F2B589032003FB4D3 /* SnapshotAndLegendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63420E2B589032003FB4D3 /* SnapshotAndLegendView.swift */; }; 3D9D41882B355E970033460C /* HitTargetSnapshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9D41862B355E970033460C /* HitTargetSnapshotView.swift */; }; 3D9D41892B355E970033460C /* UIView+ImageRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9D41872B355E970033460C /* UIView+ImageRendering.swift */; }; @@ -37,6 +38,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3D29F3ED2B59FB0D0053E048 /* HitTargetSnapshotUtility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HitTargetSnapshotUtility.swift; sourceTree = ""; }; 3D63420E2B589032003FB4D3 /* SnapshotAndLegendView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnapshotAndLegendView.swift; sourceTree = ""; }; 3D9D41862B355E970033460C /* HitTargetSnapshotView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HitTargetSnapshotView.swift; sourceTree = ""; }; 3D9D41872B355E970033460C /* UIView+ImageRendering.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+ImageRendering.swift"; sourceTree = ""; }; @@ -108,6 +110,7 @@ EF4D51D42A55A4A400FF0E46 /* Classes */ = { isa = PBXGroup; children = ( + 3D29F3ED2B59FB0D0053E048 /* HitTargetSnapshotUtility.swift */, 3D63420E2B589032003FB4D3 /* SnapshotAndLegendView.swift */, 3D9D41862B355E970033460C /* HitTargetSnapshotView.swift */, 3D9D41872B355E970033460C /* UIView+ImageRendering.swift */, @@ -294,6 +297,7 @@ EF4D51FD2A55A68500FF0E46 /* String+Localization.swift in Sources */, 8342DC102A8E441B00810258 /* AccessibilitySnapshotView+PillsView.swift in Sources */, 3D9D41882B355E970033460C /* HitTargetSnapshotView.swift in Sources */, + 3D29F3EE2B59FB0D0053E048 /* HitTargetSnapshotUtility.swift in Sources */, EF4D52002A55A68500FF0E46 /* UIApplication+FirstKeyWindow.swift in Sources */, EF4D52042A55A69A00FF0E46 /* ASAccessibilityEnabler.m in Sources */, EF4D52022A55A68500FF0E46 /* AccessibilitySnapshotView.swift in Sources */,