|
| 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