Skip to content

Commit c3a6932

Browse files
Add container visualization for accessibility snapshot legends
When showContainers is enabled in the configuration, the legend renders a hierarchical view that groups elements by their accessibility containers using dashed borders and container badges. Scope: legend-only. All element overlay rendering (ElementOverlay, positions, colors, coordinates) is completely unchanged. The snapshot image and its overlays are rendered identically regardless of showContainers — only the legend content differs. New files: - HierarchyColorAssignment: assigns color indices to hierarchy nodes (elements use traversal-order position to match overlay colors; containers use a separate counter) - ContainerLegendEntryView: dashed-border legend entry with a badge that displays the container type (Semantic Group/List/Landmark/ Data Table/Tab Bar) or its label/value/identifier - HierarchyLegendView: recursive hierarchical legend renderer - ContainerDemo: demo using SemanticGroupWrapper to create real UIKit accessibility containers from a SwiftUI demo (SwiftUI has no native equivalent for UIAccessibilityContainerType.semanticGroup) Changes: - AccessibilitySnapshotConfiguration: add showContainers flag - ParsedAccessibilityData: add hierarchy tree alongside flat markers - PreParsedAccessibilitySnapshotView: swap legend based on showContainers; snapshot+overlay section untouched - SwiftUIAccessibilitySnapshotContainerView: pass hierarchy through Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9205369 commit c3a6932

12 files changed

Lines changed: 407 additions & 20 deletions
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import AccessibilitySnapshotPreviews
2+
import SwiftUI
3+
import UIKit
4+
5+
struct ContainerDemo: View {
6+
var body: some View {
7+
VStack(alignment: .leading, spacing: 12) {
8+
DemoSection(title: "Account Info", description: "Semantic group container") {
9+
Text("Balance: $1,234.56")
10+
Text("Last updated: Today")
11+
}
12+
.semanticGroupContainer(label: "Account Info")
13+
14+
DemoSection(title: "Transactions", description: "Another semantic group") {
15+
Text("Coffee Shop - $4.50")
16+
Text("Grocery Store - $52.30")
17+
Text("Gas Station - $35.00")
18+
}
19+
.semanticGroupContainer(label: "Transactions")
20+
21+
Button("View All Transactions") {}
22+
.buttonStyle(.bordered)
23+
24+
Spacer()
25+
}
26+
.padding()
27+
}
28+
}
29+
30+
// MARK: - Semantic Group Container
31+
32+
/// A SwiftUI modifier that wraps the content in a UIKit view with
33+
/// `accessibilityContainerType = .semanticGroup`, since SwiftUI has no native equivalent.
34+
private extension View {
35+
func semanticGroupContainer(label: String? = nil) -> some View {
36+
SemanticGroupWrapper(label: label) { self }
37+
}
38+
}
39+
40+
private struct SemanticGroupWrapper<Content: View>: UIViewRepresentable {
41+
let label: String?
42+
let content: Content
43+
44+
init(label: String?, @ViewBuilder content: () -> Content) {
45+
self.label = label
46+
self.content = content()
47+
}
48+
49+
func makeUIView(context: Context) -> SemanticGroupUIView {
50+
let container = SemanticGroupUIView()
51+
container.accessibilityLabel = label
52+
53+
let hosting = UIHostingController(rootView: content)
54+
hosting.view.backgroundColor = .clear
55+
hosting.view.translatesAutoresizingMaskIntoConstraints = false
56+
container.addSubview(hosting.view)
57+
NSLayoutConstraint.activate([
58+
hosting.view.topAnchor.constraint(equalTo: container.topAnchor),
59+
hosting.view.bottomAnchor.constraint(equalTo: container.bottomAnchor),
60+
hosting.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
61+
hosting.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
62+
])
63+
context.coordinator.hostingController = hosting
64+
return container
65+
}
66+
67+
func updateUIView(_ uiView: SemanticGroupUIView, context: Context) {
68+
context.coordinator.hostingController?.rootView = content
69+
uiView.accessibilityLabel = label
70+
}
71+
72+
func makeCoordinator() -> Coordinator { Coordinator() }
73+
74+
class Coordinator {
75+
var hostingController: UIHostingController<Content>?
76+
}
77+
}
78+
79+
/// A UIView that reports itself as a semantic group accessibility container.
80+
private class SemanticGroupUIView: UIView {
81+
override var accessibilityContainerType: UIAccessibilityContainerType {
82+
get { .semanticGroup }
83+
set {}
84+
}
85+
86+
override init(frame: CGRect) {
87+
super.init(frame: frame)
88+
isAccessibilityElement = false
89+
}
90+
91+
@available(*, unavailable)
92+
required init?(coder: NSCoder) { fatalError() }
93+
}
94+
95+
// MARK: - Previews
96+
97+
#Preview {
98+
ContainerDemo()
99+
.accessibilityPreview()
100+
}
101+
102+
#Preview("Containers") {
103+
ContainerDemo()
104+
.accessibilityPreview(
105+
configuration: .init(viewRenderingMode: .drawHierarchyInRect, showContainers: true)
106+
)
107+
}

Example/AccessibilitySnapshotPreviewsTests/AccessibilitySnapshotPreviewsTestCase.swift

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AccessibilitySnapshotCore
12
import FBSnapshotTestCase_Accessibility
23
import iOSSnapshotTestCase
34

@@ -35,17 +36,30 @@ class AccessibilitySnapshotPreviewsTestCase: FBSnapshotTestCase {
3536
func snapshotVerifyAccessibility<V: View>(
3637
_ view: V,
3738
identifier: String = "",
39+
configuration: AccessibilitySnapshotConfiguration? = nil,
3840
file: StaticString = #file,
3941
line: UInt = #line
4042
) {
41-
SnapshotVerifyAccessibility(
42-
view,
43-
size: UIScreen.main.bounds.size,
44-
identifier: identifier,
45-
layoutEngine: .swiftui,
46-
file: file,
47-
line: line
48-
)
43+
if let configuration {
44+
SnapshotVerifyAccessibility(
45+
view,
46+
size: UIScreen.main.bounds.size,
47+
identifier: identifier,
48+
layoutEngine: .swiftui,
49+
snapshotConfiguration: configuration,
50+
file: file,
51+
line: line
52+
)
53+
} else {
54+
SnapshotVerifyAccessibility(
55+
view,
56+
size: UIScreen.main.bounds.size,
57+
identifier: identifier,
58+
layoutEngine: .swiftui,
59+
file: file,
60+
line: line
61+
)
62+
}
4963
}
5064

5165
// MARK: - Configuration
@@ -59,6 +73,7 @@ class AccessibilitySnapshotPreviewsTestCase: FBSnapshotTestCase {
5973
private static let testedDevices = [
6074
TestDeviceConfig(systemVersion: "18.5", screenSize: CGSize(width: 402, height: 874), screenScale: 3),
6175
TestDeviceConfig(systemVersion: "26.2", screenSize: CGSize(width: 402, height: 874), screenScale: 3),
76+
TestDeviceConfig(systemVersion: "26.4", screenSize: CGSize(width: 402, height: 874), screenScale: 3),
6277
]
6378

6479
override func setUp() {
Loading

Example/AccessibilitySnapshotPreviewsTests/SwiftUIRendererTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AccessibilitySnapshotCore
12
@testable import AccessibilitySnapshotPreviewsDemo
23

34
@available(iOS 16.0, *)
@@ -25,4 +26,18 @@ final class SwiftUIRendererTests: AccessibilitySnapshotPreviewsTestCase {
2526
func testUnspokenTraitsDemo() {
2627
snapshotVerifyAccessibility(UnspokenTraitsDemoView())
2728
}
29+
30+
func testContainerDemoWithoutContainers() {
31+
snapshotVerifyAccessibility(
32+
ContainerDemo(),
33+
identifier: "no_containers"
34+
)
35+
}
36+
37+
func testContainerDemo() {
38+
snapshotVerifyAccessibility(
39+
ContainerDemo(),
40+
configuration: .init(viewRenderingMode: .renderLayerInContext, showContainers: true)
41+
)
42+
}
2843
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import AccessibilitySnapshotCore
2+
import AccessibilitySnapshotParser
3+
import SwiftUI
4+
5+
/// A legend entry for an accessibility container, with its children inside a dashed border.
6+
@available(iOS 16.0, *)
7+
struct ContainerLegendEntryView: View {
8+
let index: Int
9+
let container: AccessibilityContainer
10+
let palette: ColorPalette
11+
let childViews: AnyView
12+
13+
private static let containerInset: CGFloat = 10
14+
private let badgeHeight: CGFloat = DesignTokens.Badge.minSize
15+
16+
var body: some View {
17+
VStack(alignment: .leading, spacing: 0) {
18+
Color.clear
19+
.frame(height: badgeHeight / 2)
20+
21+
VStack(alignment: .leading, spacing: LegendLayoutMetrics.legendVerticalSpacing) {
22+
childViews
23+
}
24+
.padding(Self.containerInset)
25+
.padding(.top, badgeHeight / 2)
26+
.overlay(
27+
RoundedRectangle(cornerRadius: DesignTokens.Element.legendCornerRadius)
28+
.stroke(
29+
palette.strokeColor(at: index),
30+
style: StrokeStyle(lineWidth: DesignTokens.Element.strokeWidth, dash: [4, 4])
31+
)
32+
)
33+
.overlay(alignment: .topLeading) {
34+
ContainerBadge(index: index, container: container, palette: palette)
35+
.offset(
36+
x: Self.containerInset,
37+
y: -badgeHeight / 2
38+
)
39+
}
40+
}
41+
}
42+
}
43+
44+
/// A badge for container entries that includes a layer icon, number, and container label.
45+
@available(iOS 16.0, *)
46+
struct ContainerBadge: View {
47+
let index: Int
48+
let container: AccessibilityContainer
49+
let palette: ColorPalette
50+
51+
var body: some View {
52+
HStack(spacing: 2) {
53+
Image(systemName: "square.on.square")
54+
.font(.system(size: 8, weight: .bold))
55+
.foregroundColor(.white)
56+
57+
Text(displayName)
58+
.font(DesignTokens.Typography.badgeNumber)
59+
.foregroundColor(.white)
60+
}
61+
.padding(.horizontal, 4)
62+
.frame(minHeight: DesignTokens.Badge.minSize)
63+
.background(
64+
RoundedRectangle(cornerRadius: DesignTokens.Badge.cornerRadius)
65+
.fill(palette.color(at: index))
66+
)
67+
}
68+
69+
private var displayName: String {
70+
switch container.type {
71+
case let .semanticGroup(label, value, identifier):
72+
let parts = [label, value].compactMap { $0?.isEmpty == false ? $0 : nil }
73+
if !parts.isEmpty {
74+
return parts.joined(separator: ": ")
75+
} else if let identifier, !identifier.isEmpty {
76+
return identifier
77+
}
78+
return "Semantic Group"
79+
case .list:
80+
return "List"
81+
case .landmark:
82+
return "Landmark"
83+
case let .dataTable(rowCount, columnCount):
84+
return "Data Table (\(rowCount) × \(columnCount))"
85+
case .tabBar:
86+
return "Tab Bar"
87+
}
88+
}
89+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import AccessibilitySnapshotCore
2+
import AccessibilitySnapshotParser
3+
4+
/// Assigns color indices to containers and elements in the hierarchy.
5+
///
6+
/// Elements use their position in the sorted traversal order (matching the `markers` array
7+
/// and overlay indices). Containers use a separate sequential counter starting at 0, so
8+
/// elements and containers cycle through palette colors independently.
9+
@available(iOS 16.0, *)
10+
public struct HierarchyColorAssignment {
11+
/// A node with its assigned color index.
12+
public enum AssignedNode {
13+
case element(AccessibilityElement, colorIndex: Int)
14+
case container(AccessibilityContainer, colorIndex: Int, children: [AssignedNode])
15+
}
16+
17+
/// The assigned nodes in hierarchy order.
18+
public let nodes: [AssignedNode]
19+
20+
/// Builds color assignments from a hierarchy tree.
21+
public static func build(from hierarchy: [AccessibilityHierarchy]) -> HierarchyColorAssignment {
22+
var containerCounter = 0
23+
24+
// Map each element's traversal index to its position in the sorted
25+
// markers array. This ensures legend colors match overlay colors.
26+
var traversalIndexToPosition: [Int: Int] = [:]
27+
var allTraversalIndices: [Int] = []
28+
func collectTraversalIndices(_ nodes: [AccessibilityHierarchy]) {
29+
for node in nodes {
30+
switch node {
31+
case let .element(_, traversalIndex):
32+
allTraversalIndices.append(traversalIndex)
33+
case let .container(_, children):
34+
collectTraversalIndices(children)
35+
}
36+
}
37+
}
38+
collectTraversalIndices(hierarchy)
39+
for (position, traversalIndex) in allTraversalIndices.sorted().enumerated() {
40+
traversalIndexToPosition[traversalIndex] = position
41+
}
42+
43+
func assign(_ nodes: [AccessibilityHierarchy]) -> [AssignedNode] {
44+
nodes.map { node in
45+
switch node {
46+
case let .container(container, children):
47+
let index = containerCounter
48+
containerCounter += 1
49+
let assignedChildren = assign(children)
50+
return .container(container, colorIndex: index, children: assignedChildren)
51+
52+
case let .element(element, traversalIndex):
53+
let index = traversalIndexToPosition[traversalIndex] ?? 0
54+
return .element(element, colorIndex: index)
55+
}
56+
}
57+
}
58+
59+
let assignedNodes = assign(hierarchy)
60+
return HierarchyColorAssignment(nodes: assignedNodes)
61+
}
62+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import AccessibilitySnapshotCore
2+
import AccessibilitySnapshotParser
3+
import SwiftUI
4+
5+
/// Renders a hierarchical legend from assigned nodes, with containers wrapping their children.
6+
@available(iOS 16.0, *)
7+
struct HierarchyLegendView: View {
8+
let nodes: [HierarchyColorAssignment.AssignedNode]
9+
let palette: ColorPalette
10+
let showUserInputLabels: Bool
11+
let showUnspokenTraits: Bool
12+
13+
var body: some View {
14+
VStack(alignment: .leading, spacing: LegendLayoutMetrics.legendVerticalSpacing) {
15+
ForEach(nodes.indices, id: \.self) { i in
16+
nodeView(for: nodes[i])
17+
}
18+
}
19+
}
20+
21+
@ViewBuilder
22+
private func nodeView(for node: HierarchyColorAssignment.AssignedNode) -> some View {
23+
switch node {
24+
case let .element(element, colorIndex):
25+
LegendEntryView(
26+
index: colorIndex,
27+
marker: element,
28+
palette: palette,
29+
showUserInputLabels: showUserInputLabels,
30+
showUnspokenTraits: showUnspokenTraits
31+
)
32+
33+
case let .container(container, colorIndex, children):
34+
ContainerLegendEntryView(
35+
index: colorIndex,
36+
container: container,
37+
palette: palette,
38+
childViews: AnyView(
39+
VStack(alignment: .leading, spacing: LegendLayoutMetrics.legendVerticalSpacing) {
40+
ForEach(children.indices, id: \.self) { i in
41+
nodeView(for: children[i])
42+
}
43+
}
44+
)
45+
)
46+
}
47+
}
48+
}

Sources/AccessibilitySnapshot/AccessibilitySnapshotPreviews/SwiftUIAccessibilitySnapshotContainerView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public final class SwiftUIAccessibilitySnapshotContainerView: AccessibilitySnaps
3939
let swiftUIView = PreParsedAccessibilitySnapshotView(
4040
snapshotImage: data.image,
4141
markers: data.markers,
42+
hierarchy: data.hierarchy,
4243
configuration: snapshotConfiguration,
4344
palette: palette,
4445
renderSize: data.containedViewBounds

0 commit comments

Comments
 (0)