From af809a60d93babf724a35bac18bcb2e65fdc7dec Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 27 May 2018 17:09:04 -0400 Subject: [PATCH] Added accessibility for Bubble charts. (#1060) Updated BubbleChartRenderer to mirror LineChartRenderer's nested use of accessibilityOrderedElements to populate accessibleChartElements. Minor updates to comments in LineChartRenderer. --- .../Renderers/BubbleChartRenderer.swift | 92 ++++++++++++++++++- .../Charts/Renderers/LineChartRenderer.swift | 3 +- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/Source/Charts/Renderers/BubbleChartRenderer.swift b/Source/Charts/Renderers/BubbleChartRenderer.swift index 51e937e0fc..4fc87647dd 100644 --- a/Source/Charts/Renderers/BubbleChartRenderer.swift +++ b/Source/Charts/Renderers/BubbleChartRenderer.swift @@ -19,6 +19,9 @@ import CoreGraphics open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer { + /// A nested array of elements ordered logically (i.e not in visual/drawing order) for use with VoiceOver. + private lazy var accessibilityOrderedElements: [[NSUIAccessibilityElement]] = accessibilityCreateEmptyOrderedElements() + @objc open weak var dataProvider: BubbleChartDataProvider? @objc public init(dataProvider: BubbleChartDataProvider, animator: Animator, viewPortHandler: ViewPortHandler) @@ -35,10 +38,32 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer let bubbleData = dataProvider.bubbleData else { return } - for set in bubbleData.dataSets as! [IBubbleChartDataSet] where set.isVisible + // If we redraw the data, remove and repopulate accessible elements to update label values and frames + accessibleChartElements.removeAll() + accessibilityOrderedElements = accessibilityCreateEmptyOrderedElements() + + // Make the chart header the first element in the accessible elements array + if let chart = dataProvider as? BubbleChartView { + let chartDescriptionText = chart.chartDescription?.text ?? "" + let dataSetDescriptions = bubbleData.dataSets.map { $0.label ?? "" } + let dataSetDescriptionText = dataSetDescriptions.joined(separator: ", ") + let dataSetCount = bubbleData.dataSets.count + let + element = NSUIAccessibilityElement(accessibilityContainer: chart) + element.accessibilityLabel = chartDescriptionText + ". \(dataSetCount) dataset\(dataSetCount == 1 ? "" : "s"). \(dataSetDescriptionText)" + element.accessibilityFrame = chart.bounds + element.isHeader = true + accessibleChartElements.append(element) + } + + for (i, set) in (bubbleData.dataSets as! [IBubbleChartDataSet]).enumerated() where set.isVisible { - drawDataSet(context: context, dataSet: set) + drawDataSet(context: context, dataSet: set, dataSetIndex: i) } + + // Merge nested ordered arrays into the single accessibleChartElements. + accessibleChartElements.append(contentsOf: accessibilityOrderedElements.flatMap { $0 } ) + accessibilityPostLayoutChangedNotification() } private func getShapeSize( @@ -57,7 +82,7 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer private var _pointBuffer = CGPoint() private var _sizeBuffer = [CGPoint](repeating: CGPoint(), count: 2) - @objc open func drawDataSet(context: CGContext, dataSet: IBubbleChartDataSet) + @objc open func drawDataSet(context: CGContext, dataSet: IBubbleChartDataSet, dataSetIndex: Int) { guard let dataProvider = dataProvider else { return } @@ -116,6 +141,20 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer context.setFillColor(color.cgColor) context.fillEllipse(in: rect) + + // Create and append the corresponding accessibility element to accessibilityOrderedElements + if let chart = dataProvider as? BubbleChartView + { + let element = createAccessibleElement(withIndex: j, + container: chart, + dataSet: dataSet, + dataSetIndex: dataSetIndex) + { (element) in + element.accessibilityFrame = rect + } + + accessibilityOrderedElements[dataSetIndex].append(element) + } } } @@ -282,4 +321,51 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer high.setDraw(x: _pointBuffer.x, y: _pointBuffer.y) } } + + /// Creates a nested array of empty subarrays each of which will be populated with NSUIAccessibilityElements. + /// This is marked internal to support HorizontalBarChartRenderer as well. + private func accessibilityCreateEmptyOrderedElements() -> [[NSUIAccessibilityElement]] + { + guard let chart = dataProvider as? BubbleChartView else { return [] } + + let maxEntryCount = chart.data?.maxEntryCountSet?.entryCount ?? 0 + + return Array(repeating: [NSUIAccessibilityElement](), + count: maxEntryCount) + } + + /// Creates an NSUIAccessibleElement representing the smallest meaningful bar of the chart + /// i.e. in case of a stacked chart, this returns each stack, not the combined bar. + /// Note that it is marked internal to support subclass modification in the HorizontalBarChart. + private func createAccessibleElement(withIndex idx: Int, + container: BubbleChartView, + dataSet: IBubbleChartDataSet, + dataSetIndex: Int, + modifier: (NSUIAccessibilityElement) -> ()) -> NSUIAccessibilityElement + { + let element = NSUIAccessibilityElement(accessibilityContainer: container) + let xAxis = container.xAxis + + guard let e = dataSet.entryForIndex(idx) else { return element } + guard let dataProvider = dataProvider else { return element } + + // NOTE: The formatter can cause issues when the x-axis labels are consecutive ints. + // i.e. due to the Double conversion, if there are more than one data set that are grouped, + // there is the possibility of some labels being rounded up. A floor() might fix this, but seems to be a brute force solution. + let label = xAxis.valueFormatter?.stringForValue(e.x, axis: xAxis) ?? "\(e.x)" + + let elementValueText = dataSet.valueFormatter?.stringForValue(e.y, + entry: e, + dataSetIndex: dataSetIndex, + viewPortHandler: viewPortHandler) ?? "\(e.y)" + + let dataSetCount = dataProvider.bubbleData?.dataSetCount ?? -1 + let doesContainMultipleDataSets = dataSetCount > 1 + + element.accessibilityLabel = "\(doesContainMultipleDataSets ? (dataSet.label ?? "") + ", " : "") \(label): \(elementValueText)" + + modifier(element) + + return element + } } diff --git a/Source/Charts/Renderers/LineChartRenderer.swift b/Source/Charts/Renderers/LineChartRenderer.swift index fb74e4a284..db02aa1f1f 100644 --- a/Source/Charts/Renderers/LineChartRenderer.swift +++ b/Source/Charts/Renderers/LineChartRenderer.swift @@ -674,7 +674,7 @@ open class LineChartRenderer: LineRadarRenderer continue } - // --------------- + // Accessibility element geometry let scaleFactor: CGFloat = 3 let accessibilityRect = CGRect(x: pt.x - (scaleFactor * circleRadius), y: pt.y - (scaleFactor * circleRadius), @@ -693,7 +693,6 @@ open class LineChartRenderer: LineRadarRenderer accessibilityOrderedElements[i].append(element) } - // --------------- if !dataSet.isDrawCirclesEnabled {