@@ -27,18 +27,30 @@ public enum HitTargetSnapshotUtility {
27
27
/// * Regions that hit test to `nil` will be darkened.
28
28
/// * Regions that hit test to another view will be highlighted using one of the specified `colors`.
29
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 height of
33
+ /// a missed region you are okay with missing (`maxPermissibleMissedRegionHeight`). In particular, this might miss
34
+ /// views of the specified height or less which have the same hit target both above and below the view. Setting this
35
+ /// value to 1 pt improves the run time by almost (1 / scale factor), i.e. a 65% improvement for a 3x scale device,
36
+ /// so this trade-off is often worth it. Increasing the value from there will continue to decrease the run time, but
37
+ /// you quickly get diminishing returns.
38
+ ///
30
39
/// - parameter view: The base view to be tested against.
31
40
/// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a
32
41
/// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to
33
42
/// read certain views.
34
43
/// - parameter viewRenderingMode: The rendering method to use when snapshotting the `view`.
35
44
/// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order,
36
45
/// repeating through the array as necessary and avoiding adjacent regions using the same color when possible.
46
+ /// - parameter maxPermissibleMissedRegionHeight: The maximum height for which it is permissible to "miss" a view.
47
+ /// Value must be a positive integer.
37
48
public static func generateSnapshotImage(
38
49
for view: UIView ,
39
50
useMonochromeSnapshot: Bool ,
40
51
viewRenderingMode: AccessibilitySnapshotView . ViewRenderingMode ,
41
- colors: [ UIColor ] = AccessibilitySnapshotView . defaultMarkerColors
52
+ colors: [ UIColor ] = AccessibilitySnapshotView . defaultMarkerColors,
53
+ maxPermissibleMissedRegionHeight: CGFloat = 0
42
54
) throws -> UIImage {
43
55
let colors = colors. map { $0. withAlphaComponent ( 0.2 ) }
44
56
@@ -56,7 +68,9 @@ public enum HitTargetSnapshotUtility {
56
68
var viewToColorMap : [ UIView : UIColor ] = [ : ]
57
69
let pixelWidth : CGFloat = 1 / UIScreen. main. scale
58
70
59
- func drawScanLine(
71
+ let maxPermissibleMissedRegionHeight = max ( pixelWidth, floor ( maxPermissibleMissedRegionHeight) )
72
+
73
+ func drawScanLineSegment(
60
74
for hitView: UIView ? ,
61
75
startingAtX: CGFloat ,
62
76
endingAtX: CGFloat ,
@@ -95,27 +109,23 @@ public enum HitTargetSnapshotUtility {
95
109
96
110
let touchOffset = pixelWidth / 2
97
111
98
- // Step through every pixel along the Y axis.
99
- for y in stride ( from: bounds. minY, to: bounds. maxY, by: pixelWidth) {
112
+ typealias ScanLine = [ ( xRange: ClosedRange < CGFloat > , view: UIView ? ) ]
113
+
114
+ func scanLine( y: CGFloat ) -> ScanLine {
115
+ var scanLine : ScanLine = [ ]
100
116
var lastHit : ( CGFloat , UIView ? ) ? = nil
101
117
102
118
// Step through every pixel along the X axis.
103
119
for x in stride ( from: bounds. minX, to: bounds. maxX, by: pixelWidth) {
104
- let hitView = view. hitTest ( CGPoint ( x: x + touchOffset, y: y + touchOffset ) , with: nil )
120
+ let hitView = view. hitTest ( CGPoint ( x: x + touchOffset, y: y) , with: nil )
105
121
106
122
if let lastHit = lastHit, hitView == lastHit. 1 {
107
123
// We're still hitting the same view. Keep scanning.
108
124
continue
109
125
110
126
} else if let previousHit = lastHit {
111
127
// We've moved on to a new view, so draw the scan line for the previous view.
112
- drawScanLine (
113
- for: previousHit. 1 ,
114
- startingAtX: previousHit. 0 ,
115
- endingAtX: x,
116
- y: y,
117
- lineHeight: pixelWidth
118
- )
128
+ scanLine. append ( ( ( previousHit. 0 ... x) , previousHit. 1 ) )
119
129
lastHit = ( x, hitView)
120
130
121
131
} else {
@@ -126,15 +136,72 @@ public enum HitTargetSnapshotUtility {
126
136
127
137
// Finish the scan line if necessary.
128
138
if let lastHit = lastHit, let lastHitView = lastHit. 1 {
129
- drawScanLine (
130
- for: lastHitView,
131
- startingAtX: lastHit. 0 ,
132
- endingAtX: bounds. maxX,
139
+ scanLine. append ( ( ( lastHit. 0 ... bounds. maxX) , lastHitView) )
140
+ }
141
+
142
+ return scanLine
143
+ }
144
+
145
+ func drawScanLine( _ scanLine: ScanLine , y: CGFloat , lineHeight: CGFloat ) {
146
+ for segment in scanLine {
147
+ drawScanLineSegment (
148
+ for: segment. view,
149
+ startingAtX: segment. xRange. lowerBound,
150
+ endingAtX: segment. xRange. upperBound,
133
151
y: y,
134
- lineHeight: pixelWidth
152
+ lineHeight: lineHeight
135
153
)
136
154
}
137
155
}
156
+
157
+ func scanLinesEqual( _ a: ScanLine , _ b: ScanLine ) -> Bool {
158
+ return a. count == b. count
159
+ && zip ( a, b) . allSatisfy { aSegment, bSegment in
160
+ aSegment. xRange == bSegment. xRange && aSegment. view === bSegment. view
161
+ }
162
+ }
163
+
164
+ // In some cases striding by 1/3 can result in the `to` value being included due to a floating point rouding
165
+ // error, in particular when dealing with bounds with a negative y origin. By striding to a value slightly
166
+ // less than the desired stop (small enough to be less than the density of any screen in the foreseeable
167
+ // future), we can avoid this rounding problem.
168
+ let stopEpsilon : CGFloat = 0.0001
169
+
170
+ // Step through every full point along the Y axis and check if it's equal to the above line. If so, draw the
171
+ // line at a full point width. If not, step through the pixel lines and draw each individually.
172
+ var previousScanLine : ( y: CGFloat , scanLine: ScanLine ) ? = nil
173
+ for y in stride ( from: bounds. minY, to: bounds. maxY, by: maxPermissibleMissedRegionHeight) {
174
+ let fullScanLine = scanLine ( y: y + touchOffset)
175
+
176
+ if let previousScanLine = previousScanLine, scanLinesEqual ( fullScanLine, previousScanLine. scanLine) {
177
+ drawScanLine (
178
+ previousScanLine. scanLine,
179
+ y: previousScanLine. y,
180
+ lineHeight: maxPermissibleMissedRegionHeight
181
+ )
182
+
183
+ } else if let previousScanLine = previousScanLine {
184
+ drawScanLine ( previousScanLine. scanLine, y: previousScanLine. y, lineHeight: pixelWidth)
185
+ for lineY in stride ( from: previousScanLine. y + pixelWidth, to: y - stopEpsilon, by: pixelWidth) {
186
+ drawScanLine ( scanLine ( y: lineY + touchOffset) , y: lineY, lineHeight: pixelWidth)
187
+ }
188
+
189
+ } else {
190
+ // No-op. We'll draw this on the next iteration.
191
+ }
192
+
193
+ previousScanLine = ( y, fullScanLine)
194
+ }
195
+
196
+ // Draw the final full scan line and any trailing pixel lines (if the bounds.height isn't divisible by the
197
+ // maxPermissibleMissedRegionHeight).
198
+ if let previousScanLine = previousScanLine {
199
+ drawScanLine ( previousScanLine. scanLine, y: previousScanLine. y, lineHeight: pixelWidth)
200
+
201
+ for lineY in stride ( from: previousScanLine. y + pixelWidth, to: bounds. maxY, by: pixelWidth) {
202
+ drawScanLine ( scanLine ( y: lineY + touchOffset) , y: lineY, lineHeight: pixelWidth)
203
+ }
204
+ }
138
205
}
139
206
}
140
207
0 commit comments