A custom Shape collapses into one view once you fill or stroke it, so VoiceOver cannot reach the individual data points it draws. accessibilityChildren fixes that: it builds an accessibility container from a phantom view you describe, without rendering it.
The Problem: A Shape Is One Element
A bar chart drawn as a single Path gives VoiceOver one opaque element, whatever the number of bars.
struct BarChartShape: Shape {
let dataPoints: [DataPoint]
func path(in rect: CGRect) -> Path {
Path { p in
let width = rect.size.width / CGFloat(dataPoints.count)
var x: CGFloat = 0
for point in dataPoints {
let pointRect = CGRect(
x: x,
y: rect.size.height - point.value,
width: width,
height: rect.size.height
)
p.addPath(RoundedRectangle(cornerRadius: 8).path(in: pointRect))
x += width
}
}
}
}
Describe the Children With a Phantom View
Attach accessibilityChildren with a ViewBuilder closure. SwiftUI never renders that closure: it only reads it to populate the accessibility tree, one element per bar.
BarChartShape(dataPoints: dataPoints)
.fill(.red)
.accessibilityLabel("Chart")
.accessibilityChildren {
HStack(alignment: .bottom, spacing: 0) {
ForEach(dataPoints) { point in
RoundedRectangle(cornerRadius: 8)
.accessibilityValue(Text(point.value.formatted()))
}
}
}
VoiceOver now announces the chart as a container and steps through each bar's value.
Children Versus Representation
accessibilityChildren only adds a container of child elements and leaves the view's own accessibility untouched. accessibilityRepresentation replaces the view's whole accessibility tree. Use children to enrich a drawing, use representation to stand in for it.