diff --git a/Example/SwiftChart/StockChartViewController.swift b/Example/SwiftChart/StockChartViewController.swift old mode 100644 new mode 100755 index b532b6b26..82b01f094 --- a/Example/SwiftChart/StockChartViewController.swift +++ b/Example/SwiftChart/StockChartViewController.swift @@ -35,6 +35,7 @@ class StockChartViewController: UIViewController, ChartDelegate { var serieData: [Double] = [] var labels: [Double] = [] var labelsAsString: Array = [] + var accessibilityXLabels: [String] = [] // Date formatter to retrieve the month names let dateFormatter = DateFormatter() @@ -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] diff --git a/Source/Chart.swift b/Source/Chart.swift old mode 100644 new mode 100755 index 4ba07102a..3731013b0 --- a/Source/Chart.swift +++ b/Source/Chart.swift @@ -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) @@ -459,16 +478,126 @@ open class Chart: UIControl { } } + // MARK: - Accessibility + + fileprivate var accessibilityChartElements: [UIAccessibilityElement] = [] + + fileprivate var accessibilityChartDataIndices: [Set] = [] + + /** + 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.. [ChartLineSegment] { + fileprivate class func segmentLine(_ line: ChartLineSegment, + zeroLevel: Double) -> ([ChartLineSegment], [Set]) { 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] = [] + var accessibilityIndexSet: Set = [] + 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) } /**