Skip to content

Commit

Permalink
Improve hit test performance by optimizing X axis
Browse files Browse the repository at this point in the history
  • Loading branch information
NickEntin committed Jan 4, 2024
1 parent bfe034f commit a5f632b
Show file tree
Hide file tree
Showing 4 changed files with 46 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
<dict>
<key>baselineAverage</key>
<real>17.200000</real>
<real>5.930000</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
Expand Down
3 changes: 2 additions & 1 deletion Example/SnapshotTests/HitTargetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ final class HitTargetTests: SnapshotTestCase {
for: buttonTraitsViewController.view,
useMonochromeSnapshot: true,
viewRenderingMode: .drawHierarchyInRect,
maxPermissibleMissedRegionHeight: 4
maxPermissibleMissedRegionWidth: 1,
maxPermissibleMissedRegionHeight: 1
)
} catch {
XCTFail("Utility should not fail to generate snapshot image")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ public final class AccessibilitySnapshotView: UIView {
/// `.renderLayerInContext` view rendering mode
case containedViewHasUnsupportedTransform(transform: CATransform3D)

case containedViewHasZeroSize(viewSize: CGSize)

}

// MARK: - Life Cycle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public enum HitTargetSnapshotUtility {
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) }
Expand All @@ -62,12 +63,17 @@ public enum HitTargetSnapshotUtility {
viewRenderingMode: viewRenderingMode
)

guard view.bounds.width > 0 && view.bounds.height > 0 else {
throw AccessibilitySnapshotView.Error.containedViewHasZeroSize(viewSize: view.bounds.size)
}

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(
Expand Down Expand Up @@ -111,32 +117,55 @@ public enum HitTargetSnapshotUtility {

typealias ScanLine = [(xRange: ClosedRange<CGFloat>, 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?)? = nil
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: pixelWidth) {
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 let lastHit = lastHit, hitView == lastHit.1 {
if hitView == lastHit.1 {
// We're still hitting the same view. Keep scanning.
continue

} else if let previousHit = lastHit {
// We've moved on to a new view, so draw the scan line for the previous view.
scanLine.append(((previousHit.0...x), previousHit.1))
lastHit = (x, hitView)

} else {
// We've started a new view's region.
lastHit = (x, hitView)
// 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 let lastHit = lastHit, let lastHitView = lastHit.1 {
scanLine.append(((lastHit.0...bounds.maxX), lastHitView))
if lastHit.0 != bounds.maxX {
scanLine.append(((lastHit.0...bounds.maxX), lastHit.1))
}

return scanLine
Expand All @@ -161,12 +190,6 @@ public enum HitTargetSnapshotUtility {
}
}

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

// 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
Expand Down

0 comments on commit a5f632b

Please sign in to comment.