From febea0f363ef9c86e97ab98eb2a54cba1c508947 Mon Sep 17 00:00:00 2001 From: Adi Date: Fri, 29 Jun 2018 18:52:21 -0400 Subject: [PATCH 1/6] Added rough outline of accessibility. Added filprivate property accessibilityChartElements which is used to adhere to the UIAccessibilityContainer protocol methods. Rather than render data twice, the methods used attempt to make UIAccessibilityElements as they are drawn using existing functions in SwiftChart. --- Source/Chart.swift | 142 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 10 deletions(-) mode change 100644 => 100755 Source/Chart.swift diff --git a/Source/Chart.swift b/Source/Chart.swift old mode 100644 new mode 100755 index 4ba07102a..086b69993 --- 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,86 @@ open class Chart: UIControl { } } + // MARK: - Accessibility + + fileprivate var accessibilityChartElements: [UIAccessibilityElement] = [] + + fileprivate var accessibilityChartDataIndices: [Set] = [] + + 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 + } + // 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. + let dimension: CGFloat = 9.0 + 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 rect = CGRect(x: CGFloat(xValues.first!) - dimension, + y: CGFloat(yValues.first!) - dimension, + width: 2 * dimension, + height: 2 * dimension) + + let ax = series[seriesIndex].data.first!.x + let ay = series[seriesIndex].data.first!.y + + let element = UIAccessibilityElement(accessibilityContainer: self) + element.accessibilityLabel = String(format: "%.2f, %.2f", ax, ay) + element.accessibilityFrame = self.convert(rect, to: UIScreen.main.coordinateSpace) + + 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. + 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) } /** From 7bd5b10a0ae7dc67d51f7e97547903bdc8a28b2a Mon Sep 17 00:00:00 2001 From: Adi Date: Fri, 29 Jun 2018 20:00:14 -0400 Subject: [PATCH 2/6] Cleaned up redundant accessibility calculation into createAccessibilityElement(). --- Source/Chart.swift | 54 +++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/Source/Chart.swift b/Source/Chart.swift index 086b69993..65ebdeab6 100755 --- a/Source/Chart.swift +++ b/Source/Chart.swift @@ -502,6 +502,29 @@ open class Chart: UIControl { 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 { + // Note: This creates the accessibility element with each side 44.0 units since it is doubled + let dimension: CGFloat = 22.0 + + let rect = CGRect(x: x - dimension, + y: y - dimension, + width: 2 * dimension, + height: 2 * dimension) + + let ax = series[seriesIndex].data[index + offset].x + let ay = series[seriesIndex].data[index + offset].y + + let element = UIAccessibilityElement(accessibilityContainer: self) + element.accessibilityLabel = "Dataset \(seriesIndex + 1):" + String(format: " x: %.2f, y: %.2f", ax, ay) + element.accessibilityFrame = self.convert(rect, to: UIScreen.main.coordinateSpace) + + return element + } + // MARK: - Drawings fileprivate func drawLine(_ xValues: [Double], yValues: [Double], seriesIndex: Int, segmentIndex: Int) { @@ -511,24 +534,17 @@ open class Chart: UIControl { 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. - let dimension: CGFloat = 9.0 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 rect = CGRect(x: CGFloat(xValues.first!) - dimension, - y: CGFloat(yValues.first!) - dimension, - width: 2 * dimension, - height: 2 * dimension) - - let ax = series[seriesIndex].data.first!.x - let ay = series[seriesIndex].data.first!.y - let element = UIAccessibilityElement(accessibilityContainer: self) - element.accessibilityLabel = String(format: "%.2f, %.2f", ax, ay) - element.accessibilityFrame = self.convert(rect, to: UIScreen.main.coordinateSpace) + let element = self.createAccessibilityElement(forSeriesIndex: seriesIndex, + withX: CGFloat(xValues.first!), + y: CGFloat(yValues.first!), + dataValueIndex: 0) self.accessibilityChartElements.append(element) } @@ -543,20 +559,14 @@ open class Chart: UIControl { // 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 rect = CGRect(x: x - dimension, - y: y - dimension, - width: 2 * dimension, - height: 2 * dimension) - - let ax = series[seriesIndex].data[i + dataSetIndexOffset].x - let ay = series[seriesIndex].data[i + dataSetIndexOffset].y - let element = UIAccessibilityElement(accessibilityContainer: self) - element.accessibilityLabel = "Dataset \(seriesIndex + 1):" + String(format: " x: %.2f, y: %.2f", ax, ay) - element.accessibilityFrame = self.convert(rect, to: UIScreen.main.coordinateSpace) + let element = self.createAccessibilityElement(forSeriesIndex: seriesIndex, + withX: x, + y: y, + dataValueIndex: i, + indexOffset: dataSetIndexOffset) self.accessibilityChartElements.append(element) - } } From 7ed999a0f7e2428b35c557047c1afa5aa4db57c9 Mon Sep 17 00:00:00 2001 From: Adi Date: Fri, 29 Jun 2018 21:22:00 -0400 Subject: [PATCH 3/6] Added optional separate accessibility x & y labels to improve usability. --- .../SwiftChart/StockChartViewController.swift | 8 ++++- Source/Chart.swift | 32 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) mode change 100644 => 100755 Example/SwiftChart/StockChartViewController.swift diff --git a/Example/SwiftChart/StockChartViewController.swift b/Example/SwiftChart/StockChartViewController.swift old mode 100644 new mode 100755 index b532b6b26..4081d0b3d --- a/Example/SwiftChart/StockChartViewController.swift +++ b/Example/SwiftChart/StockChartViewController.swift @@ -7,7 +7,6 @@ // import UIKit -import SwiftChart class StockChartViewController: UIViewController, ChartDelegate { @@ -35,6 +34,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,6 +51,11 @@ class StockChartViewController: UIViewController, ChartDelegate { labels.append(Double(i)) labelsAsString.append(monthAsString) } + + let xFormatter = DateFormatter() + xFormatter.dateStyle = .medium + let xDescription = xFormatter.string(from: value["date"] as! Date) + accessibilityXLabels.append(xDescription) } let series = ChartSeries(serieData) @@ -60,6 +65,7 @@ class StockChartViewController: UIViewController, ChartDelegate { chart.lineWidth = 0.5 chart.labelFont = UIFont.systemFont(ofSize: 12) + chart.accessibilityXLabels = accessibilityXLabels chart.xLabels = labels chart.xLabelsFormatter = { (labelIndex: Int, labelValue: Double) -> String in return labelsAsString[labelIndex] diff --git a/Source/Chart.swift b/Source/Chart.swift index 65ebdeab6..af91ee636 100755 --- a/Source/Chart.swift +++ b/Source/Chart.swift @@ -484,6 +484,20 @@ open class Chart: UIControl { 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 { } @@ -507,7 +521,7 @@ open class Chart: UIControl { y: CGFloat, dataValueIndex index: Int, indexOffset offset: Int = 0) -> UIAccessibilityElement { - // Note: This creates the accessibility element with each side 44.0 units since it is doubled + // Create the accessibility element with each side 44.0 units let dimension: CGFloat = 22.0 let rect = CGRect(x: x - dimension, @@ -518,8 +532,22 @@ open class Chart: UIControl { 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):" + String(format: " x: %.2f, y: %.2f", ax, ay) + element.accessibilityLabel = "Dataset \(seriesIndex + 1):" + labelDescription element.accessibilityFrame = self.convert(rect, to: UIScreen.main.coordinateSpace) return element From c48f23014e239523cb6e265f5e1a4f65aeceec33 Mon Sep 17 00:00:00 2001 From: Adi Date: Fri, 29 Jun 2018 21:27:07 -0400 Subject: [PATCH 4/6] Minor update to StockChartViewController to use manual accessibilityYLabels. --- Example/SwiftChart/StockChartViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Example/SwiftChart/StockChartViewController.swift b/Example/SwiftChart/StockChartViewController.swift index 4081d0b3d..096f066d7 100755 --- a/Example/SwiftChart/StockChartViewController.swift +++ b/Example/SwiftChart/StockChartViewController.swift @@ -61,11 +61,14 @@ class StockChartViewController: UIViewController, ChartDelegate { let series = ChartSeries(serieData) series.area = true + 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] From 075a633637c2d2df1bbafd5a013e2cbb99673374 Mon Sep 17 00:00:00 2001 From: Adi Date: Sat, 30 Jun 2018 13:19:09 -0400 Subject: [PATCH 5/6] Added summary of touched element(s) for accessibility clients in handleTouchEvents(). --- Source/Chart.swift | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Source/Chart.swift b/Source/Chart.swift index af91ee636..61b9d8808 100755 --- a/Source/Chart.swift +++ b/Source/Chart.swift @@ -529,6 +529,7 @@ open class Chart: UIControl { 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 @@ -872,6 +873,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) } From 8e10f52b74801ffce203766840625c5045a366ba Mon Sep 17 00:00:00 2001 From: Adi Date: Sat, 30 Jun 2018 13:30:22 -0400 Subject: [PATCH 6/6] Fixed deleted import and added comments describing some accessibility changes. --- Example/SwiftChart/StockChartViewController.swift | 3 +++ Source/Chart.swift | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Example/SwiftChart/StockChartViewController.swift b/Example/SwiftChart/StockChartViewController.swift index 096f066d7..82b01f094 100755 --- a/Example/SwiftChart/StockChartViewController.swift +++ b/Example/SwiftChart/StockChartViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import SwiftChart class StockChartViewController: UIViewController, ChartDelegate { @@ -52,6 +53,7 @@ class StockChartViewController: UIViewController, ChartDelegate { 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) @@ -61,6 +63,7 @@ class StockChartViewController: UIViewController, ChartDelegate { 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 diff --git a/Source/Chart.swift b/Source/Chart.swift index 61b9d8808..3731013b0 100755 --- a/Source/Chart.swift +++ b/Source/Chart.swift @@ -562,7 +562,7 @@ open class Chart: UIControl { 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. + // 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 } @@ -581,6 +581,7 @@ open class Chart: UIControl { // 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.. 0 else { continue }