Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accessibility & VoiceOver support #103

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Example/SwiftChart/StockChartViewController.swift
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class StockChartViewController: UIViewController, ChartDelegate {
var serieData: [Double] = []
var labels: [Double] = []
var labelsAsString: Array<String> = []
var accessibilityXLabels: [String] = []

// Date formatter to retrieve the month names
let dateFormatter = DateFormatter()
Expand All @@ -51,15 +52,26 @@ class StockChartViewController: UIViewController, ChartDelegate {
labels.append(Double(i))
labelsAsString.append(monthAsString)
}

// Create a slightly more descriptive x-label for Accessibility/VoiceOver
let xFormatter = DateFormatter()
xFormatter.dateStyle = .medium
let xDescription = xFormatter.string(from: value["date"] as! Date)
accessibilityXLabels.append(xDescription)
}

let series = ChartSeries(serieData)
series.area = true

// Since we know the values are in Dollars, we can improve the experience for VoiceOver users by simply setting y-labels
let accessibilityYLabels = serieData.map { "$\($0)" }

// Configure chart layout

chart.lineWidth = 0.5
chart.labelFont = UIFont.systemFont(ofSize: 12)
chart.accessibilityXLabels = accessibilityXLabels
chart.accessibilityYLabels = accessibilityYLabels
chart.xLabels = labels
chart.xLabelsFormatter = { (labelIndex: Int, labelValue: Double) -> String in
return labelsAsString[labelIndex]
Expand Down
222 changes: 212 additions & 10 deletions Source/Chart.swift
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -330,19 +330,38 @@ open class Chart: UIControl {
}
layerStore.removeAll()

// Remove old accessibility elements

self.accessibilityChartElements.removeAll()
self.accessibilityChartDataIndices.removeAll()

// Create a summary accessibility element

let element = UIAccessibilityElement(accessibilityContainer: self)
element.accessibilityLabel = "Line Chart. \(series.count) dataset\(series.count == 1 ? "" : "s")."
element.accessibilityFrame = self.convert(bounds, to: UIScreen.main.coordinateSpace)
element.accessibilityTraits = UIAccessibilityTraitHeader

self.accessibilityChartElements.append(element)

// Draw content

for (index, series) in self.series.enumerated() {

// Separate each line in multiple segments over and below the x axis
let segments = Chart.segmentLine(series.data as ChartLineSegment, zeroLevel: series.colors.zeroLevel)

segments.forEach({ segment in
// Accessibility indices keeps track of data points and ignores separation points
let (segments, accessibilityIndices) = Chart.segmentLine(series.data as ChartLineSegment,
zeroLevel: series.colors.zeroLevel)

// This property is used in drawLine() to generate the accessibilityLabels
self.accessibilityChartDataIndices = accessibilityIndices

segments.enumerated().forEach({ (i, segment) in
let scaledXValues = scaleValuesOnXAxis( segment.map { $0.x } )
let scaledYValues = scaleValuesOnYAxis( segment.map { $0.y } )

if series.line {
drawLine(scaledXValues, yValues: scaledYValues, seriesIndex: index)
drawLine(scaledXValues, yValues: scaledYValues, seriesIndex: index, segmentIndex: i)
}
if series.area {
drawArea(scaledXValues, yValues: scaledYValues, seriesIndex: index)
Expand Down Expand Up @@ -459,16 +478,126 @@ open class Chart: UIControl {
}
}

// MARK: - Accessibility

fileprivate var accessibilityChartElements: [UIAccessibilityElement] = []

fileprivate var accessibilityChartDataIndices: [Set<Int>] = []

/**
Labels that better describe the X component of all values.

**NOTE**: Ensure that its count is the same as the number of data points / y values.
*/
open var accessibilityXLabels: [String]?

/**
Labels to describe each Y value differently than the raw value.

**NOTE**: Ensure that its count is the same as the number of data points / x values.
*/
open var accessibilityYLabels: [String]?

open override var isAccessibilityElement: Bool {
get { return false }
set { }
}

open override func accessibilityElementCount() -> Int {
return self.accessibilityChartElements.count
}

open override func accessibilityElement(at index: Int) -> Any? {
return self.accessibilityChartElements[index]
}

open override func index(ofAccessibilityElement element: Any) -> Int {
guard let chartElement = element as? UIAccessibilityElement else { return NSNotFound }
return self.accessibilityChartElements.index(of: chartElement) ?? NSNotFound
}

fileprivate func createAccessibilityElement(forSeriesIndex seriesIndex: Int,
withX x: CGFloat,
y: CGFloat,
dataValueIndex index: Int,
indexOffset offset: Int = 0) -> UIAccessibilityElement {
// Create the accessibility element with each side 44.0 units
let dimension: CGFloat = 22.0

let rect = CGRect(x: x - dimension,
y: y - dimension,
width: 2 * dimension,
height: 2 * dimension)

// Note that the offset is used to compute the correct index into the data based on the number of valid data points already generated in prior segments. (See drawLine())
let ax = series[seriesIndex].data[index + offset].x
let ay = series[seriesIndex].data[index + offset].y

// If x or y accessibilityLabels have been set, then use those otherwise default to raw values.
var labelDescription: String = ""
if let accessibilityXLabels = self.accessibilityXLabels {
labelDescription += "\(accessibilityXLabels[index + offset])"
} else {
labelDescription += String(format: " x: %.2f", ax)
}

if let accessibilityYLabels = self.accessibilityYLabels {
labelDescription += ", \(accessibilityYLabels[index + offset])"
} else {
labelDescription += String(format: ", y: %.2f", ay)
}

let element = UIAccessibilityElement(accessibilityContainer: self)
element.accessibilityLabel = "Dataset \(seriesIndex + 1):" + labelDescription
element.accessibilityFrame = self.convert(rect, to: UIScreen.main.coordinateSpace)

return element
}

// MARK: - Drawings

fileprivate func drawLine(_ xValues: [Double], yValues: [Double], seriesIndex: Int) {
fileprivate func drawLine(_ xValues: [Double], yValues: [Double], seriesIndex: Int, segmentIndex: Int) {
// YValues are "reverted" from top to bottom, so 'above' means <= level
let isAboveZeroLine = yValues.max()! <= self.scaleValueOnYAxis(series[seriesIndex].colors.zeroLevel)
let path = CGMutablePath()
path.move(to: CGPoint(x: CGFloat(xValues.first!), y: CGFloat(yValues.first!)))

// Since drawing starts from the second point, create an accessibility element for the first data point here, before the loop.
var dataSetIndexOffset: Int = 0
let counts = self.accessibilityChartDataIndices.map { $0.count }

// If we're drawing the first segment, then generate an element.
// Otherwise, the first element is a separation point between positive/negative
if self.accessibilityChartDataIndices[segmentIndex].contains(0) {

let element = self.createAccessibilityElement(forSeriesIndex: seriesIndex,
withX: CGFloat(xValues.first!),
y: CGFloat(yValues.first!),
dataValueIndex: 0)

self.accessibilityChartElements.append(element)
}

// This offset is used to compute the correct index into the data based on the number of valid data points already generated in prior segments.
dataSetIndexOffset += segmentIndex > 0 && counts.count > 1 ? counts[0..<segmentIndex].reduce(0, +) - 1 : 0

// Using the above, generate accessibilityElements for each data point
for i in 1..<yValues.count {
let y = yValues[i]
path.addLine(to: CGPoint(x: CGFloat(xValues[i]), y: CGFloat(y)))
let x = CGFloat(xValues[i])
let y = CGFloat(yValues[i])
path.addLine(to: CGPoint(x: x, y: y))

// If the current index is not a separation point on the zero line, then generate an accessibility point
if self.accessibilityChartDataIndices[segmentIndex].contains(i) {

let element = self.createAccessibilityElement(forSeriesIndex: seriesIndex,
withX: x,
y: y,
dataValueIndex: i,
indexOffset: dataSetIndexOffset)

self.accessibilityChartElements.append(element)
}
}

let lineLayer = CAShapeLayer()
Expand Down Expand Up @@ -588,6 +717,9 @@ open class Chart: UIControl {
label.font = labelFont
label.text = xLabelsFormatter(i, labels[i])
label.textColor = labelColor

// Remove accessibility for individual labels, since we'll add it for the entire chart
label.isAccessibilityElement = false

// Set label size
label.sizeToFit()
Expand Down Expand Up @@ -664,6 +796,9 @@ open class Chart: UIControl {
label.textColor = labelColor
label.sizeToFit()

// Remove accessibility for individual labels, since we'll add it for the entire chart
label.isAccessibilityElement = false

if yLabelsOnRightSide {
label.frame.origin.x = drawingWidth
label.frame.origin.x -= label.frame.width + padding
Expand Down Expand Up @@ -739,6 +874,42 @@ open class Chart: UIControl {
}
indexes.append(index)
}

// Summarize the currently touched element for accessibility clients.
var labelDescription: String = ""
var lastSelectedIndex: Int = 0

for (seriesIndex, dataIndex) in indexes.enumerated() {
if let value = self.valueForSeries(seriesIndex, atIndex: dataIndex) {
defer { lastSelectedIndex = dataIndex ?? 0 }

// If x or y accessibilityLabels have been set, then use those otherwise default to raw values.
if let accessibilityXLabels = self.accessibilityXLabels {
labelDescription += "\(accessibilityXLabels[dataIndex ?? 0])"
} else {
labelDescription += String(format: "Dataset \(seriesIndex + 1). x: %.2f", x)
}

if let accessibilityYLabels = self.accessibilityYLabels {
labelDescription += ", \(accessibilityYLabels[dataIndex ?? 0])"
} else {
labelDescription += String(format: " y: %.2f.", value)
}

}
}

// Post an announcement, so a user doesn't need to lift their finger to hear whats being touched
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification,
labelDescription)

// If the user does end the touch and raise their finger, select the last narrated element.
// Note that one is added here, since the first element of the accessibilityElement array is the chart's description.
if point.phase == .ended {
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification,
self.accessibilityElement(at: lastSelectedIndex + 1))
}

delegate!.didTouchChart(self, indexes: indexes, x: x, left: left)
}

Expand Down Expand Up @@ -807,28 +978,59 @@ open class Chart: UIControl {
Segment a line in multiple lines when the line touches the x-axis, i.e. separating
positive from negative values.
*/
fileprivate class func segmentLine(_ line: ChartLineSegment, zeroLevel: Double) -> [ChartLineSegment] {
fileprivate class func segmentLine(_ line: ChartLineSegment,
zeroLevel: Double) -> ([ChartLineSegment], [Set<Int>]) {
var segments: [ChartLineSegment] = []
var segment: ChartLineSegment = []

// These are used to keep track of the indices of elements that are from the dataset vs those that are points on the zero line
// They closely mirror the update pattern for segment/segments
var accessibilityIndices: [Set<Int>] = []
var accessibilityIndexSet: Set<Int> = []

line.enumerated().forEach { (i, point) in
segment.append(point)
accessibilityIndexSet.insert(i)

if i < line.count - 1 {
let nextPoint = line[i+1]
if point.y >= zeroLevel && nextPoint.y < zeroLevel || point.y < zeroLevel && nextPoint.y >= zeroLevel {
// The segment intersects zeroLevel, close the segment with the intersection point
let closingPoint = Chart.intersectionWithLevel(point, and: nextPoint, level: zeroLevel)
segment.append(closingPoint)

segments.append(segment)
accessibilityIndices.append(accessibilityIndexSet)

// Start a new segment
segment = [closingPoint]
accessibilityIndexSet = []
} else {
// If it's not a closing point, keep note of the index for accessibility clients.
accessibilityIndexSet.insert(i)
}
} else {
// End of the line
segments.append(segment)
accessibilityIndices.append(accessibilityIndexSet)
}
}
return segments

// The indices of data points are relative to the original data array.
// This removes traversed element counts from earlier segment indices to make each index relative to the segment the data point occurs in.
// For example, instead of an an array like
// [Set(2, 0, 1), Set(3), Set(4)]
// We want an array that looks like
// [Set(2, 0, 1), Set(1), Set(1)]
var previousElementsOffset: Int = accessibilityIndices.first?.count ?? 0
for (i, indexSet) in accessibilityIndices.enumerated() {
guard i > 0 else { continue }
defer { previousElementsOffset += indexSet.count }

accessibilityIndices[i] = Set(indexSet.map { $0 - previousElementsOffset + 1 })
}

return (segments, accessibilityIndices)
}

/**
Expand Down