diff --git a/Example/SnapshotTests/ElementOrderTests.swift b/Example/SnapshotTests/ElementOrderTests.swift index b15fc2f1..1e8096d2 100644 --- a/Example/SnapshotTests/ElementOrderTests.swift +++ b/Example/SnapshotTests/ElementOrderTests.swift @@ -24,43 +24,50 @@ final class ElementOrderTests: SnapshotTestCase { func testScatter() { let elementOrderViewController = ElementOrderViewController(configurations: .scatter) elementOrderViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyAccessibility(elementOrderViewController.view) + let configuration = AccessibilitySnapshotConfiguration(viewRenderingMode: viewRenderingMode, includesElementOrder: .always) + SnapshotVerifyAccessibility(elementOrderViewController.view, snapshotConfiguration: configuration) } func testGrid() { let elementOrderViewController = ElementOrderViewController(configurations: .grid) elementOrderViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyAccessibility(elementOrderViewController.view) + let configuration = AccessibilitySnapshotConfiguration(viewRenderingMode: viewRenderingMode, includesElementOrder: .always) + SnapshotVerifyAccessibility(elementOrderViewController.view, snapshotConfiguration: configuration) } func testContainerInElementStack() { let elementOrderViewController = ElementOrderViewController(configurations: .containerInElementStack) elementOrderViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyAccessibility(elementOrderViewController.view) + let configuration = AccessibilitySnapshotConfiguration(viewRenderingMode: viewRenderingMode, includesElementOrder: .always) + SnapshotVerifyAccessibility(elementOrderViewController.view, snapshotConfiguration: configuration) } func testZeroSizedContainerInElementStack() { let elementOrderViewController = ElementOrderViewController(configurations: .zeroSizedContainerInElementStack) elementOrderViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyAccessibility(elementOrderViewController.view) + let configuration = AccessibilitySnapshotConfiguration(viewRenderingMode: viewRenderingMode, includesElementOrder: .always) + SnapshotVerifyAccessibility(elementOrderViewController.view, snapshotConfiguration: configuration) } func testGroupedViewsInElementStack() { let elementOrderViewController = ElementOrderViewController(configurations: .groupedViewsInElementStack) elementOrderViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyAccessibility(elementOrderViewController.view) + let configuration = AccessibilitySnapshotConfiguration(viewRenderingMode: viewRenderingMode, includesElementOrder: .always) + SnapshotVerifyAccessibility(elementOrderViewController.view, snapshotConfiguration: configuration) } func testUngroupedViewsInElementStack() { let elementOrderViewController = ElementOrderViewController(configurations: .ungroupedViewsInElementStack) elementOrderViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyAccessibility(elementOrderViewController.view) + let configuration = AccessibilitySnapshotConfiguration(viewRenderingMode: viewRenderingMode, includesElementOrder: .always) + SnapshotVerifyAccessibility(elementOrderViewController.view, snapshotConfiguration: configuration) } func testUngroupedViewsInAccessibleParent() { let elementOrderViewController = ElementOrderViewController(configurations: .ungroupedViewsInAccessibleParent) elementOrderViewController.view.frame = UIScreen.main.bounds - SnapshotVerifyAccessibility(elementOrderViewController.view) + let configuration = AccessibilitySnapshotConfiguration(viewRenderingMode: viewRenderingMode, includesElementOrder: .always) + SnapshotVerifyAccessibility(elementOrderViewController.view, snapshotConfiguration: configuration) } } diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_16_4_393x852@3x.png index c8fb97cb..5195935a 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_17_2_393x852@3x.png index c8fb97cb..5195935a 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testContainerInElementStack_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_16_4_393x852@3x.png index dac17d08..565e6b9c 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_17_2_393x852@3x.png index dac17d08..565e6b9c 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGrid_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_16_4_393x852@3x.png index 780f1ef4..7c8ebcff 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_17_2_393x852@3x.png index 780f1ef4..7c8ebcff 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testGroupedViewsInElementStack_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_16_4_393x852@3x.png index fb68ac25..7f7bf959 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_17_2_393x852@3x.png index fb68ac25..7f7bf959 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testScatter_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_16_4_393x852@3x.png index f9b967e8..8262cac0 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_17_2_393x852@3x.png index f9b967e8..8262cac0 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInAccessibleParent_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_16_4_393x852@3x.png index aea8d696..fff90b7c 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_17_2_393x852@3x.png index aea8d696..fff90b7c 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testUngroupedViewsInElementStack_17_2_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_16_4_393x852@3x.png index 21d3449f..d98b287d 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_16_4_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_17_2_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_17_2_393x852@3x.png index 21d3449f..d98b287d 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_17_2_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.ElementOrderTests/testZeroSizedContainerInElementStack_17_2_393x852@3x.png differ diff --git a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift index da0bcd0e..e0dc8cd0 100644 --- a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift +++ b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift @@ -36,11 +36,19 @@ public struct AccessibilitySnapshotConfiguration { /// Defaults to `.whenOverridden`. public let activationPointDisplay: AccessibilityContentDisplayMode + + /// Controls when to show indicators for elements' accessibility order. + /// Defaults to `.never`. + public let elementOrderDisplay: AccessibilityContentDisplayMode + + init(colors: [UIColor] = MarkerColors.defaultColors, - activationPointDisplay: AccessibilityContentDisplayMode = .whenOverridden + activationPointDisplay: AccessibilityContentDisplayMode = .whenOverridden, + elementOrderDisplay: AccessibilityContentDisplayMode = .never ) { self.colors = colors.isEmpty ? MarkerColors.defaultColors : colors self.activationPointDisplay = activationPointDisplay + self.elementOrderDisplay = elementOrderDisplay } } @@ -71,11 +79,16 @@ public struct AccessibilitySnapshotConfiguration { colorRenderingMode: ColorRenderingMode = .monochrome, overlayColors: [UIColor] = MarkerColors.defaultColors, activationPointDisplay: AccessibilityContentDisplayMode = .whenOverridden, - includesInputLabels: AccessibilityContentDisplayMode = .whenOverridden + includesInputLabels: AccessibilityContentDisplayMode = .whenOverridden, + includesElementOrder: AccessibilityContentDisplayMode = .never ) { self.snapshot = Snapshot(viewRenderingMode:viewRenderingMode, colorMode: colorRenderingMode) - self.overlay = Overlay(colors: overlayColors.isEmpty ? MarkerColors.defaultColors : overlayColors, activationPointDisplay: activationPointDisplay) + + self.overlay = Overlay(colors: overlayColors.isEmpty ? MarkerColors.defaultColors : overlayColors, + activationPointDisplay: activationPointDisplay, + elementOrderDisplay: includesElementOrder) + self.legend = Legend(includesUserInputLabels: includesInputLabels) } } diff --git a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift index 9146c971..c63eb129 100644 --- a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift +++ b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift @@ -31,7 +31,7 @@ import AccessibilitySnapshotParser /// calculated properly, the view must already be in the view hierarchy. public final class AccessibilitySnapshotView: SnapshotAndLegendView { - /// The configuration struct for snapshot rendering. + // The configuration struct for snapshot rendering. public let snapshotConfiguration: AccessibilitySnapshotConfiguration // MARK: - Life Cycle @@ -49,6 +49,7 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { @available(*, deprecated, message:"Please use `init(containedView:snapshotConfiguration:)` instead.") + public convenience init( containedView: UIView, viewRenderingMode: ViewRenderingMode, @@ -69,6 +70,7 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { /// /// - parameter containedView: The view that should be snapshotted, and for which the accessibility markers should /// be generated. + /// - parameter viewRenderingMode: The method to use when snapshotting the `containedView`. /// - parameter snapshotConfiguration: The configuration for the visual effects and markers applied to the snapshots. public init( containedView: UIView, @@ -94,7 +96,7 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { } override var minimumLegendWidth: CGFloat { - return LegendView.Metrics.minimumWidth + return ElementLegendView.Metrics.minimumWidth } // MARK: - Private Properties @@ -157,23 +159,40 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { var displayMarkers: [DisplayMarker] = [] for (index, marker) in markers.enumerated() { + let elementIndex: Int? = markers.count > 1 ? index : nil + let color = snapshotConfiguration.overlay.colors[index % snapshotConfiguration.overlay.colors.count] - let legendView = LegendView(marker: marker, color: color, configuration: snapshotConfiguration.legend) + let legendView = ElementLegendView(marker: marker, + elementIndex: elementIndex, + color: color, + configuration: snapshotConfiguration.legend) addSubview(legendView) - - let overlayView = UIView() + + let overlayView = OverlayView() snapshotView.addSubview(overlayView) + overlayView.markerView = { + if let elementIndex { + return ElementMarkerView(color: color.withAlphaComponent(0.2), index: elementIndex, style: .pill) + } + return nil + }() + + switch marker.shape { case let .frame(rect): // The `overlayView` itself is used to highlight the region. overlayView.backgroundColor = color.withAlphaComponent(0.3) overlayView.frame = rect + if let elementIndex { + overlayView.markerView = ElementMarkerView(color: color.withAlphaComponent(0.2), index: elementIndex, style: .pill) + overlayView.markerPosition = .zero + } case let .path(path): // The `overlayView` acts as a container for the highlight path. Since the `path` is already relative to - // the `snaphotView`, the `overlayView` takes up the entire size of its parent. + // the `snapshotView`, the `overlayView` takes up the entire size of its parent. overlayView.frame = snapshotView.bounds let overlayLayer = CAShapeLayer() overlayLayer.lineWidth = 4 @@ -181,6 +200,10 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { overlayLayer.fillColor = nil overlayLayer.path = path.cgPath overlayView.layer.addSublayer(overlayLayer) + if let elementIndex { + overlayView.markerView = ElementMarkerView(color: color.withAlphaComponent(0.2), index: elementIndex, style: .pill) + overlayView.markerPosition = overlayLayer.topLeadingPointOnPath(layoutDirection: .leftToRight) ?? .zero + } } var displayMarker = DisplayMarker( @@ -225,9 +248,9 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { var marker: AccessibilityMarker - var legendView: LegendView + var legendView: ElementLegendView - var overlayView: UIView + var overlayView: OverlayView var activationPointView: UIView? @@ -235,6 +258,30 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { } +internal extension AccessibilitySnapshotView { + final class OverlayView : UIView { + var markerPosition: CGPoint = .zero { + didSet { + guard let markerView else { return } + markerView.sizeToFit() + let origin = markerPosition + markerView.frame = CGRect(origin: origin, size: markerView.frame.size) + } + } + + var markerView: ElementMarkerView? { + willSet { + markerView?.removeFromSuperview() + } + didSet { + if let markerView { + addSubview(markerView) + } + } + } + } +} + private extension UIView { func superviewWithSubviewIndex() -> (UIView, Int)? { @@ -250,3 +297,154 @@ private extension UIView { } } + + +public extension CAShapeLayer { + + /// Returns the closest point *on this layer’s path* to the **top-leading** corner + /// of the path’s bounding box. + /// + /// The result is expressed in `targetLayer` coordinates (defaults to `superlayer`), + /// so you can position sibling layers or UI elements accurately even when this + /// shape layer is transformed (position, bounds, `transform`, `sublayerTransform`, etc.). + /// + /// - Parameters: + /// - layoutDirection: `.leftToRight` (top-left) or `.rightToLeft` (top-right). + /// - curveSteps: Sampling resolution per curve segment (higher = more precise). + /// Defaults to `32`; consider `64–128` for very tight curves. + /// - targetLayer: Coordinate space of the returned point. Default is `superlayer`. + /// - Returns: The nearest point on the rendered path, in `targetLayer` coordinates, or `nil` if no path. + @inlinable + func topLeadingPointOnPath( + layoutDirection: UIUserInterfaceLayoutDirection, + curveSteps: Int = 32, + targetLayer: CALayer? = nil + ) -> CGPoint? { + guard let path = self.path else { return nil } + + let target = targetLayer ?? self.superlayer + let box = path.boundingBoxOfPath + let cornerInSelf: CGPoint = (layoutDirection == .rightToLeft) + ? CGPoint(x: box.maxX, y: box.minY) // top-right in iOS coords + : CGPoint(x: box.minX, y: box.minY) // top-left in iOS coords + + // Convert corner into target coordinates (so we measure where it actually renders). + let cornerInTarget = target != nil ? self.convert(cornerInSelf, to: target) : cornerInSelf + + var bestPointInTarget: CGPoint? + var bestDist2 = CGFloat.greatestFiniteMagnitude + + // Track previous point of current subpath to build segments for projection. + var p0 = CGPoint.zero + var haveP0 = false + + // Project corner onto segment AB (in self coords), convert the projection to target space, + // and keep the closest. + @inline(__always) + func considerSegment(_ a: CGPoint, _ b: CGPoint) { + let ab = CGPoint(x: b.x - a.x, y: b.y - a.y) + let ap = CGPoint(x: cornerInSelf.x - a.x, y: cornerInSelf.y - a.y) + let abLen2 = ab.x*ab.x + ab.y*ab.y + let t = abLen2 > 0 ? max(0, min(1, (ap.x*ab.x + ap.y*ab.y) / abLen2)) : 0 + let qSelf = CGPoint(x: a.x + t*ab.x, y: a.y + t*ab.y) + + let qTarget = target != nil ? self.convert(qSelf, to: target) : qSelf + let dx = qTarget.x - cornerInTarget.x, dy = qTarget.y - cornerInTarget.y + let d2 = dx*dx + dy*dy + if d2 < bestDist2 { bestDist2 = d2; bestPointInTarget = qTarget } + } + + @inline(__always) + func quadPoint(_ p0: CGPoint, _ c: CGPoint, _ p1: CGPoint, _ t: CGFloat) -> CGPoint { + let mt = 1 - t + return CGPoint( + x: mt*mt*p0.x + 2*mt*t*c.x + t*t*p1.x, + y: mt*mt*p0.y + 2*mt*t*c.y + t*t*p1.y + ) + } + + @inline(__always) + func cubicPoint(_ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint, _ t: CGFloat) -> CGPoint { + let mt = 1 - t, mt2 = mt*mt, t2 = t*t + return CGPoint( + x: mt2*mt*p0.x + 3*mt2*t*c1.x + 3*mt*t2*c2.x + t*t2*p1.x, + y: mt2*mt*p0.y + 3*mt2*t*c1.y + 3*mt*t2*c2.y + t*t2*p1.y + ) + } + + path.applyWithBlock { el in + let type = el.pointee.type + let pts = el.pointee.points + + switch type { + case .moveToPoint: + p0 = pts[0]; haveP0 = true + // Consider isolated points too (degenerate segment). + considerSegment(p0, p0) + + case .addLineToPoint: + if haveP0 { + let p1 = pts[0] + considerSegment(p0, p1) + p0 = p1 + } + + case .addQuadCurveToPoint: + if haveP0 { + let c = pts[0], p1 = pts[1] + var prev = p0 + let steps = max(1, curveSteps) + // Light sampling along the curve; each small chord is projected. + for i in 1...steps { + let t = CGFloat(i) / CGFloat(steps) + let pt = quadPoint(p0, c, p1, t) + considerSegment(prev, pt) + prev = pt + } + p0 = p1 + } + + case .addCurveToPoint: + if haveP0 { + let c1 = pts[0], c2 = pts[1], p1 = pts[2] + var prev = p0 + let steps = max(1, curveSteps) + for i in 1...steps { + let t = CGFloat(i) / CGFloat(steps) + let pt = cubicPoint(p0, c1, c2, p1, t) + considerSegment(prev, pt) + prev = pt + } + p0 = p1 + } + + case .closeSubpath: + break + + @unknown default: + break + } + } + + return bestPointInTarget + } + + /// Convenience: infers layout direction from a `UIView`’s `semanticContentAttribute`, + /// and returns the point in that view’s **layer** coordinate space. + /// + /// - Parameters: + /// - viewForLayoutDirection: The view whose semantic content attribute determines LTR/RTL. + /// - targetLayer: Optional custom layer space for the result. Defaults to `viewForLayoutDirection.layer`. + /// - curveSteps: Sampling resolution per curve segment. + /// - Returns: The nearest point on the rendered path, in `targetLayer` coordinates. + @inlinable + func topLeadingPointOnPath( + viewForLayoutDirection view: UIView, + targetLayer: CALayer? = nil, + curveSteps: Int = 32 + ) -> CGPoint? { + let dir = UIView.userInterfaceLayoutDirection(for: view.semanticContentAttribute) + let space = targetLayer ?? view.layer + return topLeadingPointOnPath(layoutDirection: dir, curveSteps: curveSteps, targetLayer: space) + } +} diff --git a/Sources/AccessibilitySnapshot/Core/LegendView.swift b/Sources/AccessibilitySnapshot/Core/LegendView.swift index 75fd3a0b..96ef36b2 100644 --- a/Sources/AccessibilitySnapshot/Core/LegendView.swift +++ b/Sources/AccessibilitySnapshot/Core/LegendView.swift @@ -4,11 +4,74 @@ import AccessibilitySnapshotParser #endif internal extension AccessibilitySnapshotView { - final class LegendView: UIView { + + final class ElementMarkerView: UIView { + + enum Style { + case pill, box + } + + private enum Metrics { + static let ElementIndexFont = UIFont.systemFont(ofSize: 12) + } + + private let indexLabel = UILabel() + private let style: Style + + + init(frame: CGRect = .zero, color: UIColor, index: Int?, style: Style = .box) { + self.style = style + super.init(frame: frame) + indexLabel.numberOfLines = 1 + indexLabel.text = index.map(String.init) ?? nil + indexLabel.font = Metrics.ElementIndexFont + addSubview(indexLabel) + backgroundColor = color + + switch style { + case .pill: + indexLabel.textColor = .lightText + layer.borderColor = UIColor.lightText.cgColor + layer.borderWidth = 1 + layer.shadowOffset = CGSize(width: 1, height: 1) + layer.shadowRadius = 2 + case .box: + indexLabel.textColor = .darkText + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + let labelSize = indexLabel.sizeThatFits(size) + if labelSize.width > labelSize.height { + return labelSize.applying(.init(scaleX: 1.1, y: 1.1)) + } + return CGSize(width: labelSize.height, height: labelSize.height).applying(.init(scaleX: 1.1, y: 1.1)) + } + + override func layoutSubviews() { + super.layoutSubviews() + + indexLabel.sizeToFit() + indexLabel.frame = CGRect(x: (bounds.size.width - indexLabel.frame.width) / 2, + y: (bounds.size.height - indexLabel.frame.height) / 2, + width: indexLabel.frame.width, + height: indexLabel.frame.height) + + + layer.cornerRadius = style == .pill ? indexLabel.frame.height / 2 : 0.0 + } + } + + + final class ElementLegendView: UIView { // MARK: - Life Cycle - init(marker: AccessibilityMarker, color: UIColor, configuration: AccessibilitySnapshotConfiguration.Legend) { + init(marker: AccessibilityMarker, elementIndex: Int?, color: UIColor, configuration: AccessibilitySnapshotConfiguration.Legend) { self.hintLabel = marker.hint.map { let label = UILabel() label.text = $0 @@ -90,9 +153,10 @@ internal extension AccessibilitySnapshotView { return .init(titles: userInputLabels, color: color) }() + markerView = ElementMarkerView(color: color.withAlphaComponent(0.3), index: elementIndex) + super.init(frame: .zero) - markerView.backgroundColor = color.withAlphaComponent(0.3) addSubview(markerView) descriptionLabel.text = @@ -120,7 +184,7 @@ internal extension AccessibilitySnapshotView { // MARK: - Private Properties - private let markerView: UIView = .init() + private let markerView: ElementMarkerView private let descriptionLabel: UILabel = .init()