Skip to content

Commit 28aac80

Browse files
authored
Merge pull request #188 from cashapp/entin/hit-test-legend-2
Show legend in hit target snapshots
2 parents 3003fe7 + b9e2f25 commit 28aac80

30 files changed

+476
-310
lines changed

Example/AccessibilitySnapshot/ButtonAccessibilityTraitsViewController.swift

+1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ final class ButtonAccessibilityTraitsViewController: AccessibilityViewController
101101
button.setTitle(numberFormatter.string(from: NSNumber(value: (index + 1))), for: .normal)
102102
button.setTitleColor(.black, for: .normal)
103103
button.isAccessibilityElement = true
104+
button.accessibilityIdentifier = "button-\(index + 1)"
104105
}
105106

106107
view.accessibilityElements = buttons

Example/SnapshotTests/HitTargetTests.swift

+10-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ final class HitTargetTests: SnapshotTestCase {
2525
func testButtonHitTarget() {
2626
let buttonTraitsViewController = ButtonAccessibilityTraitsViewController()
2727
buttonTraitsViewController.view.frame = UIScreen.main.bounds
28-
SnapshotVerifyWithHitTargets(buttonTraitsViewController.view)
28+
SnapshotVerifyWithHitTargets(
29+
buttonTraitsViewController.view,
30+
maxPermissibleMissedRegionWidth: 1,
31+
maxPermissibleMissedRegionHeight: 1
32+
)
2933
}
3034

3135
@available(iOS 14, *)
@@ -37,7 +41,11 @@ final class HitTargetTests: SnapshotTestCase {
3741

3842
let viewController = TableViewController()
3943
viewController.view.frame = UIScreen.main.bounds
40-
SnapshotVerifyWithHitTargets(viewController.view)
44+
SnapshotVerifyWithHitTargets(
45+
viewController.view,
46+
maxPermissibleMissedRegionWidth: 1,
47+
maxPermissibleMissedRegionHeight: 1
48+
)
4149
}
4250

4351
func testPerformance() throws {

Example/SnapshotTests/ObjectiveCTests.m

+12
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,16 @@ - (void)testViewWithInvertedColors;
140140
SnapshotVerifyWithInvertedColors(view, nil);
141141
}
142142

143+
- (void)testHitTargets;
144+
{
145+
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200, 100)];
146+
[view setBackgroundColor:[UIColor whiteColor]];
147+
148+
UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(50, 25, 100, 50)];
149+
[button setBackgroundColor:[UIColor redColor]];
150+
[view addSubview:button];
151+
152+
SnapshotVerifyWithHitTargets(view, nil, YES, 1, 1);
153+
}
154+
143155
@end
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
//
2+
// Copyright 2024 Block Inc.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
import CoreImage
18+
import UIKit
19+
20+
public enum HitTargetSnapshotUtility {
21+
22+
/// Generates an image of the provided `view` with hit target regions highlighted.
23+
///
24+
/// The hit target regions are highlighted using the following rules:
25+
///
26+
/// * Regions that hit test to the base view (`view`) will not be highlighted.
27+
/// * Regions that hit test to `nil` will be darkened.
28+
/// * Regions that hit test to another view will be highlighted using one of the specified `colors`.
29+
///
30+
/// By default this snapshot is very slow (on the order of 50 seconds for a full screen snapshot) since it hit tests
31+
/// every pixel in the view to achieve a perfectly accurate result. As a performance optimization, you can trade off
32+
/// greatly increased performance for the possibility of missing very thin views by defining the maximum width and
33+
/// height of a region you are okay with missing (`maxPermissibleMissedRegion{Width,Height}`). In particular, this
34+
/// might miss hit regions of the specified width/height or less **which have the same hit target both above and
35+
/// below the region**. Note these are independent controls - a region could be missed if it falls beneath either of
36+
/// these thresholds, not both. Setting the either value alone to 1 pt improves the run time by almost (1 / scale
37+
/// factor), i.e. a 65% improvement for a 3x scale device, and setting both to 1 pt improves the run time by an
38+
/// additional (1 / scale factor), i.e. an ~88% improvement for a 3x scale device, so this trade-off is often worth
39+
/// it. Increasing the value from there will continue to decrease the run time, but you quickly get diminishing
40+
/// 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
41+
/// number of snapshots.
42+
///
43+
/// - parameter view: The base view to be tested against.
44+
/// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a
45+
/// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to
46+
/// read certain views.
47+
/// - parameter viewRenderingMode: The rendering method to use when snapshotting the `view`.
48+
/// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order,
49+
/// repeating through the array as necessary and avoiding adjacent regions using the same color when possible.
50+
/// - parameter maxPermissibleMissedRegionWidth: The maximum width for which it is permissible to "miss" a view.
51+
/// Value must be a positive integer.
52+
/// - parameter maxPermissibleMissedRegionHeight: The maximum height for which it is permissible to "miss" a view.
53+
/// Value must be a positive integer.
54+
public static func generateSnapshotImage(
55+
for view: UIView,
56+
useMonochromeSnapshot: Bool,
57+
viewRenderingMode: AccessibilitySnapshotView.ViewRenderingMode,
58+
colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors,
59+
maxPermissibleMissedRegionWidth: CGFloat = 0,
60+
maxPermissibleMissedRegionHeight: CGFloat = 0
61+
) throws -> (snapshot: UIImage, orderedViewColorPairs: [(UIColor, UIView)]) {
62+
let colors = colors.map { $0.withAlphaComponent(0.2) }
63+
64+
let bounds = view.bounds
65+
let renderer = UIGraphicsImageRenderer(bounds: bounds)
66+
67+
let viewImage = try view.renderToImage(
68+
monochrome: useMonochromeSnapshot,
69+
viewRenderingMode: viewRenderingMode
70+
)
71+
72+
guard view.bounds.width > 0 && view.bounds.height > 0 else {
73+
throw ImageRenderingError.containedViewHasZeroSize(viewSize: view.bounds.size)
74+
}
75+
76+
var orderedViewColorPairs: [(UIColor, UIView)] = []
77+
78+
let image = renderer.image { context in
79+
viewImage.draw(in: bounds)
80+
81+
var viewToColorMap: [UIView: UIColor] = [:]
82+
let pixelWidth: CGFloat = 1 / UIScreen.main.scale
83+
84+
let maxPermissibleMissedRegionWidth = max(pixelWidth, floor(maxPermissibleMissedRegionWidth))
85+
let maxPermissibleMissedRegionHeight = max(pixelWidth, floor(maxPermissibleMissedRegionHeight))
86+
87+
func drawScanLineSegment(
88+
for hitView: UIView?,
89+
startingAtX: CGFloat,
90+
endingAtX: CGFloat,
91+
y: CGFloat,
92+
lineHeight: CGFloat
93+
) {
94+
// Only draw hit areas for views other than the base view we're testing.
95+
guard hitView !== view else {
96+
return
97+
}
98+
99+
let color: UIColor
100+
if let hitView = hitView, let existingColor = viewToColorMap[hitView] {
101+
color = existingColor
102+
} else if let hitView = hitView {
103+
// As a future enhancement, this could be smarter about checking above/left colors to make sure they
104+
// aren't the same.
105+
color = colors[viewToColorMap.count % colors.count]
106+
viewToColorMap[hitView] = color
107+
orderedViewColorPairs.append((color, hitView))
108+
} else {
109+
color = .lightGray
110+
}
111+
112+
context.cgContext.setFillColor(color.cgColor)
113+
context.cgContext.beginPath()
114+
context.cgContext.addRect(
115+
CGRect(
116+
x: startingAtX,
117+
y: y,
118+
width: (endingAtX - startingAtX),
119+
height: lineHeight
120+
)
121+
)
122+
context.cgContext.drawPath(using: .fill)
123+
}
124+
125+
let touchOffset = pixelWidth / 2
126+
127+
typealias ScanLine = [(xRange: ClosedRange<CGFloat>, view: UIView?)]
128+
129+
// In some cases striding by 1/3 can result in the `to` value being included due to a floating point rouding
130+
// error, in particular when dealing with bounds with a negative y origin. By striding to a value slightly
131+
// less than the desired stop (small enough to be less than the density of any screen in the foreseeable
132+
// future), we can avoid this rounding problem.
133+
let stopEpsilon: CGFloat = 0.0001
134+
135+
func scanLine(y: CGFloat) -> ScanLine {
136+
var scanLine: ScanLine = []
137+
var lastHit: (CGFloat, UIView?) = (
138+
bounds.minX,
139+
view.hitTest(CGPoint(x: bounds.minX + touchOffset, y: y), with: nil)
140+
)
141+
142+
func updateForHit(_ hitView: UIView?, at x: CGFloat) {
143+
if hitView == lastHit.1 {
144+
// We're still hitting the same view. Nothing to update.
145+
return
146+
147+
} else {
148+
// We've moved on to a new view, so draw the scan line for the previous view.
149+
scanLine.append(((lastHit.0...x), lastHit.1))
150+
lastHit = (x, hitView)
151+
152+
}
153+
}
154+
155+
// Step through every pixel along the X axis.
156+
for x in stride(from: bounds.minX, to: bounds.maxX, by: maxPermissibleMissedRegionWidth) {
157+
let hitView = view.hitTest(CGPoint(x: x + touchOffset, y: y), with: nil)
158+
159+
if hitView == lastHit.1 {
160+
// We're still hitting the same view. Keep scanning.
161+
continue
162+
163+
} else {
164+
// The last iteration of the loop hit test at (x - maxPermissibleMissedRegionWidth), so we want
165+
// to start one pixel in front of that.
166+
let startX = x - maxPermissibleMissedRegionWidth + pixelWidth
167+
168+
for stepX in stride(from: startX, through: x, by: pixelWidth) {
169+
let stepHitView = view.hitTest(CGPoint(x: stepX + touchOffset, y: y), with: nil)
170+
updateForHit(stepHitView, at: stepX)
171+
}
172+
}
173+
}
174+
175+
// Finish the scan line if necessary.
176+
if lastHit.0 != bounds.maxX {
177+
scanLine.append(((lastHit.0...bounds.maxX), lastHit.1))
178+
}
179+
180+
return scanLine
181+
}
182+
183+
func drawScanLine(_ scanLine: ScanLine, y: CGFloat, lineHeight: CGFloat) {
184+
for segment in scanLine {
185+
drawScanLineSegment(
186+
for: segment.view,
187+
startingAtX: segment.xRange.lowerBound,
188+
endingAtX: segment.xRange.upperBound,
189+
y: y,
190+
lineHeight: lineHeight
191+
)
192+
}
193+
}
194+
195+
func scanLinesEqual(_ a: ScanLine, _ b: ScanLine) -> Bool {
196+
return a.count == b.count
197+
&& zip(a, b).allSatisfy { aSegment, bSegment in
198+
aSegment.xRange == bSegment.xRange && aSegment.view === bSegment.view
199+
}
200+
}
201+
202+
// Step through every full point along the Y axis and check if it's equal to the above line. If so, draw the
203+
// line at a full point width. If not, step through the pixel lines and draw each individually.
204+
var previousScanLine: (y: CGFloat, scanLine: ScanLine)? = nil
205+
for y in stride(from: bounds.minY, to: bounds.maxY, by: maxPermissibleMissedRegionHeight) {
206+
let fullScanLine = scanLine(y: y + touchOffset)
207+
208+
if let previousScanLine = previousScanLine, scanLinesEqual(fullScanLine, previousScanLine.scanLine) {
209+
drawScanLine(
210+
previousScanLine.scanLine,
211+
y: previousScanLine.y,
212+
lineHeight: maxPermissibleMissedRegionHeight
213+
)
214+
215+
} else if let previousScanLine = previousScanLine {
216+
drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth)
217+
for lineY in stride(from: previousScanLine.y + pixelWidth, to: y - stopEpsilon, by: pixelWidth) {
218+
drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth)
219+
}
220+
221+
} else {
222+
// No-op. We'll draw this on the next iteration.
223+
}
224+
225+
previousScanLine = (y, fullScanLine)
226+
}
227+
228+
// Draw the final full scan line and any trailing pixel lines (if the bounds.height isn't divisible by the
229+
// maxPermissibleMissedRegionHeight).
230+
if let previousScanLine = previousScanLine {
231+
drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth)
232+
233+
for lineY in stride(from: previousScanLine.y + pixelWidth, to: bounds.maxY, by: pixelWidth) {
234+
drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth)
235+
}
236+
}
237+
}
238+
239+
return (image, orderedViewColorPairs)
240+
}
241+
242+
}

0 commit comments

Comments
 (0)