From 1691036c380f679e95f3e23773f3f5ac1a40fe7d Mon Sep 17 00:00:00 2001 From: Adi Date: Thu, 10 May 2018 12:56:57 -0400 Subject: [PATCH 01/17] Adds accessibility for Pie Charts. (#1060) This commit makes PieChartView adhere to the UIAccessibility container protocol. See the Accessibility section marked for the methods. To allow conformance, 3 properties are added to IPieChartDataSet and PieChartDataSet that enable a more user friendly audio descriptionfor data elements. PieChartRenderer has a new private method createAccessibleElement() that populates the accessiblePieChartElements property, which in turn is used in PieChartView. Note that to prevent contextless audio descriptions, the default Chart Description header text was deleted in Description.swift since it is already an optional. --- Source/Charts/Charts/PieChartView.swift | 266 +++++++------ Source/Charts/Components/Description.swift | 2 +- .../Standard/PieChartDataSet.swift | 48 ++- .../Data/Interfaces/IPieChartDataSet.swift | 42 +- .../Charts/Renderers/PieChartRenderer.swift | 375 +++++++++++------- 5 files changed, 440 insertions(+), 293 deletions(-) diff --git a/Source/Charts/Charts/PieChartView.swift b/Source/Charts/Charts/PieChartView.swift index 2c4ea8b5d5..79654abc20 100644 --- a/Source/Charts/Charts/PieChartView.swift +++ b/Source/Charts/Charts/PieChartView.swift @@ -13,7 +13,7 @@ import Foundation import CoreGraphics #if !os(OSX) - import UIKit +import UIKit #endif /// View that represents a pie chart. Draws cake like slices. @@ -21,54 +21,54 @@ open class PieChartView: PieRadarChartViewBase { /// rect object that represents the bounds of the piechart, needed for drawing the circle private var _circleBox = CGRect() - + /// flag indicating if entry labels should be drawn or not private var _drawEntryLabelsEnabled = true - + /// array that holds the width of each pie-slice in degrees private var _drawAngles = [CGFloat]() - + /// array that holds the absolute angle in degrees of each slice private var _absoluteAngles = [CGFloat]() - + /// if true, the hole inside the chart will be drawn private var _drawHoleEnabled = true - + private var _holeColor: NSUIColor? = NSUIColor.white - + /// Sets the color the entry labels are drawn with. private var _entryLabelColor: NSUIColor? = NSUIColor.white - + /// Sets the font the entry labels are drawn with. private var _entryLabelFont: NSUIFont? = NSUIFont(name: "HelveticaNeue", size: 13.0) - + /// if true, the hole will see-through to the inner tips of the slices private var _drawSlicesUnderHoleEnabled = false - + /// if true, the values inside the piechart are drawn as percent values private var _usePercentValuesEnabled = false - + /// variable for the text that is drawn in the center of the pie-chart private var _centerAttributedText: NSAttributedString? - + /// the offset on the x- and y-axis the center text has in dp. private var _centerTextOffset: CGPoint = CGPoint() - + /// indicates the size of the hole in the center of the piechart /// /// **default**: `0.5` private var _holeRadiusPercent = CGFloat(0.5) - + private var _transparentCircleColor: NSUIColor? = NSUIColor(white: 1.0, alpha: 105.0/255.0) - + /// the radius of the transparent circle next to the chart-hole in the center private var _transparentCircleRadiusPercent = CGFloat(0.55) - + /// if enabled, centertext is drawn private var _drawCenterTextEnabled = true - + private var _centerTextRadiusPercent: CGFloat = 1.0 - + /// maximum angle for this pie private var _maxAngle: CGFloat = 360.0 @@ -76,126 +76,148 @@ open class PieChartView: PieRadarChartViewBase { super.init(frame: frame) } - + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } - + internal override func initialize() { super.initialize() - + renderer = PieChartRenderer(chart: self, animator: _animator, viewPortHandler: _viewPortHandler) _xAxis = nil - + self.highlighter = PieHighlighter(chart: self) } - + + // MARK: - Accessibility + + open override var isAccessibilityElement: Bool { + get { return false } + set { } + } + + open override func accessibilityElementCount() -> Int { + return (self.renderer as? PieChartRenderer)?.accessiblePieChartElements.count ?? 0 + } + + open override func accessibilityElement(at index: Int) -> Any? { + return (self.renderer as? PieChartRenderer)?.accessiblePieChartElements[index] + } + + open override func index(ofAccessibilityElement element: Any) -> Int { + guard let axElement: UIAccessibilityElement = element as? UIAccessibilityElement else { return -1 } + return (self.renderer as? PieChartRenderer)?.accessiblePieChartElements.index(of: axElement) ?? -1 + } + + // MARK: - + open override func draw(_ rect: CGRect) { super.draw(rect) - + if _data === nil { return } - + let optionalContext = NSUIGraphicsGetCurrentContext() guard let context = optionalContext, let renderer = renderer else { return } - + renderer.drawData(context: context) - + if (valuesToHighlight()) { renderer.drawHighlighted(context: context, indices: _indicesToHighlight) } - + renderer.drawExtras(context: context) - + renderer.drawValues(context: context) - + legendRenderer.renderLegend(context: context) - + drawDescription(context: context) - + drawMarkers(context: context) } - + internal override func calculateOffsets() { super.calculateOffsets() - + // prevent nullpointer when no data set if _data === nil { return } - + let radius = diameter / 2.0 - + let c = self.centerOffsets - + let shift = (data as? PieChartData)?.dataSet?.selectionShift ?? 0.0 - + // create the circle box that will contain the pie-chart (the bounds of the pie-chart) _circleBox.origin.x = (c.x - radius) + shift _circleBox.origin.y = (c.y - radius) + shift _circleBox.size.width = diameter - shift * 2.0 _circleBox.size.height = diameter - shift * 2.0 } - + internal override func calcMinMax() { calcAngles() } - + open override func getMarkerPosition(highlight: Highlight) -> CGPoint { let center = self.centerCircleBox var r = self.radius - + var off = r / 10.0 * 3.6 - + if self.isDrawHoleEnabled { off = (r - (r * self.holeRadiusPercent)) / 2.0 } - + r -= off // offset to keep things inside the chart - + let rotationAngle = self.rotationAngle - + let entryIndex = Int(highlight.x) - + // offset needed to center the drawn text in the slice let offset = drawAngles[entryIndex] / 2.0 - + // calculate the text position let x: CGFloat = (r * cos(((rotationAngle + absoluteAngles[entryIndex] - offset) * CGFloat(_animator.phaseY)).DEG2RAD) + center.x) let y: CGFloat = (r * sin(((rotationAngle + absoluteAngles[entryIndex] - offset) * CGFloat(_animator.phaseY)).DEG2RAD) + center.y) - + return CGPoint(x: x, y: y) } - + /// calculates the needed angles for the chart slices private func calcAngles() { _drawAngles = [CGFloat]() _absoluteAngles = [CGFloat]() - + guard let data = _data else { return } let entryCount = data.entryCount - + _drawAngles.reserveCapacity(entryCount) _absoluteAngles.reserveCapacity(entryCount) - + let yValueSum = (_data as! PieChartData).yValueSum - + var dataSets = data.dataSets var cnt = 0 @@ -208,7 +230,7 @@ open class PieChartView: PieRadarChartViewBase for j in 0 ..< entryCount { guard let e = set.entryForIndex(j) else { continue } - + _drawAngles.append(calcAngle(value: abs(e.y), yValueSum: yValueSum)) if cnt == 0 @@ -224,7 +246,7 @@ open class PieChartView: PieRadarChartViewBase } } } - + /// Checks if the given index is set to be highlighted. @objc open func needsHighlight(index: Int) -> Bool { @@ -233,7 +255,7 @@ open class PieChartView: PieRadarChartViewBase { return false } - + for i in 0 ..< _indicesToHighlight.count { // check if the xvalue for the given dataset needs highlight @@ -242,28 +264,28 @@ open class PieChartView: PieRadarChartViewBase return true } } - + return false } - + /// calculates the needed angle for a given value private func calcAngle(_ value: Double) -> CGFloat { return calcAngle(value: value, yValueSum: (_data as! PieChartData).yValueSum) } - + /// calculates the needed angle for a given value private func calcAngle(value: Double, yValueSum: Double) -> CGFloat { return CGFloat(value) / CGFloat(yValueSum) * _maxAngle } - + /// This will throw an exception, PieChart has no XAxis object. open override var xAxis: XAxis { fatalError("PieChart has no XAxis") } - + open override func indexForAngle(_ angle: CGFloat) -> Int { // take the current angle of the chart into consideration @@ -275,15 +297,15 @@ open class PieChartView: PieRadarChartViewBase return i } } - + return -1 // return -1 if no index found } - + /// - returns: The index of the DataSet this x-index belongs to. @objc open func dataSetIndexForIndex(_ xValue: Double) -> Int { var dataSets = _data?.dataSets ?? [] - + for i in 0 ..< dataSets.count { if (dataSets[i].entryForXValue(xValue, closestToY: Double.nan) !== nil) @@ -291,10 +313,10 @@ open class PieChartView: PieRadarChartViewBase return i } } - + return -1 } - + /// - returns: An integer array of all the different angles the chart slices /// have the angles in the returned array determine how much space (of 360°) /// each slice takes @@ -309,12 +331,12 @@ open class PieChartView: PieRadarChartViewBase { return _absoluteAngles } - + /// The color for the hole that is drawn in the center of the PieChart (if enabled). - /// + /// /// - note: Use holeTransparent with holeColor = nil to make the hole transparent.* @objc open var holeColor: NSUIColor? - { + { get { return _holeColor @@ -325,12 +347,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// if true, the hole will see-through to the inner tips of the slices /// /// **default**: `false` @objc open var drawSlicesUnderHoleEnabled: Bool - { + { get { return _drawSlicesUnderHoleEnabled @@ -341,16 +363,16 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if the inner tips of the slices are visible behind the hole, `false` if not. @objc open var isDrawSlicesUnderHoleEnabled: Bool { return drawSlicesUnderHoleEnabled } - + /// `true` if the hole in the center of the pie-chart is set to be visible, `false` ifnot @objc open var drawHoleEnabled: Bool - { + { get { return _drawHoleEnabled @@ -361,19 +383,19 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if the hole in the center of the pie-chart is set to be visible, `false` ifnot @objc open var isDrawHoleEnabled: Bool - { + { get { return drawHoleEnabled } } - + /// the text that is displayed in the center of the pie-chart @objc open var centerText: String? - { + { get { return self.centerAttributedText?.string @@ -388,14 +410,14 @@ open class PieChartView: PieRadarChartViewBase else { #if os(OSX) - let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle - paragraphStyle.lineBreakMode = NSParagraphStyle.LineBreakMode.byTruncatingTail + let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle + paragraphStyle.lineBreakMode = NSParagraphStyle.LineBreakMode.byTruncatingTail #else - let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle - paragraphStyle.lineBreakMode = NSLineBreakMode.byTruncatingTail + let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle + paragraphStyle.lineBreakMode = NSLineBreakMode.byTruncatingTail #endif paragraphStyle.alignment = .center - + attrString = NSMutableAttributedString(string: newValue!) attrString?.setAttributes([ NSAttributedStringKey.foregroundColor: NSUIColor.black, @@ -406,10 +428,10 @@ open class PieChartView: PieRadarChartViewBase self.centerAttributedText = attrString } } - + /// the text that is displayed in the center of the pie-chart @objc open var centerAttributedText: NSAttributedString? - { + { get { return _centerAttributedText @@ -420,10 +442,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// Sets the offset the center text should have from it's original position in dp. Default x = 0, y = 0 @objc open var centerTextOffset: CGPoint - { + { get { return _centerTextOffset @@ -434,10 +456,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// `true` if drawing the center text is enabled @objc open var drawCenterTextEnabled: Bool - { + { get { return _drawCenterTextEnabled @@ -448,48 +470,48 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if drawing the center text is enabled @objc open var isDrawCenterTextEnabled: Bool - { + { get { return drawCenterTextEnabled } } - + internal override var requiredLegendOffset: CGFloat { return _legend.font.pointSize * 2.0 } - + internal override var requiredBaseOffset: CGFloat { return 0.0 } - + open override var radius: CGFloat { return _circleBox.width / 2.0 } - + /// - returns: The circlebox, the boundingbox of the pie-chart slices @objc open var circleBox: CGRect { return _circleBox } - + /// - returns: The center of the circlebox @objc open var centerCircleBox: CGPoint { return CGPoint(x: _circleBox.midX, y: _circleBox.midY) } - + /// the radius of the hole in the center of the piechart in percent of the maximum radius (max = the radius of the whole chart) - /// + /// /// **default**: 0.5 (50%) (half the pie) @objc open var holeRadiusPercent: CGFloat - { + { get { return _holeRadiusPercent @@ -500,12 +522,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The color that the transparent-circle should have. /// /// **default**: `nil` @objc open var transparentCircleColor: NSUIColor? - { + { get { return _transparentCircleColor @@ -516,12 +538,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// the radius of the transparent circle that is drawn next to the hole in the piechart in percent of the maximum radius (max = the radius of the whole chart) - /// + /// /// **default**: 0.55 (55%) -> means 5% larger than the center-hole by default @objc open var transparentCircleRadiusPercent: CGFloat - { + { get { return _transparentCircleRadiusPercent @@ -532,10 +554,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The color the entry labels are drawn with. @objc open var entryLabelColor: NSUIColor? - { + { get { return _entryLabelColor } set { @@ -543,10 +565,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The font the entry labels are drawn with. @objc open var entryLabelFont: NSUIFont? - { + { get { return _entryLabelFont } set { @@ -554,10 +576,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// Set this to true to draw the enrty labels into the pie slices @objc open var drawEntryLabelsEnabled: Bool - { + { get { return _drawEntryLabelsEnabled @@ -568,19 +590,19 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if drawing entry labels is enabled, `false` ifnot @objc open var isDrawEntryLabelsEnabled: Bool - { + { get { return drawEntryLabelsEnabled } } - + /// If this is enabled, values inside the PieChart are drawn in percent and not with their original value. Values provided for the ValueFormatter to format are then provided in percent. @objc open var usePercentValuesEnabled: Bool - { + { get { return _usePercentValuesEnabled @@ -591,19 +613,19 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if drawing x-values is enabled, `false` ifnot @objc open var isUsePercentValuesEnabled: Bool - { + { get { return usePercentValuesEnabled } } - + /// the rectangular radius of the bounding box for the center text, as a percentage of the pie hole @objc open var centerTextRadiusPercent: CGFloat - { + { get { return _centerTextRadiusPercent @@ -614,12 +636,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The max angle that is used for calculating the pie-circle. /// 360 means it's a full pie-chart, 180 results in a half-pie-chart. /// **default**: 360.0 @objc open var maxAngle: CGFloat - { + { get { return _maxAngle @@ -627,12 +649,12 @@ open class PieChartView: PieRadarChartViewBase set { _maxAngle = newValue - + if _maxAngle > 360.0 { _maxAngle = 360.0 } - + if _maxAngle < 90.0 { _maxAngle = 90.0 diff --git a/Source/Charts/Components/Description.swift b/Source/Charts/Components/Description.swift index c40577d9af..a063499e4f 100644 --- a/Source/Charts/Components/Description.swift +++ b/Source/Charts/Components/Description.swift @@ -34,7 +34,7 @@ open class Description: ComponentBase } /// The text to be shown as the description. - @objc open var text: String? = "Description Label" + @objc open var text: String? /// Custom position for the description text in pixels on the screen. open var position: CGPoint? = nil diff --git a/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift b/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift index af4aa617d0..c1b863df29 100644 --- a/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift +++ b/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift @@ -20,34 +20,34 @@ open class PieChartDataSet: ChartDataSet, IPieChartDataSet case insideSlice case outsideSlice } - + private func initialize() { self.valueTextColor = NSUIColor.white self.valueFont = NSUIFont.systemFont(ofSize: 13.0) } - + public required init() { super.init() initialize() } - + public override init(values: [ChartDataEntry]?, label: String?) { super.init(values: values, label: label) initialize() } - + internal override func calcMinMax(entry e: ChartDataEntry) { calcMinMaxY(entry: e) } - + // MARK: - Styling functions and accessors - + private var _sliceSpace = CGFloat(0.0) - + /// the space in pixels between the pie-slices /// **default**: 0 /// **maximum**: 20 @@ -74,42 +74,42 @@ open class PieChartDataSet: ChartDataSet, IPieChartDataSet /// When enabled, slice spacing will be 0.0 when the smallest value is going to be smaller than the slice spacing itself. open var automaticallyDisableSliceSpacing: Bool = false - + /// indicates the selection distance of a pie slice open var selectionShift = CGFloat(18.0) - + open var xValuePosition: ValuePosition = .insideSlice open var yValuePosition: ValuePosition = .insideSlice - + /// When valuePosition is OutsideSlice, indicates line color open var valueLineColor: NSUIColor? = NSUIColor.black - + /// When valuePosition is OutsideSlice, indicates line width open var valueLineWidth: CGFloat = 1.0 - + /// When valuePosition is OutsideSlice, indicates offset as percentage out of the slice size open var valueLinePart1OffsetPercentage: CGFloat = 0.75 - + /// When valuePosition is OutsideSlice, indicates length of first half of the line open var valueLinePart1Length: CGFloat = 0.3 - + /// When valuePosition is OutsideSlice, indicates length of second half of the line open var valueLinePart2Length: CGFloat = 0.4 - + /// When valuePosition is OutsideSlice, this allows variable line length open var valueLineVariableLength: Bool = true - + /// the font for the slice-text labels open var entryLabelFont: NSUIFont? = nil - + /// the color for the slice-text labels open var entryLabelColor: NSUIColor? = nil - + /// the color for the highlighted sector open var highlightColor: NSUIColor? = nil - + // MARK: - NSCopying - + open override func copyWithZone(_ zone: NSZone?) -> AnyObject { let copy = super.copyWithZone(zone) as! PieChartDataSet @@ -118,4 +118,12 @@ open class PieChartDataSet: ChartDataSet, IPieChartDataSet copy.highlightColor = highlightColor return copy } + + // MARK: - Accessibility + + open var accessibilityEntryLabelSuffixIsCount: Bool = false + + open var accessibilityEntryLabelPrefix: String? + + open var accessibilityEntryLabelSuffix: String? } diff --git a/Source/Charts/Data/Interfaces/IPieChartDataSet.swift b/Source/Charts/Data/Interfaces/IPieChartDataSet.swift index 1e027be0cb..bf3468b9a3 100644 --- a/Source/Charts/Data/Interfaces/IPieChartDataSet.swift +++ b/Source/Charts/Data/Interfaces/IPieChartDataSet.swift @@ -20,45 +20,61 @@ import CoreGraphics public protocol IPieChartDataSet: IChartDataSet { // MARK: - Styling functions and accessors - + /// the space in pixels between the pie-slices /// **default**: 0 /// **maximum**: 20 var sliceSpace: CGFloat { get set } - + /// When enabled, slice spacing will be 0.0 when the smallest value is going to be smaller than the slice spacing itself. var automaticallyDisableSliceSpacing: Bool { get set } - + /// indicates the selection distance of a pie slice var selectionShift: CGFloat { get set } - + var xValuePosition: PieChartDataSet.ValuePosition { get set } var yValuePosition: PieChartDataSet.ValuePosition { get set } - + /// When valuePosition is OutsideSlice, indicates line color var valueLineColor: NSUIColor? { get set } - + /// When valuePosition is OutsideSlice, indicates line width var valueLineWidth: CGFloat { get set } - + /// When valuePosition is OutsideSlice, indicates offset as percentage out of the slice size var valueLinePart1OffsetPercentage: CGFloat { get set } - + /// When valuePosition is OutsideSlice, indicates length of first half of the line var valueLinePart1Length: CGFloat { get set } - + /// When valuePosition is OutsideSlice, indicates length of second half of the line var valueLinePart2Length: CGFloat { get set } - + /// When valuePosition is OutsideSlice, this allows variable line length var valueLineVariableLength: Bool { get set } - + /// the font for the slice-text labels var entryLabelFont: NSUIFont? { get set } - + /// the color for the slice-text labels var entryLabelColor: NSUIColor? { get set } - + /// get/sets the color for the highlighted sector var highlightColor: NSUIColor? { get set } + + // MARK: - Accessibility + + /// When the data entry labels for slices are generated identifiers, set this property to prepend a string before each identifier + /// + /// For example, if a label is "#3", settings this property to "Item" allows it to be spoken as "Item #3" + var accessibilityEntryLabelPrefix: String? { get set } + + /// When the data entry value requires a unit, use this property to append the string representation of the unit to the value + /// + /// For example, if a value is "44.1", setting this property to "m" allows it to be spoken as "44.1 m" + var accessibilityEntryLabelSuffix: String? { get set } + + /// If the data entry value is a count, set this to true to allow plurals and other grammatical changes + /// **default**: false + var accessibilityEntryLabelSuffixIsCount: Bool { get set } } diff --git a/Source/Charts/Renderers/PieChartRenderer.swift b/Source/Charts/Renderers/PieChartRenderer.swift index a363da290d..d662582133 100644 --- a/Source/Charts/Renderers/PieChartRenderer.swift +++ b/Source/Charts/Renderers/PieChartRenderer.swift @@ -13,27 +13,28 @@ import Foundation import CoreGraphics #if !os(OSX) - import UIKit +import UIKit #endif - open class PieChartRenderer: DataRenderer { + open var accessiblePieChartElements: [UIAccessibilityElement] = [] + @objc open weak var chart: PieChartView? - + @objc public init(chart: PieChartView, animator: Animator, viewPortHandler: ViewPortHandler) { super.init(animator: animator, viewPortHandler: viewPortHandler) - + self.chart = chart } - + open override func drawData(context: CGContext) { guard let chart = chart else { return } - + let pieData = chart.data - + if pieData != nil { for set in pieData!.dataSets as! [IPieChartDataSet] @@ -45,7 +46,7 @@ open class PieChartRenderer: DataRenderer } } } - + @objc open func calculateMinimumRadiusForSpacedSlice( center: CGPoint, radius: CGFloat, @@ -56,37 +57,37 @@ open class PieChartRenderer: DataRenderer sweepAngle: CGFloat) -> CGFloat { let angleMiddle = startAngle + sweepAngle / 2.0 - + // Other point of the arc let arcEndPointX = center.x + radius * cos((startAngle + sweepAngle).DEG2RAD) let arcEndPointY = center.y + radius * sin((startAngle + sweepAngle).DEG2RAD) - + // Middle point on the arc let arcMidPointX = center.x + radius * cos(angleMiddle.DEG2RAD) let arcMidPointY = center.y + radius * sin(angleMiddle.DEG2RAD) - + // This is the base of the contained triangle let basePointsDistance = sqrt( pow(arcEndPointX - arcStartPointX, 2) + pow(arcEndPointY - arcStartPointY, 2)) - + // After reducing space from both sides of the "slice", // the angle of the contained triangle should stay the same. // So let's find out the height of that triangle. let containedTriangleHeight = (basePointsDistance / 2.0 * tan((180.0 - angle).DEG2RAD / 2.0)) - + // Now we subtract that from the radius var spacedRadius = radius - containedTriangleHeight - + // And now subtract the height of the arc that's between the triangle and the outer circle spacedRadius -= sqrt( pow(arcMidPointX - (arcEndPointX + arcStartPointX) / 2.0, 2) + pow(arcMidPointY - (arcEndPointY + arcStartPointY) / 2.0, 2)) - + return spacedRadius } - + /// Calculates the sliceSpace to use based on visible values and their size compared to the set sliceSpace. @objc open func getSliceSpace(dataSet: IPieChartDataSet) -> CGFloat { @@ -94,34 +95,37 @@ open class PieChartRenderer: DataRenderer dataSet.automaticallyDisableSliceSpacing, let data = chart?.data as? PieChartData else { return dataSet.sliceSpace } - + let spaceSizeRatio = dataSet.sliceSpace / min(viewPortHandler.contentWidth, viewPortHandler.contentHeight) let minValueRatio = dataSet.yMin / data.yValueSum * 2.0 - + let sliceSpace = spaceSizeRatio > CGFloat(minValueRatio) ? 0.0 : dataSet.sliceSpace - + return sliceSpace } @objc open func drawDataSet(context: CGContext, dataSet: IPieChartDataSet) { guard let chart = chart else {return } - + + // If we redraw the data, remove and repopulate accessible elements to update label values and frames + self.accessiblePieChartElements.removeAll() + var angle: CGFloat = 0.0 let rotationAngle = chart.rotationAngle - + let phaseX = animator.phaseX let phaseY = animator.phaseY - + let entryCount = dataSet.entryCount var drawAngles = chart.drawAngles let center = chart.centerCircleBox let radius = chart.radius let drawInnerArc = chart.drawHoleEnabled && !chart.drawSlicesUnderHoleEnabled let userInnerRadius = drawInnerArc ? radius * chart.holeRadiusPercent : 0.0 - + var visibleAngleCount = 0 for j in 0 ..< entryCount { @@ -131,27 +135,44 @@ open class PieChartRenderer: DataRenderer visibleAngleCount += 1 } } - + let sliceSpace = visibleAngleCount <= 1 ? 0.0 : getSliceSpace(dataSet: dataSet) context.saveGState() - + + // Make the chart header the first element in the accessible elements array + if let container = self.chart { + // NOTE: - Since we want to summarize the total count of slices/portions/elements, use a default string here + // This is unlike when we are naming individual slices, wherein it's alright to not use a prefix as descriptor. + // i.e. We want to VO to say "3 Elements" even if the developer didn't specify an accessibility prefix + // If prefix is unspecified it is safe to assume they did not want to use "Element 1", so that uses a default empty string + let prefix: String = dataSet.accessibilityEntryLabelPrefix ?? "Element" + let description = container.chartDescription?.text ?? dataSet.label ?? container.centerText ?? "" + + let + element: UIAccessibilityElement = UIAccessibilityElement(accessibilityContainer: container) + element.accessibilityLabel = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" + element.accessibilityFrame = container.convert(container.bounds, to: UIScreen.main.fixedCoordinateSpace) + element.accessibilityTraits = UIAccessibilityTraitHeader + self.accessiblePieChartElements.append(element) + } + for j in 0 ..< entryCount { let sliceAngle = drawAngles[j] var innerRadius = userInnerRadius - + guard let e = dataSet.entryForIndex(j) else { continue } - + // draw only if the value is greater than zero if (abs(e.y) > Double.ulpOfOne) { if !chart.needsHighlight(index: j) { let accountForSliceSpacing = sliceSpace > 0.0 && sliceAngle <= 180.0 - + context.setFillColor(dataSet.color(atIndex: j).cgColor) - + let sliceSpaceAngleOuter = visibleAngleCount == 1 ? 0.0 : sliceSpace / radius.DEG2RAD @@ -161,15 +182,15 @@ open class PieChartRenderer: DataRenderer { sweepAngleOuter = 0.0 } - + let arcStartPointX = center.x + radius * cos(startAngleOuter.DEG2RAD) let arcStartPointY = center.y + radius * sin(startAngleOuter.DEG2RAD) let path = CGMutablePath() - + path.move(to: CGPoint(x: arcStartPointX, y: arcStartPointY)) - + path.addRelativeArc(center: center, radius: radius, startAngle: startAngleOuter.DEG2RAD, delta: sweepAngleOuter.DEG2RAD) if drawInnerArc && @@ -191,7 +212,7 @@ open class PieChartRenderer: DataRenderer } innerRadius = min(max(innerRadius, minSpacedRadius), radius) } - + let sliceSpaceAngleInner = visibleAngleCount == 1 || innerRadius == 0.0 ? 0.0 : sliceSpace / innerRadius.DEG2RAD @@ -202,12 +223,12 @@ open class PieChartRenderer: DataRenderer sweepAngleInner = 0.0 } let endAngleInner = startAngleInner + sweepAngleInner - + path.addLine( to: CGPoint( x: center.x + innerRadius * cos(endAngleInner.DEG2RAD), y: center.y + innerRadius * sin(endAngleInner.DEG2RAD))) - + path.addRelativeArc(center: center, radius: innerRadius, startAngle: endAngleInner.DEG2RAD, delta: -sweepAngleInner.DEG2RAD) } else @@ -215,7 +236,7 @@ open class PieChartRenderer: DataRenderer if accountForSliceSpacing { let angleMiddle = startAngleOuter + sweepAngleOuter / 2.0 - + let sliceSpaceOffset = calculateMinimumRadiusForSpacedSlice( center: center, @@ -228,7 +249,7 @@ open class PieChartRenderer: DataRenderer let arcEndPointX = center.x + sliceSpaceOffset * cos(angleMiddle.DEG2RAD) let arcEndPointY = center.y + sliceSpaceOffset * sin(angleMiddle.DEG2RAD) - + path.addLine( to: CGPoint( x: arcEndPointX, @@ -239,90 +260,106 @@ open class PieChartRenderer: DataRenderer path.addLine(to: center) } } - + path.closeSubpath() - + context.beginPath() context.addPath(path) context.fillPath(using: .evenOdd) + + if let container = self.chart { + + let axElement = self.createAccessibleElement(withIndex: j, + container: container, + dataSet: dataSet) + { (element) in + element.accessibilityFrame = container.convert(path.boundingBoxOfPath, + to: UIScreen.main.coordinateSpace) + } + + self.accessiblePieChartElements.append(axElement) + } } } - + angle += sliceAngle * CGFloat(phaseX) } - + + // Post this notification to let VoiceOver account for the redrawn frames + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil) + context.restoreGState() } - + open override func drawValues(context: CGContext) { guard let chart = chart, let data = chart.data else { return } - + let center = chart.centerCircleBox - + // get whole the radius let radius = chart.radius let rotationAngle = chart.rotationAngle var drawAngles = chart.drawAngles var absoluteAngles = chart.absoluteAngles - + let phaseX = animator.phaseX let phaseY = animator.phaseY - + var labelRadiusOffset = radius / 10.0 * 3.0 - + if chart.drawHoleEnabled { labelRadiusOffset = (radius - (radius * chart.holeRadiusPercent)) / 2.0 } - + let labelRadius = radius - labelRadiusOffset - + var dataSets = data.dataSets - + let yValueSum = (data as! PieChartData).yValueSum - + let drawEntryLabels = chart.isDrawEntryLabelsEnabled let usePercentValuesEnabled = chart.usePercentValuesEnabled let entryLabelColor = chart.entryLabelColor let entryLabelFont = chart.entryLabelFont - + var angle: CGFloat = 0.0 var xIndex = 0 - + context.saveGState() defer { context.restoreGState() } - + for i in 0 ..< dataSets.count { guard let dataSet = dataSets[i] as? IPieChartDataSet else { continue } - + let drawValues = dataSet.isDrawValuesEnabled - + if !drawValues && !drawEntryLabels && !dataSet.isDrawIconsEnabled { continue } - + let iconsOffset = dataSet.iconsOffset - + let xValuePosition = dataSet.xValuePosition let yValuePosition = dataSet.yValuePosition - + let valueFont = dataSet.valueFont let entryLabelFont = dataSet.entryLabelFont let lineHeight = valueFont.lineHeight - + guard let formatter = dataSet.valueFormatter else { continue } - + for j in 0 ..< dataSet.entryCount { guard let e = dataSet.entryForIndex(j) else { continue } let pe = e as? PieChartDataEntry - + if xIndex == 0 { angle = 0.0 @@ -331,48 +368,48 @@ open class PieChartRenderer: DataRenderer { angle = absoluteAngles[xIndex - 1] * CGFloat(phaseX) } - + let sliceAngle = drawAngles[xIndex] let sliceSpace = getSliceSpace(dataSet: dataSet) let sliceSpaceMiddleAngle = sliceSpace / labelRadius.DEG2RAD - + // offset needed to center the drawn text in the slice let angleOffset = (sliceAngle - sliceSpaceMiddleAngle / 2.0) / 2.0 angle = angle + angleOffset - + let transformedAngle = rotationAngle + angle * CGFloat(phaseY) - + let value = usePercentValuesEnabled ? e.y / yValueSum * 100.0 : e.y let valueText = formatter.stringForValue( value, entry: e, dataSetIndex: i, viewPortHandler: viewPortHandler) - + let sliceXBase = cos(transformedAngle.DEG2RAD) let sliceYBase = sin(transformedAngle.DEG2RAD) - + let drawXOutside = drawEntryLabels && xValuePosition == .outsideSlice let drawYOutside = drawValues && yValuePosition == .outsideSlice let drawXInside = drawEntryLabels && xValuePosition == .insideSlice let drawYInside = drawValues && yValuePosition == .insideSlice - + let valueTextColor = dataSet.valueTextColorAt(j) let entryLabelColor = dataSet.entryLabelColor - + if drawXOutside || drawYOutside { let valueLineLength1 = dataSet.valueLinePart1Length let valueLineLength2 = dataSet.valueLinePart2Length let valueLinePart1OffsetPercentage = dataSet.valueLinePart1OffsetPercentage - + var pt2: CGPoint var labelPoint: CGPoint var align: NSTextAlignment - + var line1Radius: CGFloat - + if chart.drawHoleEnabled { line1Radius = (radius - (radius * chart.holeRadiusPercent)) * valueLinePart1OffsetPercentage + (radius * chart.holeRadiusPercent) @@ -381,19 +418,19 @@ open class PieChartRenderer: DataRenderer { line1Radius = radius * valueLinePart1OffsetPercentage } - + let polyline2Length = dataSet.valueLineVariableLength ? labelRadius * valueLineLength2 * abs(sin(transformedAngle.DEG2RAD)) : labelRadius * valueLineLength2 - + let pt0 = CGPoint( x: line1Radius * sliceXBase + center.x, y: line1Radius * sliceYBase + center.y) - + let pt1 = CGPoint( x: labelRadius * (1 + valueLineLength1) * sliceXBase + center.x, y: labelRadius * (1 + valueLineLength1) * sliceYBase + center.y) - + if transformedAngle.truncatingRemainder(dividingBy: 360.0) >= 90.0 && transformedAngle.truncatingRemainder(dividingBy: 360.0) <= 270.0 { pt2 = CGPoint(x: pt1.x - polyline2Length, y: pt1.y) @@ -406,19 +443,19 @@ open class PieChartRenderer: DataRenderer align = .left labelPoint = CGPoint(x: pt2.x + 5, y: pt2.y - lineHeight) } - + if dataSet.valueLineColor != nil { context.setStrokeColor(dataSet.valueLineColor!.cgColor) context.setLineWidth(dataSet.valueLineWidth) - + context.move(to: CGPoint(x: pt0.x, y: pt0.y)) context.addLine(to: CGPoint(x: pt1.x, y: pt1.y)) context.addLine(to: CGPoint(x: pt2.x, y: pt2.y)) - + context.drawPath(using: CGPathDrawingMode.stroke) } - + if drawXOutside && drawYOutside { ChartUtils.drawText( @@ -428,7 +465,7 @@ open class PieChartRenderer: DataRenderer align: align, attributes: [NSAttributedStringKey.font: valueFont, NSAttributedStringKey.foregroundColor: valueTextColor] ) - + if j < data.entryCount && pe?.label != nil { ChartUtils.drawText( @@ -468,13 +505,13 @@ open class PieChartRenderer: DataRenderer ) } } - + if drawXInside || drawYInside { // calculate the text position let x = labelRadius * sliceXBase + center.x let y = labelRadius * sliceYBase + center.y - lineHeight - + if drawXInside && drawYInside { ChartUtils.drawText( @@ -484,7 +521,7 @@ open class PieChartRenderer: DataRenderer align: .center, attributes: [NSAttributedStringKey.font: valueFont, NSAttributedStringKey.foregroundColor: valueTextColor] ) - + if j < data.entryCount && pe?.label != nil { ChartUtils.drawText( @@ -524,15 +561,15 @@ open class PieChartRenderer: DataRenderer ) } } - + if let icon = e.icon, dataSet.isDrawIconsEnabled { // calculate the icon's position - + let x = (labelRadius + iconsOffset.y) * sliceXBase + center.x var y = (labelRadius + iconsOffset.y) * sliceYBase + center.y y += iconsOffset.x - + ChartUtils.drawImage(context: context, image: icon, x: x, @@ -544,26 +581,26 @@ open class PieChartRenderer: DataRenderer } } } - + open override func drawExtras(context: CGContext) { drawHole(context: context) drawCenterText(context: context) } - + /// draws the hole in the center of the chart and the transparent circle / hole private func drawHole(context: CGContext) { guard let chart = chart else { return } - + if chart.drawHoleEnabled { context.saveGState() - + let radius = chart.radius let holeRadius = radius * chart.holeRadiusPercent let center = chart.centerCircleBox - + if let holeColor = chart.holeColor { if holeColor != NSUIColor.clear @@ -573,7 +610,7 @@ open class PieChartRenderer: DataRenderer context.fillEllipse(in: CGRect(x: center.x - holeRadius, y: center.y - holeRadius, width: holeRadius * 2.0, height: holeRadius * 2.0)) } } - + // only draw the circle if it can be seen (not covered by the hole) if let transparentCircleColor = chart.transparentCircleColor { @@ -582,11 +619,11 @@ open class PieChartRenderer: DataRenderer { let alpha = animator.phaseX * animator.phaseY let secondHoleRadius = radius * chart.transparentCircleRadiusPercent - + // make transparent context.setAlpha(CGFloat(alpha)) context.setFillColor(transparentCircleColor.cgColor) - + // draw the transparent-circle context.beginPath() context.addEllipse(in: CGRect( @@ -602,11 +639,11 @@ open class PieChartRenderer: DataRenderer context.fillPath(using: .evenOdd) } } - + context.restoreGState() } } - + /// draws the description text in the center of the pie chart makes most sense when center-hole is enabled private func drawCenterText(context: CGContext) { @@ -614,70 +651,73 @@ open class PieChartRenderer: DataRenderer let chart = chart, let centerAttributedText = chart.centerAttributedText else { return } - + if chart.drawCenterTextEnabled && centerAttributedText.length > 0 { let center = chart.centerCircleBox let offset = chart.centerTextOffset let innerRadius = chart.drawHoleEnabled && !chart.drawSlicesUnderHoleEnabled ? chart.radius * chart.holeRadiusPercent : chart.radius - + let x = center.x + offset.x let y = center.y + offset.y - + let holeRect = CGRect( x: x - innerRadius, y: y - innerRadius, width: innerRadius * 2.0, height: innerRadius * 2.0) var boundingRect = holeRect - + if chart.centerTextRadiusPercent > 0.0 { boundingRect = boundingRect.insetBy(dx: (boundingRect.width - boundingRect.width * chart.centerTextRadiusPercent) / 2.0, dy: (boundingRect.height - boundingRect.height * chart.centerTextRadiusPercent) / 2.0) } - + let textBounds = centerAttributedText.boundingRect(with: boundingRect.size, options: [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine], context: nil) - + var drawingRect = boundingRect drawingRect.origin.x += (boundingRect.size.width - textBounds.size.width) / 2.0 drawingRect.origin.y += (boundingRect.size.height - textBounds.size.height) / 2.0 drawingRect.size = textBounds.size - + context.saveGState() let clippingPath = CGPath(ellipseIn: holeRect, transform: nil) context.beginPath() context.addPath(clippingPath) context.clip() - + centerAttributedText.draw(with: drawingRect, options: [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine], context: nil) - + context.restoreGState() } } - + open override func drawHighlighted(context: CGContext, indices: [Highlight]) { guard let chart = chart, let data = chart.data else { return } - + context.saveGState() - + let phaseX = animator.phaseX let phaseY = animator.phaseY - + var angle: CGFloat = 0.0 let rotationAngle = chart.rotationAngle - + var drawAngles = chart.drawAngles var absoluteAngles = chart.absoluteAngles let center = chart.centerCircleBox let radius = chart.radius let drawInnerArc = chart.drawHoleEnabled && !chart.drawSlicesUnderHoleEnabled let userInnerRadius = drawInnerArc ? radius * chart.holeRadiusPercent : 0.0 - + + // Append highlighted accessibility slices into this array, so we can prioritize them over unselected slices + var highlightedAccessibleElements: [UIAccessibilityElement] = [] + for i in 0 ..< indices.count { // get the index to highlight @@ -686,9 +726,9 @@ open class PieChartRenderer: DataRenderer { continue } - + guard let set = data.getDataSetByIndex(indices[i].dataSetIndex) as? IPieChartDataSet else { continue } - + if !set.isHighlightEnabled { continue @@ -704,7 +744,7 @@ open class PieChartRenderer: DataRenderer visibleAngleCount += 1 } } - + if index == 0 { angle = 0.0 @@ -713,49 +753,49 @@ open class PieChartRenderer: DataRenderer { angle = absoluteAngles[index - 1] * CGFloat(phaseX) } - + let sliceSpace = visibleAngleCount <= 1 ? 0.0 : set.sliceSpace - + let sliceAngle = drawAngles[index] var innerRadius = userInnerRadius - + let shift = set.selectionShift let highlightedRadius = radius + shift - + let accountForSliceSpacing = sliceSpace > 0.0 && sliceAngle <= 180.0 - + context.setFillColor(set.highlightColor?.cgColor ?? set.color(atIndex: index).cgColor) let sliceSpaceAngleOuter = visibleAngleCount == 1 ? 0.0 : sliceSpace / radius.DEG2RAD - + let sliceSpaceAngleShifted = visibleAngleCount == 1 ? 0.0 : sliceSpace / highlightedRadius.DEG2RAD - + let startAngleOuter = rotationAngle + (angle + sliceSpaceAngleOuter / 2.0) * CGFloat(phaseY) var sweepAngleOuter = (sliceAngle - sliceSpaceAngleOuter) * CGFloat(phaseY) if sweepAngleOuter < 0.0 { sweepAngleOuter = 0.0 } - + let startAngleShifted = rotationAngle + (angle + sliceSpaceAngleShifted / 2.0) * CGFloat(phaseY) var sweepAngleShifted = (sliceAngle - sliceSpaceAngleShifted) * CGFloat(phaseY) if sweepAngleShifted < 0.0 { sweepAngleShifted = 0.0 } - + let path = CGMutablePath() - + path.move(to: CGPoint(x: center.x + highlightedRadius * cos(startAngleShifted.DEG2RAD), y: center.y + highlightedRadius * sin(startAngleShifted.DEG2RAD))) - + path.addRelativeArc(center: center, radius: highlightedRadius, startAngle: startAngleShifted.DEG2RAD, delta: sweepAngleShifted.DEG2RAD) - + var sliceSpaceRadius: CGFloat = 0.0 if accountForSliceSpacing { @@ -768,7 +808,7 @@ open class PieChartRenderer: DataRenderer startAngle: startAngleOuter, sweepAngle: sweepAngleOuter) } - + if drawInnerArc && (innerRadius > 0.0 || accountForSliceSpacing) { @@ -781,7 +821,7 @@ open class PieChartRenderer: DataRenderer } innerRadius = min(max(innerRadius, minSpacedRadius), radius) } - + let sliceSpaceAngleInner = visibleAngleCount == 1 || innerRadius == 0.0 ? 0.0 : sliceSpace / innerRadius.DEG2RAD @@ -792,12 +832,12 @@ open class PieChartRenderer: DataRenderer sweepAngleInner = 0.0 } let endAngleInner = startAngleInner + sweepAngleInner - + path.addLine( to: CGPoint( x: center.x + innerRadius * cos(endAngleInner.DEG2RAD), y: center.y + innerRadius * sin(endAngleInner.DEG2RAD))) - + path.addRelativeArc(center: center, radius: innerRadius, startAngle: endAngleInner.DEG2RAD, delta: -sweepAngleInner.DEG2RAD) @@ -807,10 +847,10 @@ open class PieChartRenderer: DataRenderer if accountForSliceSpacing { let angleMiddle = startAngleOuter + sweepAngleOuter / 2.0 - + let arcEndPointX = center.x + sliceSpaceRadius * cos(angleMiddle.DEG2RAD) let arcEndPointY = center.y + sliceSpaceRadius * sin(angleMiddle.DEG2RAD) - + path.addLine( to: CGPoint( x: arcEndPointX, @@ -821,14 +861,75 @@ open class PieChartRenderer: DataRenderer path.addLine(to: center) } } - + path.closeSubpath() - + context.beginPath() context.addPath(path) context.fillPath(using: .evenOdd) + + if let container = self.chart { + + let axElement = self.createAccessibleElement(withIndex: index, + container: container, + dataSet: set) + { (element) in + element.accessibilityFrame = container.convert(path.boundingBoxOfPath, + to: UIScreen.main.coordinateSpace) + element.accessibilityTraits = UIAccessibilityTraitSelected + } + + highlightedAccessibleElements.append(axElement) + } } - + + // Prepend selected slices before the already rendered unselected ones. + // NOTE: - This relies on drawDataSet() being called before drawHighlighted in PieChartView. + self.accessiblePieChartElements.insert(contentsOf: highlightedAccessibleElements, at: 1) + context.restoreGState() } + + /// Creates a UIAccessibleElement representing a slice of the PieChart. + /// The element only has it's container and label set based on the chart and dataSet. Use the modifier to alter traits and frame. + private func createAccessibleElement(withIndex idx: Int, + container: PieChartView, + dataSet: IPieChartDataSet, + modifier: (UIAccessibilityElement) -> ()) -> UIAccessibilityElement { + + let element: UIAccessibilityElement = UIAccessibilityElement(accessibilityContainer: container) + + guard let e = dataSet.entryForIndex(idx) else { return element } + guard let formatter = dataSet.valueFormatter else { return element } + + var elementValueText: String = formatter.stringForValue( + e.y, + entry: e, + dataSetIndex: idx, + viewPortHandler: viewPortHandler) + + if container.usePercentValuesEnabled { + if let data = container.data as? PieChartData { + let value = e.y / data.yValueSum * 100.0 + let valueText = formatter.stringForValue( + value, + entry: e, + dataSetIndex: idx, + viewPortHandler: viewPortHandler) + + elementValueText = valueText + } + } + + let pieChartDataEntry = (dataSet.entryForIndex(idx) as? PieChartDataEntry) + let isCount = dataSet.accessibilityEntryLabelSuffixIsCount + let prefix = dataSet.accessibilityEntryLabelPrefix?.appending("\(idx + 1)") ?? pieChartDataEntry?.label ?? "" + let suffix = dataSet.accessibilityEntryLabelSuffix ?? "" + element.accessibilityLabel = "\(prefix) : \(elementValueText) \(suffix + (isCount ? (e.y == 1.0 ? "" : "s") : "") )" + + // The modifier allows changing of traits and frame depending on highlight, rotation, etc + modifier(element) + + return element + } } From 2f00321306c29d08693ab36dd2fc9fcc63ccda3e Mon Sep 17 00:00:00 2001 From: Adi Date: Sat, 12 May 2018 17:33:42 -0400 Subject: [PATCH 02/17] Formatting & cleanup in PieChartRenderer. (#1060) Minor changes to spacing in PieChartRenderer.swift. Removed formatting and use of "self." to match library style. --- .../Charts/Renderers/PieChartRenderer.swift | 79 +++++++++---------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/Source/Charts/Renderers/PieChartRenderer.swift b/Source/Charts/Renderers/PieChartRenderer.swift index d662582133..dc325c3f1d 100644 --- a/Source/Charts/Renderers/PieChartRenderer.swift +++ b/Source/Charts/Renderers/PieChartRenderer.swift @@ -13,7 +13,7 @@ import Foundation import CoreGraphics #if !os(OSX) -import UIKit + import UIKit #endif open class PieChartRenderer: DataRenderer @@ -37,6 +37,9 @@ open class PieChartRenderer: DataRenderer if pieData != nil { + // If we redraw the data, remove and repopulate accessible elements to update label values and frames + accessiblePieChartElements.removeAll() + for set in pieData!.dataSets as! [IPieChartDataSet] { if set.isVisible && set.entryCount > 0 @@ -110,9 +113,6 @@ open class PieChartRenderer: DataRenderer { guard let chart = chart else {return } - // If we redraw the data, remove and repopulate accessible elements to update label values and frames - self.accessiblePieChartElements.removeAll() - var angle: CGFloat = 0.0 let rotationAngle = chart.rotationAngle @@ -141,21 +141,20 @@ open class PieChartRenderer: DataRenderer context.saveGState() // Make the chart header the first element in the accessible elements array - if let container = self.chart { - // NOTE: - Since we want to summarize the total count of slices/portions/elements, use a default string here - // This is unlike when we are naming individual slices, wherein it's alright to not use a prefix as descriptor. - // i.e. We want to VO to say "3 Elements" even if the developer didn't specify an accessibility prefix - // If prefix is unspecified it is safe to assume they did not want to use "Element 1", so that uses a default empty string - let prefix: String = dataSet.accessibilityEntryLabelPrefix ?? "Element" - let description = container.chartDescription?.text ?? dataSet.label ?? container.centerText ?? "" - - let - element: UIAccessibilityElement = UIAccessibilityElement(accessibilityContainer: container) - element.accessibilityLabel = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" - element.accessibilityFrame = container.convert(container.bounds, to: UIScreen.main.fixedCoordinateSpace) - element.accessibilityTraits = UIAccessibilityTraitHeader - self.accessiblePieChartElements.append(element) - } + // We can do this in drawDataSet, since we know PieChartView can have only 1 dataSet + // NOTE: - Since we want to summarize the total count of slices/portions/elements, use a default string here + // This is unlike when we are naming individual slices, wherein it's alright to not use a prefix as descriptor. + // i.e. We want to VO to say "3 Elements" even if the developer didn't specify an accessibility prefix + // If prefix is unspecified it is safe to assume they did not want to use "Element 1", so that uses a default empty string + let prefix: String = dataSet.accessibilityEntryLabelPrefix ?? "Element" + let description = chart.chartDescription?.text ?? dataSet.label ?? chart.centerText ?? "" + + let + element: UIAccessibilityElement = UIAccessibilityElement(accessibilityContainer: chart) + element.accessibilityLabel = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" + element.accessibilityFrame = chart.convert(chart.bounds, to: UIScreen.main.fixedCoordinateSpace) + element.accessibilityTraits = UIAccessibilityTraitHeader + accessiblePieChartElements.append(element) for j in 0 ..< entryCount { @@ -267,18 +266,15 @@ open class PieChartRenderer: DataRenderer context.addPath(path) context.fillPath(using: .evenOdd) - if let container = self.chart { - - let axElement = self.createAccessibleElement(withIndex: j, - container: container, - dataSet: dataSet) - { (element) in - element.accessibilityFrame = container.convert(path.boundingBoxOfPath, - to: UIScreen.main.coordinateSpace) - } - - self.accessiblePieChartElements.append(axElement) + let axElement = createAccessibleElement(withIndex: j, + container: chart, + dataSet: dataSet) + { (element) in + element.accessibilityFrame = chart.convert(path.boundingBoxOfPath, + to: UIScreen.main.coordinateSpace) } + + accessiblePieChartElements.append(axElement) } } @@ -868,24 +864,21 @@ open class PieChartRenderer: DataRenderer context.addPath(path) context.fillPath(using: .evenOdd) - if let container = self.chart { - - let axElement = self.createAccessibleElement(withIndex: index, - container: container, - dataSet: set) - { (element) in - element.accessibilityFrame = container.convert(path.boundingBoxOfPath, - to: UIScreen.main.coordinateSpace) - element.accessibilityTraits = UIAccessibilityTraitSelected - } - - highlightedAccessibleElements.append(axElement) + let axElement = createAccessibleElement(withIndex: index, + container: chart, + dataSet: set) + { (element) in + element.accessibilityFrame = chart.convert(path.boundingBoxOfPath, + to: UIScreen.main.coordinateSpace) + element.accessibilityTraits = UIAccessibilityTraitSelected } + + highlightedAccessibleElements.append(axElement) } // Prepend selected slices before the already rendered unselected ones. // NOTE: - This relies on drawDataSet() being called before drawHighlighted in PieChartView. - self.accessiblePieChartElements.insert(contentsOf: highlightedAccessibleElements, at: 1) + accessiblePieChartElements.insert(contentsOf: highlightedAccessibleElements, at: 1) context.restoreGState() } From 74b418acb2c04f134a8872d55a9eacf5940b9058 Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 13 May 2018 21:43:09 -0400 Subject: [PATCH 03/17] Added accessibility to base classes. (#1060) Updated ChartViewBase, ChartData and ChartDataRendererBase to declare the primary properties required for accessibility support within the Charts library. This includes the UIAccessibility protocol methods within ChartViewBase and the internal accessibleChartElements property in the base renderer. ChartData also has 3 optional properties to allow proper formatting of audio. --- Source/Charts/Charts/ChartViewBase.swift | 20 ++ Source/Charts/Charts/PieChartView.swift | 266 ++++++++---------- .../Implementations/Standard/ChartData.swift | 16 ++ .../Standard/PieChartDataSet.swift | 8 - .../Data/Interfaces/IPieChartDataSet.swift | 15 - .../Renderers/ChartDataRendererBase.swift | 3 + .../Charts/Renderers/PieChartRenderer.swift | 39 ++- 7 files changed, 179 insertions(+), 188 deletions(-) diff --git a/Source/Charts/Charts/ChartViewBase.swift b/Source/Charts/Charts/ChartViewBase.swift index e82918f4de..94d80b591e 100644 --- a/Source/Charts/Charts/ChartViewBase.swift +++ b/Source/Charts/Charts/ChartViewBase.swift @@ -367,6 +367,26 @@ open class ChartViewBase: NSUIView, ChartDataProvider, AnimatorDelegate attributes: attrs) } + // MARK: - Accessibility + + open override var isAccessibilityElement: Bool { + get { return false } + set { } + } + + open override func accessibilityElementCount() -> Int { + return renderer?.accessibleChartElements.count ?? 0 + } + + open override func accessibilityElement(at index: Int) -> Any? { + return renderer?.accessibleChartElements[index] + } + + open override func index(ofAccessibilityElement element: Any) -> Int { + guard let axElement: UIAccessibilityElement = element as? UIAccessibilityElement else { return -1 } + return renderer?.accessibleChartElements.index(of: axElement) ?? -1 + } + // MARK: - Highlighting /// - returns: The array of currently highlighted values. This might an empty if nothing is highlighted. diff --git a/Source/Charts/Charts/PieChartView.swift b/Source/Charts/Charts/PieChartView.swift index 79654abc20..2c4ea8b5d5 100644 --- a/Source/Charts/Charts/PieChartView.swift +++ b/Source/Charts/Charts/PieChartView.swift @@ -13,7 +13,7 @@ import Foundation import CoreGraphics #if !os(OSX) -import UIKit + import UIKit #endif /// View that represents a pie chart. Draws cake like slices. @@ -21,54 +21,54 @@ open class PieChartView: PieRadarChartViewBase { /// rect object that represents the bounds of the piechart, needed for drawing the circle private var _circleBox = CGRect() - + /// flag indicating if entry labels should be drawn or not private var _drawEntryLabelsEnabled = true - + /// array that holds the width of each pie-slice in degrees private var _drawAngles = [CGFloat]() - + /// array that holds the absolute angle in degrees of each slice private var _absoluteAngles = [CGFloat]() - + /// if true, the hole inside the chart will be drawn private var _drawHoleEnabled = true - + private var _holeColor: NSUIColor? = NSUIColor.white - + /// Sets the color the entry labels are drawn with. private var _entryLabelColor: NSUIColor? = NSUIColor.white - + /// Sets the font the entry labels are drawn with. private var _entryLabelFont: NSUIFont? = NSUIFont(name: "HelveticaNeue", size: 13.0) - + /// if true, the hole will see-through to the inner tips of the slices private var _drawSlicesUnderHoleEnabled = false - + /// if true, the values inside the piechart are drawn as percent values private var _usePercentValuesEnabled = false - + /// variable for the text that is drawn in the center of the pie-chart private var _centerAttributedText: NSAttributedString? - + /// the offset on the x- and y-axis the center text has in dp. private var _centerTextOffset: CGPoint = CGPoint() - + /// indicates the size of the hole in the center of the piechart /// /// **default**: `0.5` private var _holeRadiusPercent = CGFloat(0.5) - + private var _transparentCircleColor: NSUIColor? = NSUIColor(white: 1.0, alpha: 105.0/255.0) - + /// the radius of the transparent circle next to the chart-hole in the center private var _transparentCircleRadiusPercent = CGFloat(0.55) - + /// if enabled, centertext is drawn private var _drawCenterTextEnabled = true - + private var _centerTextRadiusPercent: CGFloat = 1.0 - + /// maximum angle for this pie private var _maxAngle: CGFloat = 360.0 @@ -76,148 +76,126 @@ open class PieChartView: PieRadarChartViewBase { super.init(frame: frame) } - + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } - + internal override func initialize() { super.initialize() - + renderer = PieChartRenderer(chart: self, animator: _animator, viewPortHandler: _viewPortHandler) _xAxis = nil - + self.highlighter = PieHighlighter(chart: self) } - - // MARK: - Accessibility - - open override var isAccessibilityElement: Bool { - get { return false } - set { } - } - - open override func accessibilityElementCount() -> Int { - return (self.renderer as? PieChartRenderer)?.accessiblePieChartElements.count ?? 0 - } - - open override func accessibilityElement(at index: Int) -> Any? { - return (self.renderer as? PieChartRenderer)?.accessiblePieChartElements[index] - } - - open override func index(ofAccessibilityElement element: Any) -> Int { - guard let axElement: UIAccessibilityElement = element as? UIAccessibilityElement else { return -1 } - return (self.renderer as? PieChartRenderer)?.accessiblePieChartElements.index(of: axElement) ?? -1 - } - - // MARK: - - + open override func draw(_ rect: CGRect) { super.draw(rect) - + if _data === nil { return } - + let optionalContext = NSUIGraphicsGetCurrentContext() guard let context = optionalContext, let renderer = renderer else { return } - + renderer.drawData(context: context) - + if (valuesToHighlight()) { renderer.drawHighlighted(context: context, indices: _indicesToHighlight) } - + renderer.drawExtras(context: context) - + renderer.drawValues(context: context) - + legendRenderer.renderLegend(context: context) - + drawDescription(context: context) - + drawMarkers(context: context) } - + internal override func calculateOffsets() { super.calculateOffsets() - + // prevent nullpointer when no data set if _data === nil { return } - + let radius = diameter / 2.0 - + let c = self.centerOffsets - + let shift = (data as? PieChartData)?.dataSet?.selectionShift ?? 0.0 - + // create the circle box that will contain the pie-chart (the bounds of the pie-chart) _circleBox.origin.x = (c.x - radius) + shift _circleBox.origin.y = (c.y - radius) + shift _circleBox.size.width = diameter - shift * 2.0 _circleBox.size.height = diameter - shift * 2.0 } - + internal override func calcMinMax() { calcAngles() } - + open override func getMarkerPosition(highlight: Highlight) -> CGPoint { let center = self.centerCircleBox var r = self.radius - + var off = r / 10.0 * 3.6 - + if self.isDrawHoleEnabled { off = (r - (r * self.holeRadiusPercent)) / 2.0 } - + r -= off // offset to keep things inside the chart - + let rotationAngle = self.rotationAngle - + let entryIndex = Int(highlight.x) - + // offset needed to center the drawn text in the slice let offset = drawAngles[entryIndex] / 2.0 - + // calculate the text position let x: CGFloat = (r * cos(((rotationAngle + absoluteAngles[entryIndex] - offset) * CGFloat(_animator.phaseY)).DEG2RAD) + center.x) let y: CGFloat = (r * sin(((rotationAngle + absoluteAngles[entryIndex] - offset) * CGFloat(_animator.phaseY)).DEG2RAD) + center.y) - + return CGPoint(x: x, y: y) } - + /// calculates the needed angles for the chart slices private func calcAngles() { _drawAngles = [CGFloat]() _absoluteAngles = [CGFloat]() - + guard let data = _data else { return } let entryCount = data.entryCount - + _drawAngles.reserveCapacity(entryCount) _absoluteAngles.reserveCapacity(entryCount) - + let yValueSum = (_data as! PieChartData).yValueSum - + var dataSets = data.dataSets var cnt = 0 @@ -230,7 +208,7 @@ open class PieChartView: PieRadarChartViewBase for j in 0 ..< entryCount { guard let e = set.entryForIndex(j) else { continue } - + _drawAngles.append(calcAngle(value: abs(e.y), yValueSum: yValueSum)) if cnt == 0 @@ -246,7 +224,7 @@ open class PieChartView: PieRadarChartViewBase } } } - + /// Checks if the given index is set to be highlighted. @objc open func needsHighlight(index: Int) -> Bool { @@ -255,7 +233,7 @@ open class PieChartView: PieRadarChartViewBase { return false } - + for i in 0 ..< _indicesToHighlight.count { // check if the xvalue for the given dataset needs highlight @@ -264,28 +242,28 @@ open class PieChartView: PieRadarChartViewBase return true } } - + return false } - + /// calculates the needed angle for a given value private func calcAngle(_ value: Double) -> CGFloat { return calcAngle(value: value, yValueSum: (_data as! PieChartData).yValueSum) } - + /// calculates the needed angle for a given value private func calcAngle(value: Double, yValueSum: Double) -> CGFloat { return CGFloat(value) / CGFloat(yValueSum) * _maxAngle } - + /// This will throw an exception, PieChart has no XAxis object. open override var xAxis: XAxis { fatalError("PieChart has no XAxis") } - + open override func indexForAngle(_ angle: CGFloat) -> Int { // take the current angle of the chart into consideration @@ -297,15 +275,15 @@ open class PieChartView: PieRadarChartViewBase return i } } - + return -1 // return -1 if no index found } - + /// - returns: The index of the DataSet this x-index belongs to. @objc open func dataSetIndexForIndex(_ xValue: Double) -> Int { var dataSets = _data?.dataSets ?? [] - + for i in 0 ..< dataSets.count { if (dataSets[i].entryForXValue(xValue, closestToY: Double.nan) !== nil) @@ -313,10 +291,10 @@ open class PieChartView: PieRadarChartViewBase return i } } - + return -1 } - + /// - returns: An integer array of all the different angles the chart slices /// have the angles in the returned array determine how much space (of 360°) /// each slice takes @@ -331,12 +309,12 @@ open class PieChartView: PieRadarChartViewBase { return _absoluteAngles } - + /// The color for the hole that is drawn in the center of the PieChart (if enabled). - /// + /// /// - note: Use holeTransparent with holeColor = nil to make the hole transparent.* @objc open var holeColor: NSUIColor? - { + { get { return _holeColor @@ -347,12 +325,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// if true, the hole will see-through to the inner tips of the slices /// /// **default**: `false` @objc open var drawSlicesUnderHoleEnabled: Bool - { + { get { return _drawSlicesUnderHoleEnabled @@ -363,16 +341,16 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if the inner tips of the slices are visible behind the hole, `false` if not. @objc open var isDrawSlicesUnderHoleEnabled: Bool { return drawSlicesUnderHoleEnabled } - + /// `true` if the hole in the center of the pie-chart is set to be visible, `false` ifnot @objc open var drawHoleEnabled: Bool - { + { get { return _drawHoleEnabled @@ -383,19 +361,19 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if the hole in the center of the pie-chart is set to be visible, `false` ifnot @objc open var isDrawHoleEnabled: Bool - { + { get { return drawHoleEnabled } } - + /// the text that is displayed in the center of the pie-chart @objc open var centerText: String? - { + { get { return self.centerAttributedText?.string @@ -410,14 +388,14 @@ open class PieChartView: PieRadarChartViewBase else { #if os(OSX) - let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle - paragraphStyle.lineBreakMode = NSParagraphStyle.LineBreakMode.byTruncatingTail + let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle + paragraphStyle.lineBreakMode = NSParagraphStyle.LineBreakMode.byTruncatingTail #else - let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle - paragraphStyle.lineBreakMode = NSLineBreakMode.byTruncatingTail + let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle + paragraphStyle.lineBreakMode = NSLineBreakMode.byTruncatingTail #endif paragraphStyle.alignment = .center - + attrString = NSMutableAttributedString(string: newValue!) attrString?.setAttributes([ NSAttributedStringKey.foregroundColor: NSUIColor.black, @@ -428,10 +406,10 @@ open class PieChartView: PieRadarChartViewBase self.centerAttributedText = attrString } } - + /// the text that is displayed in the center of the pie-chart @objc open var centerAttributedText: NSAttributedString? - { + { get { return _centerAttributedText @@ -442,10 +420,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// Sets the offset the center text should have from it's original position in dp. Default x = 0, y = 0 @objc open var centerTextOffset: CGPoint - { + { get { return _centerTextOffset @@ -456,10 +434,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// `true` if drawing the center text is enabled @objc open var drawCenterTextEnabled: Bool - { + { get { return _drawCenterTextEnabled @@ -470,48 +448,48 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if drawing the center text is enabled @objc open var isDrawCenterTextEnabled: Bool - { + { get { return drawCenterTextEnabled } } - + internal override var requiredLegendOffset: CGFloat { return _legend.font.pointSize * 2.0 } - + internal override var requiredBaseOffset: CGFloat { return 0.0 } - + open override var radius: CGFloat { return _circleBox.width / 2.0 } - + /// - returns: The circlebox, the boundingbox of the pie-chart slices @objc open var circleBox: CGRect { return _circleBox } - + /// - returns: The center of the circlebox @objc open var centerCircleBox: CGPoint { return CGPoint(x: _circleBox.midX, y: _circleBox.midY) } - + /// the radius of the hole in the center of the piechart in percent of the maximum radius (max = the radius of the whole chart) - /// + /// /// **default**: 0.5 (50%) (half the pie) @objc open var holeRadiusPercent: CGFloat - { + { get { return _holeRadiusPercent @@ -522,12 +500,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The color that the transparent-circle should have. /// /// **default**: `nil` @objc open var transparentCircleColor: NSUIColor? - { + { get { return _transparentCircleColor @@ -538,12 +516,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// the radius of the transparent circle that is drawn next to the hole in the piechart in percent of the maximum radius (max = the radius of the whole chart) - /// + /// /// **default**: 0.55 (55%) -> means 5% larger than the center-hole by default @objc open var transparentCircleRadiusPercent: CGFloat - { + { get { return _transparentCircleRadiusPercent @@ -554,10 +532,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The color the entry labels are drawn with. @objc open var entryLabelColor: NSUIColor? - { + { get { return _entryLabelColor } set { @@ -565,10 +543,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The font the entry labels are drawn with. @objc open var entryLabelFont: NSUIFont? - { + { get { return _entryLabelFont } set { @@ -576,10 +554,10 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// Set this to true to draw the enrty labels into the pie slices @objc open var drawEntryLabelsEnabled: Bool - { + { get { return _drawEntryLabelsEnabled @@ -590,19 +568,19 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if drawing entry labels is enabled, `false` ifnot @objc open var isDrawEntryLabelsEnabled: Bool - { + { get { return drawEntryLabelsEnabled } } - + /// If this is enabled, values inside the PieChart are drawn in percent and not with their original value. Values provided for the ValueFormatter to format are then provided in percent. @objc open var usePercentValuesEnabled: Bool - { + { get { return _usePercentValuesEnabled @@ -613,19 +591,19 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// - returns: `true` if drawing x-values is enabled, `false` ifnot @objc open var isUsePercentValuesEnabled: Bool - { + { get { return usePercentValuesEnabled } } - + /// the rectangular radius of the bounding box for the center text, as a percentage of the pie hole @objc open var centerTextRadiusPercent: CGFloat - { + { get { return _centerTextRadiusPercent @@ -636,12 +614,12 @@ open class PieChartView: PieRadarChartViewBase setNeedsDisplay() } } - + /// The max angle that is used for calculating the pie-circle. /// 360 means it's a full pie-chart, 180 results in a half-pie-chart. /// **default**: 360.0 @objc open var maxAngle: CGFloat - { + { get { return _maxAngle @@ -649,12 +627,12 @@ open class PieChartView: PieRadarChartViewBase set { _maxAngle = newValue - + if _maxAngle > 360.0 { _maxAngle = 360.0 } - + if _maxAngle < 90.0 { _maxAngle = 90.0 diff --git a/Source/Charts/Data/Implementations/Standard/ChartData.swift b/Source/Charts/Data/Implementations/Standard/ChartData.swift index 3b9851a43c..24309ebae9 100644 --- a/Source/Charts/Data/Implementations/Standard/ChartData.swift +++ b/Source/Charts/Data/Implementations/Standard/ChartData.swift @@ -756,4 +756,20 @@ open class ChartData: NSObject return max } + + // MARK: - Accessibility + + /// When the data entry labels are generated identifiers, set this property to prepend a string before each identifier + /// + /// For example, if a label is "#3", settings this property to "Item" allows it to be spoken as "Item #3" + @objc open var accessibilityEntryLabelPrefix: String? + + /// When the data entry value requires a unit, use this property to append the string representation of the unit to the value + /// + /// For example, if a value is "44.1", setting this property to "m" allows it to be spoken as "44.1 m" + @objc open var accessibilityEntryLabelSuffix: String? + + /// If the data entry value is a count, set this to true to allow plurals and other grammatical changes + /// **default**: false + @objc open var accessibilityEntryLabelSuffixIsCount: Bool = false } diff --git a/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift b/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift index c1b863df29..5847796068 100644 --- a/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift +++ b/Source/Charts/Data/Implementations/Standard/PieChartDataSet.swift @@ -118,12 +118,4 @@ open class PieChartDataSet: ChartDataSet, IPieChartDataSet copy.highlightColor = highlightColor return copy } - - // MARK: - Accessibility - - open var accessibilityEntryLabelSuffixIsCount: Bool = false - - open var accessibilityEntryLabelPrefix: String? - - open var accessibilityEntryLabelSuffix: String? } diff --git a/Source/Charts/Data/Interfaces/IPieChartDataSet.swift b/Source/Charts/Data/Interfaces/IPieChartDataSet.swift index bf3468b9a3..872a031170 100644 --- a/Source/Charts/Data/Interfaces/IPieChartDataSet.swift +++ b/Source/Charts/Data/Interfaces/IPieChartDataSet.swift @@ -62,19 +62,4 @@ public protocol IPieChartDataSet: IChartDataSet /// get/sets the color for the highlighted sector var highlightColor: NSUIColor? { get set } - // MARK: - Accessibility - - /// When the data entry labels for slices are generated identifiers, set this property to prepend a string before each identifier - /// - /// For example, if a label is "#3", settings this property to "Item" allows it to be spoken as "Item #3" - var accessibilityEntryLabelPrefix: String? { get set } - - /// When the data entry value requires a unit, use this property to append the string representation of the unit to the value - /// - /// For example, if a value is "44.1", setting this property to "m" allows it to be spoken as "44.1 m" - var accessibilityEntryLabelSuffix: String? { get set } - - /// If the data entry value is a count, set this to true to allow plurals and other grammatical changes - /// **default**: false - var accessibilityEntryLabelSuffixIsCount: Bool { get set } } diff --git a/Source/Charts/Renderers/ChartDataRendererBase.swift b/Source/Charts/Renderers/ChartDataRendererBase.swift index 024516aa15..4010390edf 100644 --- a/Source/Charts/Renderers/ChartDataRendererBase.swift +++ b/Source/Charts/Renderers/ChartDataRendererBase.swift @@ -15,6 +15,9 @@ import CoreGraphics @objc(ChartDataRendererBase) open class DataRenderer: Renderer { + /// An array of elements that, when populated are presented to ChartViewBase accessibility methods + @objc open var accessibleChartElements: [UIAccessibilityElement] = [] + @objc open let animator: Animator @objc public init(animator: Animator, viewPortHandler: ViewPortHandler) diff --git a/Source/Charts/Renderers/PieChartRenderer.swift b/Source/Charts/Renderers/PieChartRenderer.swift index dc325c3f1d..977b7107fb 100644 --- a/Source/Charts/Renderers/PieChartRenderer.swift +++ b/Source/Charts/Renderers/PieChartRenderer.swift @@ -18,8 +18,6 @@ import CoreGraphics open class PieChartRenderer: DataRenderer { - open var accessiblePieChartElements: [UIAccessibilityElement] = [] - @objc open weak var chart: PieChartView? @objc public init(chart: PieChartView, animator: Animator, viewPortHandler: ViewPortHandler) @@ -38,7 +36,7 @@ open class PieChartRenderer: DataRenderer if pieData != nil { // If we redraw the data, remove and repopulate accessible elements to update label values and frames - accessiblePieChartElements.removeAll() + accessibleChartElements.removeAll() for set in pieData!.dataSets as! [IPieChartDataSet] { @@ -146,7 +144,7 @@ open class PieChartRenderer: DataRenderer // This is unlike when we are naming individual slices, wherein it's alright to not use a prefix as descriptor. // i.e. We want to VO to say "3 Elements" even if the developer didn't specify an accessibility prefix // If prefix is unspecified it is safe to assume they did not want to use "Element 1", so that uses a default empty string - let prefix: String = dataSet.accessibilityEntryLabelPrefix ?? "Element" + let prefix: String = chart.data?.accessibilityEntryLabelPrefix ?? "Element" let description = chart.chartDescription?.text ?? dataSet.label ?? chart.centerText ?? "" let @@ -154,7 +152,7 @@ open class PieChartRenderer: DataRenderer element.accessibilityLabel = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" element.accessibilityFrame = chart.convert(chart.bounds, to: UIScreen.main.fixedCoordinateSpace) element.accessibilityTraits = UIAccessibilityTraitHeader - accessiblePieChartElements.append(element) + accessibleChartElements.append(element) for j in 0 ..< entryCount { @@ -274,7 +272,7 @@ open class PieChartRenderer: DataRenderer to: UIScreen.main.coordinateSpace) } - accessiblePieChartElements.append(axElement) + accessibleChartElements.append(axElement) } } @@ -878,7 +876,7 @@ open class PieChartRenderer: DataRenderer // Prepend selected slices before the already rendered unselected ones. // NOTE: - This relies on drawDataSet() being called before drawHighlighted in PieChartView. - accessiblePieChartElements.insert(contentsOf: highlightedAccessibleElements, at: 1) + accessibleChartElements.insert(contentsOf: highlightedAccessibleElements, at: 1) context.restoreGState() } @@ -894,30 +892,29 @@ open class PieChartRenderer: DataRenderer guard let e = dataSet.entryForIndex(idx) else { return element } guard let formatter = dataSet.valueFormatter else { return element } + guard let data = container.data as? PieChartData else { return element } - var elementValueText: String = formatter.stringForValue( + var elementValueText = formatter.stringForValue( e.y, entry: e, dataSetIndex: idx, viewPortHandler: viewPortHandler) if container.usePercentValuesEnabled { - if let data = container.data as? PieChartData { - let value = e.y / data.yValueSum * 100.0 - let valueText = formatter.stringForValue( - value, - entry: e, - dataSetIndex: idx, - viewPortHandler: viewPortHandler) - - elementValueText = valueText - } + let value = e.y / data.yValueSum * 100.0 + let valueText = formatter.stringForValue( + value, + entry: e, + dataSetIndex: idx, + viewPortHandler: viewPortHandler) + + elementValueText = valueText } let pieChartDataEntry = (dataSet.entryForIndex(idx) as? PieChartDataEntry) - let isCount = dataSet.accessibilityEntryLabelSuffixIsCount - let prefix = dataSet.accessibilityEntryLabelPrefix?.appending("\(idx + 1)") ?? pieChartDataEntry?.label ?? "" - let suffix = dataSet.accessibilityEntryLabelSuffix ?? "" + let isCount = data.accessibilityEntryLabelSuffixIsCount + let prefix = data.accessibilityEntryLabelPrefix?.appending("\(idx + 1)") ?? pieChartDataEntry?.label ?? "" + let suffix = data.accessibilityEntryLabelSuffix ?? "" element.accessibilityLabel = "\(prefix) : \(elementValueText) \(suffix + (isCount ? (e.y == 1.0 ? "" : "s") : "") )" // The modifier allows changing of traits and frame depending on highlight, rotation, etc From 4adff8954e10c5755a1dc3193628a20ef578fb0e Mon Sep 17 00:00:00 2001 From: Adi Date: Fri, 18 May 2018 17:42:07 -0400 Subject: [PATCH 04/17] Extended NSUIView to abstract Platform accessibility. (#1060) Added accessibilityChildren to ChartViewBase which is a layer over both UIAccessibilityContainer and NSAccessibilityGroup protocols. Updated PieChartRenderer to use the platform agnostic NSUIAccessibilityElement. Added init() overrides in NSUIView declaration for macOS to add .list NSAccessibilityRole. Added Platform+Accessibility.swift which extends NSUIView with accessibility container and group protocols and also declares NSUIAccessibilityElement, which acts as an abstraction over NSAccessibilityElement and UIAccessibilityElement. --- Charts.xcodeproj/project.pbxproj | 4 + Platform+Accessibility.swift | 207 ++++++++++++++++++ Source/Charts/Charts/ChartViewBase.swift | 18 +- .../Renderers/ChartDataRendererBase.swift | 5 +- .../Charts/Renderers/PieChartRenderer.swift | 22 +- Source/Charts/Utils/Platform.swift | 17 +- 6 files changed, 242 insertions(+), 31 deletions(-) create mode 100644 Platform+Accessibility.swift diff --git a/Charts.xcodeproj/project.pbxproj b/Charts.xcodeproj/project.pbxproj index d5ed7687e8..769132f22e 100644 --- a/Charts.xcodeproj/project.pbxproj +++ b/Charts.xcodeproj/project.pbxproj @@ -98,6 +98,7 @@ 9400725714D0DA707DDECD2E /* ViewPortJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7BDB22C97F39A4B33E38A7 /* ViewPortJob.swift */; }; 95B6D6F35684292A62DBEA74 /* LineChartDataSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A75AA73C5AA381DA517959 /* LineChartDataSet.swift */; }; 967EE2EDDE3337C5C4337C59 /* IndexAxisValueFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10DD0A02E3CF611BD11EBA9B /* IndexAxisValueFormatter.swift */; }; + 970221AD20ADFA85007410E5 /* Platform+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970221AC20ADFA85007410E5 /* Platform+Accessibility.swift */; }; 97E033CC0ABEF0F448DAFA8E /* DataApproximator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EF9709CF635BEE70D1ABC5 /* DataApproximator.swift */; }; 98E2EEF45E8933E4AD182D58 /* ChartViewBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFAD7920F76360ADB3B5F5 /* ChartViewBase.swift */; }; 9A26C8DB1F87B01700367599 /* DataApproximator+N.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A26C8DA1F87B01700367599 /* DataApproximator+N.swift */; }; @@ -257,6 +258,7 @@ 923206233CA89FD03565FF87 /* LineScatterCandleRadarRenderer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LineScatterCandleRadarRenderer.swift; path = Source/Charts/Renderers/LineScatterCandleRadarRenderer.swift; sourceTree = ""; }; 9249AD9AEC8C85772365A128 /* ILineScatterCandleRadarChartDataSet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ILineScatterCandleRadarChartDataSet.swift; path = Source/Charts/Data/Interfaces/ILineScatterCandleRadarChartDataSet.swift; sourceTree = ""; }; 93EF9709CF635BEE70D1ABC5 /* DataApproximator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DataApproximator.swift; path = Source/Charts/Filters/DataApproximator.swift; sourceTree = ""; }; + 970221AC20ADFA85007410E5 /* Platform+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Platform+Accessibility.swift"; sourceTree = ""; }; 998F2BFE318471AFC05B50AC /* IHighlighter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IHighlighter.swift; path = Source/Charts/Highlight/IHighlighter.swift; sourceTree = ""; }; 9A26C8DA1F87B01700367599 /* DataApproximator+N.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "DataApproximator+N.swift"; path = "Source/Charts/Filters/DataApproximator+N.swift"; sourceTree = ""; }; 9D7184C8A5A60A3522AB9B05 /* BarChartDataProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BarChartDataProvider.swift; path = Source/Charts/Interfaces/BarChartDataProvider.swift; sourceTree = ""; }; @@ -584,6 +586,7 @@ 3FDA09EF973925A110506799 /* ChartUtils.swift */, 5A4CFFFB65819121595F06F1 /* Fill.swift */, 3ED23C354AFE81818D78E645 /* Platform.swift */, + 970221AC20ADFA85007410E5 /* Platform+Accessibility.swift */, FF475B9593B9898853814340 /* Transformer.swift */, 324C9127B53A8D39C8B49277 /* TransformerHorizontalBarChart.swift */, 72EAEBB7CF73E33565FC2896 /* ViewPortHandler.swift */, @@ -923,6 +926,7 @@ 24151B0729D77251A8494D70 /* LineRadarRenderer.swift in Sources */, B6DCC229615EFE706F64A37D /* LineScatterCandleRadarRenderer.swift in Sources */, 795E100895C24049509F1EDE /* PieChartRenderer.swift in Sources */, + 970221AD20ADFA85007410E5 /* Platform+Accessibility.swift in Sources */, 69EA073EDF75D49ABE1715D6 /* RadarChartRenderer.swift in Sources */, CEF68F42A5390A73113F3663 /* Renderer.swift in Sources */, 796D3E63A37A95FD9D1AB9A1 /* ChevronDownShapeRenderer.swift in Sources */, diff --git a/Platform+Accessibility.swift b/Platform+Accessibility.swift new file mode 100644 index 0000000000..910653c6cf --- /dev/null +++ b/Platform+Accessibility.swift @@ -0,0 +1,207 @@ +import Foundation + +#if os(iOS) || os(tvOS) + +internal func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) +{ + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, element) +} + +internal func accessibilityPostScreenChangedNotification(withElement element: Any? = nil) +{ + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, element) +} + +open class NSUIAccessibilityElement: UIAccessibilityElement +{ + private let containerView: UIView + + final var isHeader: Bool = false + { + didSet + { + accessibilityTraits = isHeader ? UIAccessibilityTraitHeader : UIAccessibilityTraitNone + } + } + + final var isSelected: Bool = false + { + didSet + { + accessibilityTraits = isSelected ? UIAccessibilityTraitSelected : UIAccessibilityTraitNone + } + } + + override init(accessibilityContainer container: Any) + { + // We can force unwrap since all chart views are subclasses of UIView + containerView = container as! UIView + super.init(accessibilityContainer: container) + } + + override open var accessibilityFrame: CGRect + { + get + { + return super.accessibilityFrame + } + + set + { + super.accessibilityFrame = containerView.convert(newValue, to: UIScreen.main.coordinateSpace) + } + } +} + +extension NSUIView +{ + /// An array of accessibilityElements that is used to implement UIAccessibilityContainer internally. + /// Subclasses **MUST** override this with an array of such elements. + @objc open func accessibilityChildren() -> [Any]? + { + return nil + } + + public final override var isAccessibilityElement: Bool + { + get { return false } // Return false here, so we can make individual elements accessible + set { } + } + + open override func accessibilityElementCount() -> Int + { + return accessibilityChildren()?.count ?? 0 + } + + open override func accessibilityElement(at index: Int) -> Any? + { + return accessibilityChildren()?[index] + } + + open override func index(ofAccessibilityElement element: Any) -> Int + { + guard let axElement = element as? NSUIAccessibilityElement else { return -1 } + return (accessibilityChildren() as? [NSUIAccessibilityElement])?.index(of: axElement) ?? -1 + } +} + +#endif + +#if os(OSX) + +internal func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) +{ + guard let validElement = element else { return } + NSAccessibilityPostNotification(validElement, .layoutChanged) +} + +internal func accessibilityPostScreenChangedNotification(withElement element: Any? = nil) +{ + // Placeholder +} + +open class NSUIAccessibilityElement: NSAccessibilityElement +{ + private let containerView: NSView + + final var isHeader: Bool = false + { + didSet + { + setAccessibilityRole(isHeader ? .staticText : .none) + } + } + + // TODO: Make isSelected toggle a selected state in conjunction with a .valueChanged notification + /// A placeholder for parity with iOS. Has no effect. + final var isSelected: Bool = false + + open var accessibilityLabel: String + { + get + { + return accessibilityLabel() ?? "" + } + + set + { + setAccessibilityLabel(newValue) + } + } + + open var accessibilityFrame: NSRect + { + get + { + return accessibilityFrame() + } + + set + { + let bounds = NSAccessibilityFrameInView(containerView, newValue) + setAccessibilityFrame(bounds) + } + } + + init(accessibilityContainer container: Any) + { + // We can force unwrap since all chart views are subclasses of NSView + containerView = container as! NSView + + super.init() + + setAccessibilityParent(containerView) + setAccessibilityRole(.row) + } + + open override func accessibilityParent() -> Any? + { + return super.accessibilityParent() + } +} + +/* +/// This would have been needed if the NSAccessibilityList protocol worked. +extension NSUIAccessibilityElement: NSAccessibilityRow +{ + open override func accessibilityChildren() -> [Any]? + { + return nil + } + + open override func accessibilityIdentifier() -> String + { + return super.accessibilityIdentifier() ?? "" + } + + open override func accessibilityIndex() -> Int + { + guard let parentChartView = containerView as? ChartViewBase else { return -1 } + return (parentChartView.accessibilityChildren() as? [NSUIAccessibilityElement])?.index(of: self) ?? -1 + } +} +*/ + +/// NOTE: Using Swift makes all NSAccessibility methods required +/// Since the method signatures for accessibilityRows() differ between the NSAccessibilityTable and NSAccessibility protocols, +/// trying to override or create either causes a compiler error. Hence we resort to calling setAccessibilityRole(.list) +/// while making NSUIView an NSAccessibilityGroup. +extension NSUIView: NSAccessibilityGroup +{ + open override func accessibilityChildren() -> [Any]? + { + return nil + } + + open override func accessibilityLabel() -> String? + { + return "Chart View" + } + + open override func accessibilityRows() -> [Any]? + { + return accessibilityChildren() + } +} + +#endif diff --git a/Source/Charts/Charts/ChartViewBase.swift b/Source/Charts/Charts/ChartViewBase.swift index 94d80b591e..7528e0c75b 100644 --- a/Source/Charts/Charts/ChartViewBase.swift +++ b/Source/Charts/Charts/ChartViewBase.swift @@ -369,22 +369,8 @@ open class ChartViewBase: NSUIView, ChartDataProvider, AnimatorDelegate // MARK: - Accessibility - open override var isAccessibilityElement: Bool { - get { return false } - set { } - } - - open override func accessibilityElementCount() -> Int { - return renderer?.accessibleChartElements.count ?? 0 - } - - open override func accessibilityElement(at index: Int) -> Any? { - return renderer?.accessibleChartElements[index] - } - - open override func index(ofAccessibilityElement element: Any) -> Int { - guard let axElement: UIAccessibilityElement = element as? UIAccessibilityElement else { return -1 } - return renderer?.accessibleChartElements.index(of: axElement) ?? -1 + open override func accessibilityChildren() -> [Any]? { + return renderer?.accessibleChartElements } // MARK: - Highlighting diff --git a/Source/Charts/Renderers/ChartDataRendererBase.swift b/Source/Charts/Renderers/ChartDataRendererBase.swift index 4010390edf..552327b492 100644 --- a/Source/Charts/Renderers/ChartDataRendererBase.swift +++ b/Source/Charts/Renderers/ChartDataRendererBase.swift @@ -15,8 +15,9 @@ import CoreGraphics @objc(ChartDataRendererBase) open class DataRenderer: Renderer { - /// An array of elements that, when populated are presented to ChartViewBase accessibility methods - @objc open var accessibleChartElements: [UIAccessibilityElement] = [] + /// An array of elements that are presented to the ChartViewBase accessibility methods. + /// Subclasses should populate this array in drawData() or drawDataSet() to make the chart accessible. + @objc open var accessibleChartElements: [NSUIAccessibilityElement] = [] @objc open let animator: Animator diff --git a/Source/Charts/Renderers/PieChartRenderer.swift b/Source/Charts/Renderers/PieChartRenderer.swift index 977b7107fb..848c1009c1 100644 --- a/Source/Charts/Renderers/PieChartRenderer.swift +++ b/Source/Charts/Renderers/PieChartRenderer.swift @@ -148,10 +148,10 @@ open class PieChartRenderer: DataRenderer let description = chart.chartDescription?.text ?? dataSet.label ?? chart.centerText ?? "" let - element: UIAccessibilityElement = UIAccessibilityElement(accessibilityContainer: chart) + element = NSUIAccessibilityElement(accessibilityContainer: chart) element.accessibilityLabel = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" - element.accessibilityFrame = chart.convert(chart.bounds, to: UIScreen.main.fixedCoordinateSpace) - element.accessibilityTraits = UIAccessibilityTraitHeader + element.accessibilityFrame = chart.bounds + element.isHeader = true accessibleChartElements.append(element) for j in 0 ..< entryCount @@ -268,8 +268,7 @@ open class PieChartRenderer: DataRenderer container: chart, dataSet: dataSet) { (element) in - element.accessibilityFrame = chart.convert(path.boundingBoxOfPath, - to: UIScreen.main.coordinateSpace) + element.accessibilityFrame = path.boundingBoxOfPath } accessibleChartElements.append(axElement) @@ -280,7 +279,7 @@ open class PieChartRenderer: DataRenderer } // Post this notification to let VoiceOver account for the redrawn frames - UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil) + accessibilityPostLayoutChangedNotification() context.restoreGState() } @@ -710,7 +709,7 @@ open class PieChartRenderer: DataRenderer let userInnerRadius = drawInnerArc ? radius * chart.holeRadiusPercent : 0.0 // Append highlighted accessibility slices into this array, so we can prioritize them over unselected slices - var highlightedAccessibleElements: [UIAccessibilityElement] = [] + var highlightedAccessibleElements: [NSUIAccessibilityElement] = [] for i in 0 ..< indices.count { @@ -866,9 +865,8 @@ open class PieChartRenderer: DataRenderer container: chart, dataSet: set) { (element) in - element.accessibilityFrame = chart.convert(path.boundingBoxOfPath, - to: UIScreen.main.coordinateSpace) - element.accessibilityTraits = UIAccessibilityTraitSelected + element.accessibilityFrame = path.boundingBoxOfPath + element.isSelected = true } highlightedAccessibleElements.append(axElement) @@ -886,9 +884,9 @@ open class PieChartRenderer: DataRenderer private func createAccessibleElement(withIndex idx: Int, container: PieChartView, dataSet: IPieChartDataSet, - modifier: (UIAccessibilityElement) -> ()) -> UIAccessibilityElement { + modifier: (NSUIAccessibilityElement) -> ()) -> NSUIAccessibilityElement { - let element: UIAccessibilityElement = UIAccessibilityElement(accessibilityContainer: container) + let element = NSUIAccessibilityElement(accessibilityContainer: container) guard let e = dataSet.entryForIndex(idx) else { return element } guard let formatter = dataSet.valueFormatter else { return element } diff --git a/Source/Charts/Utils/Platform.swift b/Source/Charts/Utils/Platform.swift index ae17c109a3..8141ad960e 100644 --- a/Source/Charts/Utils/Platform.swift +++ b/Source/Charts/Utils/Platform.swift @@ -394,6 +394,22 @@ types are aliased to either their UI* implementation (on iOS) or their NS* imple open class NSUIView: NSView { + /// A private constant to set the accessibility role during initialization + /// (See Platform+Accessibility for details) + private let role: NSAccessibilityRole = .list + + public override init(frame frameRect: NSRect) + { + super.init(frame: frameRect) + setAccessibilityRole(role) + } + + required public init?(coder decoder: NSCoder) + { + super.init(coder: decoder) + setAccessibilityRole(role) + } + public final override var isFlipped: Bool { return true @@ -403,7 +419,6 @@ types are aliased to either their UI* implementation (on iOS) or their NS* imple { self.setNeedsDisplay(self.bounds) } - public final override func touchesBegan(with event: NSEvent) { From 2bfca15767f5674019c47bb12e4ab7dc88b96f13 Mon Sep 17 00:00:00 2001 From: Adi Date: Fri, 18 May 2018 19:01:27 -0400 Subject: [PATCH 05/17] Minor file structure & accessibility fix. (#1060) Moved Platform+Accessibility.swift to Utils folder. Changed accessibleChartElements to be final since Renderer subclasses should not need to modify its working. Simply populating it in draw() functions will add basic accessibility. --- Charts.xcodeproj/project.pbxproj | 8 +- Platform+Accessibility.swift | 207 ------------------ .../Renderers/ChartDataRendererBase.swift | 2 +- 3 files changed, 5 insertions(+), 212 deletions(-) delete mode 100644 Platform+Accessibility.swift diff --git a/Charts.xcodeproj/project.pbxproj b/Charts.xcodeproj/project.pbxproj index 769132f22e..ac86e71d95 100644 --- a/Charts.xcodeproj/project.pbxproj +++ b/Charts.xcodeproj/project.pbxproj @@ -98,7 +98,7 @@ 9400725714D0DA707DDECD2E /* ViewPortJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7BDB22C97F39A4B33E38A7 /* ViewPortJob.swift */; }; 95B6D6F35684292A62DBEA74 /* LineChartDataSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A75AA73C5AA381DA517959 /* LineChartDataSet.swift */; }; 967EE2EDDE3337C5C4337C59 /* IndexAxisValueFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10DD0A02E3CF611BD11EBA9B /* IndexAxisValueFormatter.swift */; }; - 970221AD20ADFA85007410E5 /* Platform+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970221AC20ADFA85007410E5 /* Platform+Accessibility.swift */; }; + 97AD2D4620AF917100F9C24A /* Platform+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97AD2D4520AF917100F9C24A /* Platform+Accessibility.swift */; }; 97E033CC0ABEF0F448DAFA8E /* DataApproximator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EF9709CF635BEE70D1ABC5 /* DataApproximator.swift */; }; 98E2EEF45E8933E4AD182D58 /* ChartViewBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFAD7920F76360ADB3B5F5 /* ChartViewBase.swift */; }; 9A26C8DB1F87B01700367599 /* DataApproximator+N.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A26C8DA1F87B01700367599 /* DataApproximator+N.swift */; }; @@ -258,7 +258,7 @@ 923206233CA89FD03565FF87 /* LineScatterCandleRadarRenderer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LineScatterCandleRadarRenderer.swift; path = Source/Charts/Renderers/LineScatterCandleRadarRenderer.swift; sourceTree = ""; }; 9249AD9AEC8C85772365A128 /* ILineScatterCandleRadarChartDataSet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ILineScatterCandleRadarChartDataSet.swift; path = Source/Charts/Data/Interfaces/ILineScatterCandleRadarChartDataSet.swift; sourceTree = ""; }; 93EF9709CF635BEE70D1ABC5 /* DataApproximator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DataApproximator.swift; path = Source/Charts/Filters/DataApproximator.swift; sourceTree = ""; }; - 970221AC20ADFA85007410E5 /* Platform+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Platform+Accessibility.swift"; sourceTree = ""; }; + 97AD2D4520AF917100F9C24A /* Platform+Accessibility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Platform+Accessibility.swift"; path = "Source/Charts/Utils/Platform+Accessibility.swift"; sourceTree = ""; }; 998F2BFE318471AFC05B50AC /* IHighlighter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IHighlighter.swift; path = Source/Charts/Highlight/IHighlighter.swift; sourceTree = ""; }; 9A26C8DA1F87B01700367599 /* DataApproximator+N.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "DataApproximator+N.swift"; path = "Source/Charts/Filters/DataApproximator+N.swift"; sourceTree = ""; }; 9D7184C8A5A60A3522AB9B05 /* BarChartDataProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BarChartDataProvider.swift; path = Source/Charts/Interfaces/BarChartDataProvider.swift; sourceTree = ""; }; @@ -586,7 +586,7 @@ 3FDA09EF973925A110506799 /* ChartUtils.swift */, 5A4CFFFB65819121595F06F1 /* Fill.swift */, 3ED23C354AFE81818D78E645 /* Platform.swift */, - 970221AC20ADFA85007410E5 /* Platform+Accessibility.swift */, + 97AD2D4520AF917100F9C24A /* Platform+Accessibility.swift */, FF475B9593B9898853814340 /* Transformer.swift */, 324C9127B53A8D39C8B49277 /* TransformerHorizontalBarChart.swift */, 72EAEBB7CF73E33565FC2896 /* ViewPortHandler.swift */, @@ -926,7 +926,7 @@ 24151B0729D77251A8494D70 /* LineRadarRenderer.swift in Sources */, B6DCC229615EFE706F64A37D /* LineScatterCandleRadarRenderer.swift in Sources */, 795E100895C24049509F1EDE /* PieChartRenderer.swift in Sources */, - 970221AD20ADFA85007410E5 /* Platform+Accessibility.swift in Sources */, + 97AD2D4620AF917100F9C24A /* Platform+Accessibility.swift in Sources */, 69EA073EDF75D49ABE1715D6 /* RadarChartRenderer.swift in Sources */, CEF68F42A5390A73113F3663 /* Renderer.swift in Sources */, 796D3E63A37A95FD9D1AB9A1 /* ChevronDownShapeRenderer.swift in Sources */, diff --git a/Platform+Accessibility.swift b/Platform+Accessibility.swift deleted file mode 100644 index 910653c6cf..0000000000 --- a/Platform+Accessibility.swift +++ /dev/null @@ -1,207 +0,0 @@ -import Foundation - -#if os(iOS) || os(tvOS) - -internal func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) -{ - UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, element) -} - -internal func accessibilityPostScreenChangedNotification(withElement element: Any? = nil) -{ - UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, element) -} - -open class NSUIAccessibilityElement: UIAccessibilityElement -{ - private let containerView: UIView - - final var isHeader: Bool = false - { - didSet - { - accessibilityTraits = isHeader ? UIAccessibilityTraitHeader : UIAccessibilityTraitNone - } - } - - final var isSelected: Bool = false - { - didSet - { - accessibilityTraits = isSelected ? UIAccessibilityTraitSelected : UIAccessibilityTraitNone - } - } - - override init(accessibilityContainer container: Any) - { - // We can force unwrap since all chart views are subclasses of UIView - containerView = container as! UIView - super.init(accessibilityContainer: container) - } - - override open var accessibilityFrame: CGRect - { - get - { - return super.accessibilityFrame - } - - set - { - super.accessibilityFrame = containerView.convert(newValue, to: UIScreen.main.coordinateSpace) - } - } -} - -extension NSUIView -{ - /// An array of accessibilityElements that is used to implement UIAccessibilityContainer internally. - /// Subclasses **MUST** override this with an array of such elements. - @objc open func accessibilityChildren() -> [Any]? - { - return nil - } - - public final override var isAccessibilityElement: Bool - { - get { return false } // Return false here, so we can make individual elements accessible - set { } - } - - open override func accessibilityElementCount() -> Int - { - return accessibilityChildren()?.count ?? 0 - } - - open override func accessibilityElement(at index: Int) -> Any? - { - return accessibilityChildren()?[index] - } - - open override func index(ofAccessibilityElement element: Any) -> Int - { - guard let axElement = element as? NSUIAccessibilityElement else { return -1 } - return (accessibilityChildren() as? [NSUIAccessibilityElement])?.index(of: axElement) ?? -1 - } -} - -#endif - -#if os(OSX) - -internal func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) -{ - guard let validElement = element else { return } - NSAccessibilityPostNotification(validElement, .layoutChanged) -} - -internal func accessibilityPostScreenChangedNotification(withElement element: Any? = nil) -{ - // Placeholder -} - -open class NSUIAccessibilityElement: NSAccessibilityElement -{ - private let containerView: NSView - - final var isHeader: Bool = false - { - didSet - { - setAccessibilityRole(isHeader ? .staticText : .none) - } - } - - // TODO: Make isSelected toggle a selected state in conjunction with a .valueChanged notification - /// A placeholder for parity with iOS. Has no effect. - final var isSelected: Bool = false - - open var accessibilityLabel: String - { - get - { - return accessibilityLabel() ?? "" - } - - set - { - setAccessibilityLabel(newValue) - } - } - - open var accessibilityFrame: NSRect - { - get - { - return accessibilityFrame() - } - - set - { - let bounds = NSAccessibilityFrameInView(containerView, newValue) - setAccessibilityFrame(bounds) - } - } - - init(accessibilityContainer container: Any) - { - // We can force unwrap since all chart views are subclasses of NSView - containerView = container as! NSView - - super.init() - - setAccessibilityParent(containerView) - setAccessibilityRole(.row) - } - - open override func accessibilityParent() -> Any? - { - return super.accessibilityParent() - } -} - -/* -/// This would have been needed if the NSAccessibilityList protocol worked. -extension NSUIAccessibilityElement: NSAccessibilityRow -{ - open override func accessibilityChildren() -> [Any]? - { - return nil - } - - open override func accessibilityIdentifier() -> String - { - return super.accessibilityIdentifier() ?? "" - } - - open override func accessibilityIndex() -> Int - { - guard let parentChartView = containerView as? ChartViewBase else { return -1 } - return (parentChartView.accessibilityChildren() as? [NSUIAccessibilityElement])?.index(of: self) ?? -1 - } -} -*/ - -/// NOTE: Using Swift makes all NSAccessibility methods required -/// Since the method signatures for accessibilityRows() differ between the NSAccessibilityTable and NSAccessibility protocols, -/// trying to override or create either causes a compiler error. Hence we resort to calling setAccessibilityRole(.list) -/// while making NSUIView an NSAccessibilityGroup. -extension NSUIView: NSAccessibilityGroup -{ - open override func accessibilityChildren() -> [Any]? - { - return nil - } - - open override func accessibilityLabel() -> String? - { - return "Chart View" - } - - open override func accessibilityRows() -> [Any]? - { - return accessibilityChildren() - } -} - -#endif diff --git a/Source/Charts/Renderers/ChartDataRendererBase.swift b/Source/Charts/Renderers/ChartDataRendererBase.swift index 552327b492..0cf2f72d21 100644 --- a/Source/Charts/Renderers/ChartDataRendererBase.swift +++ b/Source/Charts/Renderers/ChartDataRendererBase.swift @@ -17,7 +17,7 @@ open class DataRenderer: Renderer { /// An array of elements that are presented to the ChartViewBase accessibility methods. /// Subclasses should populate this array in drawData() or drawDataSet() to make the chart accessible. - @objc open var accessibleChartElements: [NSUIAccessibilityElement] = [] + @objc final var accessibleChartElements: [NSUIAccessibilityElement] = [] @objc open let animator: Animator From bce41d382616ecbdb611d54bec65d527d4b14067 Mon Sep 17 00:00:00 2001 From: Adi Date: Sat, 19 May 2018 16:47:22 -0400 Subject: [PATCH 06/17] Fixed missing accessibility file. --- .../Charts/Utils/Platform+Accessibility.swift | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 Source/Charts/Utils/Platform+Accessibility.swift diff --git a/Source/Charts/Utils/Platform+Accessibility.swift b/Source/Charts/Utils/Platform+Accessibility.swift new file mode 100644 index 0000000000..80c8c39824 --- /dev/null +++ b/Source/Charts/Utils/Platform+Accessibility.swift @@ -0,0 +1,178 @@ +import Foundation + +#if os(iOS) || os(tvOS) + +internal func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) +{ + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, element) +} + +internal func accessibilityPostScreenChangedNotification(withElement element: Any? = nil) +{ + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, element) +} + +open class NSUIAccessibilityElement: UIAccessibilityElement +{ + private let containerView: UIView + + final var isHeader: Bool = false + { + didSet + { + accessibilityTraits = isHeader ? UIAccessibilityTraitHeader : UIAccessibilityTraitNone + } + } + + final var isSelected: Bool = false + { + didSet + { + accessibilityTraits = isSelected ? UIAccessibilityTraitSelected : UIAccessibilityTraitNone + } + } + + override init(accessibilityContainer container: Any) + { + // We can force unwrap since all chart views are subclasses of UIView + containerView = container as! UIView + super.init(accessibilityContainer: container) + } + + override open var accessibilityFrame: CGRect + { + get + { + return super.accessibilityFrame + } + + set + { + super.accessibilityFrame = containerView.convert(newValue, to: UIScreen.main.coordinateSpace) + } + } +} + +extension NSUIView +{ + /// An array of accessibilityElements that is used to implement UIAccessibilityContainer internally. + /// Subclasses **MUST** override this with an array of such elements. + @objc open func accessibilityChildren() -> [Any]? + { + return nil + } + + public final override var isAccessibilityElement: Bool + { + get { return false } // Return false here, so we can make individual elements accessible + set { } + } + + open override func accessibilityElementCount() -> Int + { + return accessibilityChildren()?.count ?? 0 + } + + open override func accessibilityElement(at index: Int) -> Any? + { + return accessibilityChildren()?[index] + } + + open override func index(ofAccessibilityElement element: Any) -> Int + { + guard let axElement = element as? NSUIAccessibilityElement else { return -1 } + return (accessibilityChildren() as? [NSUIAccessibilityElement])?.index(of: axElement) ?? -1 + } +} + +#endif + +#if os(OSX) + +internal func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) +{ + guard let validElement = element else { return } + NSAccessibilityPostNotification(validElement, .layoutChanged) +} + +internal func accessibilityPostScreenChangedNotification(withElement element: Any? = nil) +{ + // Placeholder +} + +open class NSUIAccessibilityElement: NSAccessibilityElement +{ + private let containerView: NSView + + final var isHeader: Bool = false + { + didSet + { + setAccessibilityRole(isHeader ? .staticText : .none) + } + } + + // TODO: Make isSelected toggle a selected state in conjunction with a .valueChanged notification + /// A placeholder for parity with iOS. Has no effect. + final var isSelected: Bool = false + { + didSet + { + setAccessibilitySelected(isSelected) + } + } + + open var accessibilityLabel: String + { + get + { + return accessibilityLabel() ?? "" + } + + set + { + setAccessibilityLabel(newValue) + } + } + + open var accessibilityFrame: NSRect + { + get + { + return accessibilityFrame() + } + + set + { + let bounds = NSAccessibilityFrameInView(containerView, newValue) + setAccessibilityFrame(bounds) + } + } + + init(accessibilityContainer container: Any) + { + // We can force unwrap since all chart views are subclasses of NSView + containerView = container as! NSView + + super.init() + + setAccessibilityParent(containerView) + setAccessibilityRole(.row) + } +} + +/// NOTE: setAccessibilityRole(.list) is called at init. +extension NSUIView: NSAccessibilityGroup +{ + open override func accessibilityLabel() -> String? + { + return "Chart View" + } + + open override func accessibilityRows() -> [Any]? + { + return accessibilityChildren() + } +} + +#endif From b9f6a14dbb3912744fe6729977d9f0f21499a11f Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 20 May 2018 16:10:58 -0400 Subject: [PATCH 07/17] Added accessibility for Bar charts. (#1060) Created internal property accessibilityOrderedElements to make BarChartRenderer be composed of logically ordered accessible elements (See inline comments for details). Updated ChartDataRendererBase, PieChartRenderer and Platform+Accessibility with updated comments to reflect the platform agnostic NSUIAccessibilityElement's use. --- .../Charts/Renderers/BarChartRenderer.swift | 143 ++++++++++++++++-- .../Renderers/ChartDataRendererBase.swift | 7 +- .../Charts/Renderers/PieChartRenderer.swift | 2 +- .../Charts/Utils/Platform+Accessibility.swift | 4 +- 4 files changed, 141 insertions(+), 15 deletions(-) diff --git a/Source/Charts/Renderers/BarChartRenderer.swift b/Source/Charts/Renderers/BarChartRenderer.swift index 3621ddbf9c..d5bcf1e902 100644 --- a/Source/Charts/Renderers/BarChartRenderer.swift +++ b/Source/Charts/Renderers/BarChartRenderer.swift @@ -18,6 +18,28 @@ import CoreGraphics open class BarChartRenderer: BarLineScatterCandleBubbleRenderer { + /// A nested array of elements ordered logically (i.e not in visual/drawing order) for use with VoiceOver + /// + /// Its use is apparent when there are multiple data sets, since we want to read bars in left to right order, + /// irrespective of dataset. However, drawing is done per dataset, so using this array and then flattening it prevents us from needing to + /// re-render for the sake of accessibility. + /// + /// In practise, its structure is: + /// + /// ```` + /// [ + /// [dataset1 element1, dataset2 element1], + /// [dataset1 element2, dataset2 element2], + /// [dataset1 element3, dataset2 element3] + /// ... + /// ] + /// ```` + /// This is done to provide numerical inference across datasets to a screenreader user, in the same way that a sighted individual + /// uses a multi-dataset bar chart. + /// + /// The ````internal```` specifier is to allow subclasses (HorizontalBar) to populate the same array + internal lazy var accessibilityOrderedElements: [[NSUIAccessibilityElement]] = accessibilityCreateEmptyOrderedElements() + private class Buffer { var rects = [CGRect]() @@ -187,6 +209,25 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer let barData = dataProvider.barData else { return } + // 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? BarChartView { + let chartDescriptionText = chart.chartDescription?.text ?? "" + let dataSetDescriptions = barData.dataSets.map { $0.label ?? "" } + let dataSetDescriptionText = dataSetDescriptions.joined(separator: ", ") + let dataSetCount = barData.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) + } + + // Populate logically ordered nested elements into accessibilityOrderedElements in drawDataSet() for i in 0 ..< barData.dataSetCount { guard let set = barData.getDataSetByIndex(i) else { continue } @@ -201,19 +242,23 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer drawDataSet(context: context, dataSet: set as! IBarChartDataSet, index: i) } } + + // Merge nested ordered arrays into the single accessibleChartElements. + accessibleChartElements.append(contentsOf: accessibilityOrderedElements.flatMap { $0 } ) + accessibilityPostLayoutChangedNotification() } - + private var _barShadowRectBuffer: CGRect = CGRect() - + @objc open func drawDataSet(context: CGContext, dataSet: IBarChartDataSet, index: Int) { guard let dataProvider = dataProvider else { return } - + let trans = dataProvider.getTransformer(forAxis: dataSet.axisDependency) - + prepareBuffer(dataSet: dataSet, index: index) trans.rectValuesToPixel(&_buffers[index].rects) - + let borderWidth = dataSet.barBorderWidth let borderColor = dataSet.barBorderColor let drawBorder = borderWidth > 0.0 @@ -257,7 +302,7 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer context.fill(_barShadowRectBuffer) } } - + let buffer = _buffers[index] // draw the bar shadow before the values @@ -288,11 +333,15 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer { context.setFillColor(dataSet.color(atIndex: 0).cgColor) } - + + // In case the chart is stacked, we need to accomodate individual bars within accessibilityOrdereredElements + let isStacked = dataSet.isStacked + let stackSize = isStacked ? dataSet.stackSize : 1 + for j in stride(from: 0, to: buffer.rects.count, by: 1) { let barRect = buffer.rects[j] - + if (!viewPortHandler.isInBoundsLeft(barRect.origin.x + barRect.size.width)) { continue @@ -317,6 +366,21 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer context.setLineWidth(borderWidth) context.stroke(barRect) } + + // Create and append the corresponding accessibility element to accessibilityOrderedElements + if let chart = dataProvider as? BarChartView + { + let element = createAccessibleElement(withIndex: j, + container: chart, + dataSet: dataSet, + dataSetIndex: index, + stackSize: stackSize) + { (element) in + element.accessibilityFrame = barRect + } + + accessibilityOrderedElements[j/stackSize].append(element) + } } context.restoreGState() @@ -682,10 +746,69 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer context.restoreGState() } - - /// Sets the drawing position of the highlight object based on the riven bar-rect. + + /// Sets the drawing position of the highlight object based on the given bar-rect. internal func setHighlightDrawPos(highlight high: Highlight, barRect: CGRect) { high.setDraw(x: barRect.midX, y: barRect.origin.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. + internal func accessibilityCreateEmptyOrderedElements() -> [[NSUIAccessibilityElement]] + { + guard let chart = dataProvider as? BarChartView 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. + internal func createAccessibleElement(withIndex idx: Int, + container: BarChartView, + dataSet: IBarChartDataSet, + dataSetIndex: Int, + stackSize: Int, + modifier: (NSUIAccessibilityElement) -> ()) -> NSUIAccessibilityElement + { + let element = NSUIAccessibilityElement(accessibilityContainer: container) + let xAxis = container.xAxis + + guard let e = dataSet.entryForIndex(idx/stackSize) as? BarChartDataEntry else { return element } + guard let dataProvider = dataProvider else { return element } + + let label = xAxis.valueFormatter?.stringForValue(e.x, axis: xAxis) ?? "\(e.x)" + + var elementValueText = dataSet.valueFormatter?.stringForValue( + e.y, + entry: e, + dataSetIndex: dataSetIndex, + viewPortHandler: viewPortHandler) ?? "\(e.y)" + + if dataSet.isStacked, let vals = e.yValues + { + let stackLabel = dataSet.stackLabels[idx % stackSize] + + elementValueText = dataSet.valueFormatter?.stringForValue( + vals[idx % stackSize], + entry: e, + dataSetIndex: dataSetIndex, + viewPortHandler: viewPortHandler) ?? "\(e.y)" + + elementValueText = stackLabel + " \(elementValueText)" + } + + let dataSetCount = dataProvider.barData?.dataSetCount ?? -1 + let doesContainMultipleDataSets = dataSetCount > 1 + + element.accessibilityLabel = "\(doesContainMultipleDataSets ? (dataSet.label ?? "") + ", " : "") \(label): \(elementValueText)" + + modifier(element) + + return element + } } diff --git a/Source/Charts/Renderers/ChartDataRendererBase.swift b/Source/Charts/Renderers/ChartDataRendererBase.swift index 0cf2f72d21..26b1f41167 100644 --- a/Source/Charts/Renderers/ChartDataRendererBase.swift +++ b/Source/Charts/Renderers/ChartDataRendererBase.swift @@ -15,7 +15,12 @@ import CoreGraphics @objc(ChartDataRendererBase) open class DataRenderer: Renderer { - /// An array of elements that are presented to the ChartViewBase accessibility methods. + /// An array of accessibility elements that are presented to the ChartViewBase accessibility methods. + /// + /// Note that the order of elements in this array determines the order in which they are presented and navigated by + /// Accessibility clients such as VoiceOver. + /// + /// Renderers should ensure that the order of elements makes sense to a client presenting an audio-only interface to a user. /// Subclasses should populate this array in drawData() or drawDataSet() to make the chart accessible. @objc final var accessibleChartElements: [NSUIAccessibilityElement] = [] diff --git a/Source/Charts/Renderers/PieChartRenderer.swift b/Source/Charts/Renderers/PieChartRenderer.swift index 848c1009c1..4e0eaa5f6c 100644 --- a/Source/Charts/Renderers/PieChartRenderer.swift +++ b/Source/Charts/Renderers/PieChartRenderer.swift @@ -879,7 +879,7 @@ open class PieChartRenderer: DataRenderer context.restoreGState() } - /// Creates a UIAccessibleElement representing a slice of the PieChart. + /// Creates an NSUIAccessibilityElement representing a slice of the PieChart. /// The element only has it's container and label set based on the chart and dataSet. Use the modifier to alter traits and frame. private func createAccessibleElement(withIndex idx: Int, container: PieChartView, diff --git a/Source/Charts/Utils/Platform+Accessibility.swift b/Source/Charts/Utils/Platform+Accessibility.swift index 80c8c39824..b7577a72a6 100644 --- a/Source/Charts/Utils/Platform+Accessibility.swift +++ b/Source/Charts/Utils/Platform+Accessibility.swift @@ -112,8 +112,6 @@ open class NSUIAccessibilityElement: NSAccessibilityElement } } - // TODO: Make isSelected toggle a selected state in conjunction with a .valueChanged notification - /// A placeholder for parity with iOS. Has no effect. final var isSelected: Bool = false { didSet @@ -161,7 +159,7 @@ open class NSUIAccessibilityElement: NSAccessibilityElement } } -/// NOTE: setAccessibilityRole(.list) is called at init. +/// NOTE: setAccessibilityRole(.list) is called at init. See Platform.swift. extension NSUIView: NSAccessibilityGroup { open override func accessibilityLabel() -> String? From 0b15b6e09754584cc87578d617fcde467f177995 Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 20 May 2018 20:02:18 -0400 Subject: [PATCH 08/17] Added accessibility for Horizontal bars. (#1060) Updated HorizontalBarChartRenderer to populate accessibilityOrderedElements, which in turn is used by the BarChartRenderer superclass to enable accessibility. Added minor note to BarChartRenderer's createAccesibleElement() for edge case where x-axis labels can be inaccurate if multiple data sets are present. --- .../Charts/Renderers/BarChartRenderer.swift | 3 +++ .../HorizontalBarChartRenderer.swift | 25 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Source/Charts/Renderers/BarChartRenderer.swift b/Source/Charts/Renderers/BarChartRenderer.swift index d5bcf1e902..2d1a85137d 100644 --- a/Source/Charts/Renderers/BarChartRenderer.swift +++ b/Source/Charts/Renderers/BarChartRenderer.swift @@ -781,6 +781,9 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer guard let e = dataSet.entryForIndex(idx/stackSize) as? BarChartDataEntry 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)" var elementValueText = dataSet.valueFormatter?.stringForValue( diff --git a/Source/Charts/Renderers/HorizontalBarChartRenderer.swift b/Source/Charts/Renderers/HorizontalBarChartRenderer.swift index 23925c4d60..0307cd5f9a 100644 --- a/Source/Charts/Renderers/HorizontalBarChartRenderer.swift +++ b/Source/Charts/Renderers/HorizontalBarChartRenderer.swift @@ -240,7 +240,11 @@ open class HorizontalBarChartRenderer: BarChartRenderer { context.setFillColor(dataSet.color(atIndex: 0).cgColor) } - + + // In case the chart is stacked, we need to accomodate individual bars within accessibilityOrdereredElements + let isStacked = dataSet.isStacked + let stackSize = isStacked ? dataSet.stackSize : 1 + for j in stride(from: 0, to: buffer.rects.count, by: 1) { let barRect = buffer.rects[j] @@ -260,15 +264,30 @@ open class HorizontalBarChartRenderer: BarChartRenderer // Set the color for the currently drawn value. If the index is out of bounds, reuse colors. context.setFillColor(dataSet.color(atIndex: j).cgColor) } - + context.fill(barRect) - + if drawBorder { context.setStrokeColor(borderColor.cgColor) context.setLineWidth(borderWidth) context.stroke(barRect) } + + // Create and append the corresponding accessibility element to accessibilityOrderedElements (see BarChartRenderer) + if let chart = dataProvider as? BarChartView + { + let element = createAccessibleElement(withIndex: j, + container: chart, + dataSet: dataSet, + dataSetIndex: index, + stackSize: stackSize) + { (element) in + element.accessibilityFrame = barRect + } + + accessibilityOrderedElements[j/stackSize].append(element) + } } context.restoreGState() From 44f7519a3d7eb4e89dad684f311d7947b3ac2eba Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 27 May 2018 16:19:43 -0400 Subject: [PATCH 09/17] Added accessibility for Line charts. (#1060) LineChartRenderer now has a private accessibilityOrderedElements nested array that is then used to populate accessibleChartElements. Do note that the nesting is unnecessary for now, but will be needed once a custom rotor is added. Also unlike most other renderers, LineChartRenderer's accessibleChartElements is populated in drawCircles(). This required moving the check for isDrawCirclesEnabled() after accessibleChartElements are populated. --- .../Charts/Renderers/LineChartRenderer.swift | 109 +++++++++++++++++- .../Charts/Utils/Platform+Accessibility.swift | 2 + 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/Source/Charts/Renderers/LineChartRenderer.swift b/Source/Charts/Renderers/LineChartRenderer.swift index 24c5fdf285..fb74e4a284 100644 --- a/Source/Charts/Renderers/LineChartRenderer.swift +++ b/Source/Charts/Renderers/LineChartRenderer.swift @@ -19,6 +19,12 @@ import CoreGraphics open class LineChartRenderer: LineRadarRenderer { + // TODO: Currently, this nesting isn't necessary. However, it will make it much easier to add a custom rotor + // that navigates between datasets. + // NOTE: Unlike the other renderers, LineChartRenderer populates accessibleChartElements in drawCircles due to the nature of its drawing options. + /// 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: LineChartDataProvider? @objc public init(dataProvider: LineChartDataProvider, animator: Animator, viewPortHandler: ViewPortHandler) @@ -603,13 +609,31 @@ open class LineChartRenderer: LineRadarRenderer var pt = CGPoint() var rect = CGRect() + // 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? LineChartView { + let chartDescriptionText = chart.chartDescription?.text ?? "" + let dataSetDescriptions = lineData.dataSets.map { $0.label ?? "" } + let dataSetDescriptionText = dataSetDescriptions.joined(separator: ", ") + let dataSetCount = lineData.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) + } + context.saveGState() - + for i in 0 ..< dataSets.count { guard let dataSet = lineData.getDataSetByIndex(i) as? ILineChartDataSet else { continue } - if !dataSet.isVisible || !dataSet.isDrawCirclesEnabled || dataSet.entryCount == 0 + if !dataSet.isVisible || dataSet.entryCount == 0 { continue } @@ -650,13 +674,39 @@ open class LineChartRenderer: LineRadarRenderer continue } + // --------------- + let scaleFactor: CGFloat = 3 + let accessibilityRect = CGRect(x: pt.x - (scaleFactor * circleRadius), + y: pt.y - (scaleFactor * circleRadius), + width: scaleFactor * circleDiameter, + height: scaleFactor * circleDiameter) + // Create and append the corresponding accessibility element to accessibilityOrderedElements + if let chart = dataProvider as? LineChartView + { + let element = createAccessibleElement(withIndex: j, + container: chart, + dataSet: dataSet, + dataSetIndex: i) + { (element) in + element.accessibilityFrame = accessibilityRect + } + + accessibilityOrderedElements[i].append(element) + } + // --------------- + + if !dataSet.isDrawCirclesEnabled + { + continue + } + context.setFillColor(dataSet.getCircleColor(atIndex: j)!.cgColor) - + rect.origin.x = pt.x - circleRadius rect.origin.y = pt.y - circleRadius rect.size.width = circleDiameter rect.size.height = circleDiameter - + if drawTransparentCircleHole { // Begin path for circle with hole @@ -694,6 +744,10 @@ open class LineChartRenderer: LineRadarRenderer } context.restoreGState() + + // Merge nested ordered arrays into the single accessibleChartElements. + accessibleChartElements.append(contentsOf: accessibilityOrderedElements.flatMap { $0 } ) + accessibilityPostLayoutChangedNotification() } open override func drawHighlighted(context: CGContext, indices: [Highlight]) @@ -751,4 +805,51 @@ open class LineChartRenderer: LineRadarRenderer context.restoreGState() } + + /// 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? LineChartView 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: LineChartView, + dataSet: ILineChartDataSet, + 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.lineData?.dataSetCount ?? -1 + let doesContainMultipleDataSets = dataSetCount > 1 + + element.accessibilityLabel = "\(doesContainMultipleDataSets ? (dataSet.label ?? "") + ", " : "") \(label): \(elementValueText)" + + modifier(element) + + return element + } } diff --git a/Source/Charts/Utils/Platform+Accessibility.swift b/Source/Charts/Utils/Platform+Accessibility.swift index b7577a72a6..b778229b60 100644 --- a/Source/Charts/Utils/Platform+Accessibility.swift +++ b/Source/Charts/Utils/Platform+Accessibility.swift @@ -12,6 +12,7 @@ internal func accessibilityPostScreenChangedNotification(withElement element: An UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, element) } +/// A simple abstraction over UIAccessibilityElement and NSAccessibilityElement. open class NSUIAccessibilityElement: UIAccessibilityElement { private let containerView: UIView @@ -100,6 +101,7 @@ internal func accessibilityPostScreenChangedNotification(withElement element: An // Placeholder } +/// A simple abstraction over UIAccessibilityElement and NSAccessibilityElement. open class NSUIAccessibilityElement: NSAccessibilityElement { private let containerView: NSView From 2329bd72b4c5dd0cb8e660525ca2e07ee21aabb7 Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 27 May 2018 17:09:04 -0400 Subject: [PATCH 10/17] 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 { From 0a35b862f9b70753d578cf495fab9d3c8315a427 Mon Sep 17 00:00:00 2001 From: Adi Date: Tue, 29 May 2018 08:12:23 -0400 Subject: [PATCH 11/17] Added accessibility for Radar charts. (#1060) Updated RadarChartRenderer with accessibility properties and calls in drawData and drawDataSet. Due to the unique spatial arrangement of radar charts, accessibleChartElements is populated by dataset, within which variables are ordered in decreasing order. --- .../Charts/Renderers/RadarChartRenderer.swift | 97 ++++++++++++++++++- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/Source/Charts/Renderers/RadarChartRenderer.swift b/Source/Charts/Renderers/RadarChartRenderer.swift index 3b9b87fbd9..11bf58e4fa 100644 --- a/Source/Charts/Renderers/RadarChartRenderer.swift +++ b/Source/Charts/Renderers/RadarChartRenderer.swift @@ -19,6 +19,21 @@ import CoreGraphics open class RadarChartRenderer: LineRadarRenderer { + private lazy var accessibilityXLabels: [String] = { + var labels: [String] = [] + + guard let chart = chart else { return [] } + guard let formatter = chart.xAxis.valueFormatter else { return [] } + + let maxEntryCount = chart.data?.maxEntryCountSet?.entryCount ?? 0 + for i in stride(from: 0, to: maxEntryCount, by: 1) + { + labels.append(formatter.stringForValue(Double(i), axis: chart.xAxis)) + } + + return labels + }() + @objc open weak var chart: RadarChartView? @objc public init(chart: RadarChartView, animator: Animator, viewPortHandler: ViewPortHandler) @@ -37,7 +52,21 @@ open class RadarChartRenderer: LineRadarRenderer if radarData != nil { let mostEntries = radarData?.maxEntryCountSet?.entryCount ?? 0 - + + // If we redraw the data, remove and repopulate accessible elements to update label values and frames + self.accessibleChartElements.removeAll() + + // Make the chart header the first element in the accessible elements array + if let chartDescriptionText: String = chart.chartDescription?.text { + let dataSetCount = radarData!.dataSets.count + let + element = NSUIAccessibilityElement(accessibilityContainer: chart) + element.accessibilityLabel = chartDescriptionText + ". \(dataSetCount) dataset\(dataSetCount == 1 ? "" : "s")" + element.accessibilityFrame = chart.bounds + element.isHeader = true + self.accessibleChartElements.append(element) + } + for set in radarData!.dataSets as! [IRadarChartDataSet] { if set.isVisible @@ -71,7 +100,20 @@ open class RadarChartRenderer: LineRadarRenderer let entryCount = dataSet.entryCount let path = CGMutablePath() var hasMovedToPoint = false - + + let prefix: String = chart.data?.accessibilityEntryLabelPrefix ?? "Item" + let description = dataSet.label ?? "" + + // Make a tuple of (xLabels, value, originalIndex) then sort it + // This is done, so that the labels are narrated in decreasing order of their corresponding value + // Otherwise, there is no non-visual logic to the data presented + let accessibilityEntryValues = Array(0 ..< entryCount).map { (dataSet.entryForIndex($0)?.y ?? 0, $0) } + let accessibilityAxisLabelValueTuples = zip(accessibilityXLabels, accessibilityEntryValues).map { ($0, $1.0, $1.1) }.sorted { $0.1 > $1.1 } + let accessibilityDataSetDescription: String = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s")). " + let accessibilityFrameWidth: CGFloat = 9.0 + + var accessibilityEntryElements: [NSUIAccessibilityElement] = [] + for j in 0 ..< entryCount { guard let e = dataSet.entryForIndex(j) else { continue } @@ -93,6 +135,28 @@ open class RadarChartRenderer: LineRadarRenderer { path.addLine(to: p) } + + let accessibilityLabel = accessibilityAxisLabelValueTuples[j].0 + let accessibilityValue = accessibilityAxisLabelValueTuples[j].1 + let accessibilityValueIndex = accessibilityAxisLabelValueTuples[j].2 + // accessibilityDescription.append(accessibilityLabel + ": \(accessibilityValue) \(dataSet.accessibilityEntryLabelSuffix ?? "")") + // accessibilityDescription.append(j == (entryCount - 1) ? "." : ", ") + + let axp = center.moving(distance: CGFloat((accessibilityValue - chart.chartYMin) * Double(factor) * phaseY), + atAngle: sliceangle * CGFloat(accessibilityValueIndex) * CGFloat(phaseX) + chart.rotationAngle) + + let axDescription = accessibilityLabel + ": \(accessibilityValue) \(chart.data?.accessibilityEntryLabelSuffix ?? "")" + let axElement = createAccessibleElement(withDescription: axDescription, + container: chart, + dataSet: dataSet) + { (element) in + element.accessibilityFrame = CGRect(x: axp.x - accessibilityFrameWidth, + y: axp.y - accessibilityFrameWidth, + width: 2 * accessibilityFrameWidth, + height: 2 * accessibilityFrameWidth) + } + + accessibilityEntryElements.append(axElement) } // if this is the largest set, close it @@ -123,12 +187,25 @@ open class RadarChartRenderer: LineRadarRenderer context.setStrokeColor(dataSet.color(atIndex: 0).cgColor) context.setLineWidth(dataSet.lineWidth) context.setAlpha(1.0) - + context.beginPath() context.addPath(path) context.strokePath() + + let axElement = createAccessibleElement(withDescription: accessibilityDataSetDescription, + container: chart, + dataSet: dataSet) + { (element) in + element.isHeader = true + element.accessibilityFrame = path.boundingBoxOfPath + } + + accessibleChartElements.append(axElement) + accessibleChartElements.append(contentsOf: accessibilityEntryElements) } + accessibilityPostLayoutChangedNotification() + context.restoreGState() } @@ -400,4 +477,18 @@ open class RadarChartRenderer: LineRadarRenderer context.restoreGState() } + + private func createAccessibleElement(withDescription description: String, + container: RadarChartView, + dataSet: IRadarChartDataSet, + modifier: (NSUIAccessibilityElement) -> ()) -> NSUIAccessibilityElement { + + let element = NSUIAccessibilityElement(accessibilityContainer: container) + element.accessibilityLabel = description + + // The modifier allows changing of traits and frame depending on highlight, rotation, etc + modifier(element) + + return element + } } From 867a502efd54875d06800b6720135e86f44bce54 Mon Sep 17 00:00:00 2001 From: Adi Date: Mon, 18 Jun 2018 15:15:29 -0400 Subject: [PATCH 12/17] Bug fixes in Bubble and Line Chart accessibility. (#1060) Fixed crash due to incorrect use of maxEntryCountSet instead of dataSetCount in BubbleChartRenderer and LineChartRenderer's accessibilityCreateEmptyOrderedElements(). Updated BubbleChartRenderer's bubble accessibilityLabel to include percentage size of bubble based on maxSize property of IBubbleChartDataSet. --- Source/Charts/Renderers/BarChartRenderer.swift | 1 + Source/Charts/Renderers/BubbleChartRenderer.swift | 10 ++++++---- Source/Charts/Renderers/LineChartRenderer.swift | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Source/Charts/Renderers/BarChartRenderer.swift b/Source/Charts/Renderers/BarChartRenderer.swift index 2d1a85137d..0b50f0b75d 100644 --- a/Source/Charts/Renderers/BarChartRenderer.swift +++ b/Source/Charts/Renderers/BarChartRenderer.swift @@ -759,6 +759,7 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer { guard let chart = dataProvider as? BarChartView else { return [] } + // Unlike Bubble & Line charts, here we use the maximum entry count to account for stacked bars let maxEntryCount = chart.data?.maxEntryCountSet?.entryCount ?? 0 return Array(repeating: [NSUIAccessibilityElement](), diff --git a/Source/Charts/Renderers/BubbleChartRenderer.swift b/Source/Charts/Renderers/BubbleChartRenderer.swift index 4fc87647dd..c80ef31dde 100644 --- a/Source/Charts/Renderers/BubbleChartRenderer.swift +++ b/Source/Charts/Renderers/BubbleChartRenderer.swift @@ -148,7 +148,8 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer let element = createAccessibleElement(withIndex: j, container: chart, dataSet: dataSet, - dataSetIndex: dataSetIndex) + dataSetIndex: dataSetIndex, + shapeSize: shapeSize) { (element) in element.accessibilityFrame = rect } @@ -328,10 +329,10 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer { guard let chart = dataProvider as? BubbleChartView else { return [] } - let maxEntryCount = chart.data?.maxEntryCountSet?.entryCount ?? 0 + let dataSetCount = chart.bubbleData?.dataSetCount ?? 0 return Array(repeating: [NSUIAccessibilityElement](), - count: maxEntryCount) + count: dataSetCount) } /// Creates an NSUIAccessibleElement representing the smallest meaningful bar of the chart @@ -341,6 +342,7 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer container: BubbleChartView, dataSet: IBubbleChartDataSet, dataSetIndex: Int, + shapeSize: CGFloat, modifier: (NSUIAccessibilityElement) -> ()) -> NSUIAccessibilityElement { let element = NSUIAccessibilityElement(accessibilityContainer: container) @@ -362,7 +364,7 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer let dataSetCount = dataProvider.bubbleData?.dataSetCount ?? -1 let doesContainMultipleDataSets = dataSetCount > 1 - element.accessibilityLabel = "\(doesContainMultipleDataSets ? (dataSet.label ?? "") + ", " : "") \(label): \(elementValueText)" + element.accessibilityLabel = "\(doesContainMultipleDataSets ? (dataSet.label ?? "") + ", " : "") \(label): \(elementValueText), bubble size: \(String(format: "%.2f", (shapeSize/dataSet.maxSize) * 100)) %" modifier(element) diff --git a/Source/Charts/Renderers/LineChartRenderer.swift b/Source/Charts/Renderers/LineChartRenderer.swift index db02aa1f1f..38c72252f5 100644 --- a/Source/Charts/Renderers/LineChartRenderer.swift +++ b/Source/Charts/Renderers/LineChartRenderer.swift @@ -811,10 +811,10 @@ open class LineChartRenderer: LineRadarRenderer { guard let chart = dataProvider as? LineChartView else { return [] } - let maxEntryCount = chart.data?.maxEntryCountSet?.entryCount ?? 0 + let dataSetCount = chart.lineData?.dataSetCount ?? 0 return Array(repeating: [NSUIAccessibilityElement](), - count: maxEntryCount) + count: dataSetCount) } /// Creates an NSUIAccessibleElement representing the smallest meaningful bar of the chart From 99aadf508e6d0ef1a33f0ae39e17e43dfb588e3b Mon Sep 17 00:00:00 2001 From: Adi Date: Mon, 18 Jun 2018 19:54:42 -0400 Subject: [PATCH 13/17] Added accessibility for CandleStick charts. (#1060) Updated CandleStickChartRenderer to populate accessibleChartElements. Unlike most other renderers with multiple dataSet support, we do not attempt to order elements logically and hence dataSets are presented to VO in the same order they are drawn with the dataSet label acting as a separating heading. --- .../Renderers/CandleStickChartRenderer.swift | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/Source/Charts/Renderers/CandleStickChartRenderer.swift b/Source/Charts/Renderers/CandleStickChartRenderer.swift index 28a460d9fe..4487f58c21 100644 --- a/Source/Charts/Renderers/CandleStickChartRenderer.swift +++ b/Source/Charts/Renderers/CandleStickChartRenderer.swift @@ -32,6 +32,9 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer { guard let dataProvider = dataProvider, let candleData = dataProvider.candleData else { return } + // If we redraw the data, remove and repopulate accessible elements to update label values and frames + accessibleChartElements.removeAll() + for set in candleData.dataSets as! [ICandleChartDataSet] { if set.isVisible @@ -50,13 +53,17 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer @objc open func drawDataSet(context: CGContext, dataSet: ICandleChartDataSet) { - guard let dataProvider = dataProvider else { return } + guard + let dataProvider = dataProvider, + let chart = dataProvider as? CandleStickChartView + else { return } let trans = dataProvider.getTransformer(forAxis: dataSet.axisDependency) let phaseY = animator.phaseY let barSpace = dataSet.barSpace let showCandleBar = dataSet.showCandleBar + let entryCount = dataSet.entryCount _xBounds.set(chart: dataProvider, dataSet: dataSet, animator: animator) @@ -64,6 +71,17 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer context.setLineWidth(dataSet.shadowWidth) + // Make the chart header the first element in the accessible elements array + let prefix: String = chart.data?.accessibilityEntryLabelPrefix ?? "Element" + let description = chart.chartDescription?.text ?? dataSet.label ?? "" + + let + element = NSUIAccessibilityElement(accessibilityContainer: chart) + element.accessibilityLabel = "Candle Stick chart: " + description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" + element.accessibilityFrame = chart.bounds + element.isHeader = true + accessibleChartElements.append(element) + for j in stride(from: _xBounds.min, through: _xBounds.range + _xBounds.min, by: 1) { // get the entry @@ -76,6 +94,14 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer let high = e.high let low = e.low + let doesContainMultipleDataSets = (dataProvider.candleData?.dataSets.count ?? 1) > 1 + var accessibilityMovementDescription = "neutral" + var accessibilityRect = CGRect(x: CGFloat(xPos) + 0.5 - barSpace, + y: CGFloat(low * phaseY), + width: (2 * barSpace) - 1.0, + height: (CGFloat(abs(high - low) * phaseY))) + trans.rectValueToPixel(&accessibilityRect) + if showCandleBar { // calculate the shadow @@ -146,9 +172,11 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer trans.rectValueToPixel(&_bodyRect) // draw body differently for increasing and decreasing entry - + if open > close { + accessibilityMovementDescription = "decreasing" + let color = dataSet.decreasingColor ?? dataSet.color(atIndex: j) if dataSet.isDecreasingFilled @@ -164,6 +192,8 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer } else if open < close { + accessibilityMovementDescription = "increasing" + let color = dataSet.increasingColor ?? dataSet.color(atIndex: j) if dataSet.isIncreasingFilled @@ -208,13 +238,15 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer // draw the ranges var barColor: NSUIColor! = nil - + if open > close { + accessibilityMovementDescription = "decreasing" barColor = dataSet.decreasingColor ?? dataSet.color(atIndex: j) } else if open < close { + accessibilityMovementDescription = "increasing" barColor = dataSet.increasingColor ?? dataSet.color(atIndex: j) } else @@ -227,8 +259,22 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer context.strokeLineSegments(between: _openPoints) context.strokeLineSegments(between: _closePoints) } + + let axElement = createAccessibleElement(withIndex: j, + container: chart, + dataSet: dataSet) + { (element) in + element.accessibilityLabel = "\(doesContainMultipleDataSets ? "\(dataSet.label ?? "Dataset")" : "") " + "\(xPos) - \(accessibilityMovementDescription). low: \(low), high: \(high), opening: \(open), closing: \(close)" + element.accessibilityFrame = accessibilityRect + } + + accessibleChartElements.append(axElement) + } - + + // Post this notification to let VoiceOver account for the redrawn frames + accessibilityPostLayoutChangedNotification() + context.restoreGState() } @@ -374,4 +420,17 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer context.restoreGState() } + + private func createAccessibleElement(withIndex idx: Int, + container: CandleChartDataProvider, + dataSet: ICandleChartDataSet, + modifier: (NSUIAccessibilityElement) -> ()) -> NSUIAccessibilityElement { + + let element = NSUIAccessibilityElement(accessibilityContainer: container) + + // The modifier allows changing of traits and frame depending on highlight, rotation, etc + modifier(element) + + return element + } } From afaf9e54a92094c178ade9720a252914e16444f9 Mon Sep 17 00:00:00 2001 From: Adi Date: Tue, 19 Jun 2018 19:29:02 -0400 Subject: [PATCH 14/17] Fixed accessibility frame calculation on macOS. (#1060) Added a workaround for non updating accessibility frame when resizing windows and using Charts on macOS by using setAccessibilityFrameInParent() in Platform+Accessibility. See inline comments for details. --- .../Charts/Utils/Platform+Accessibility.swift | 17 ++++++++++++++++- Source/Charts/Utils/Platform.swift | 3 ++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Source/Charts/Utils/Platform+Accessibility.swift b/Source/Charts/Utils/Platform+Accessibility.swift index b778229b60..0d36dc374f 100644 --- a/Source/Charts/Utils/Platform+Accessibility.swift +++ b/Source/Charts/Utils/Platform+Accessibility.swift @@ -145,7 +145,22 @@ open class NSUIAccessibilityElement: NSAccessibilityElement set { let bounds = NSAccessibilityFrameInView(containerView, newValue) - setAccessibilityFrame(bounds) + + // This works, but won't auto update if the window is resized or moved. + // setAccessibilityFrame(bounds) + + // using FrameInParentSpace allows for automatic updating of frame when windows are moved and resized. + // However, there seems to be a bug right now where using it causes an offset in the frame. + // This is a slightly hacky workaround that calculates the offset and removes it from frame calculation. + setAccessibilityFrameInParentSpace(bounds) + let axFrame = accessibilityFrame() + let widthOffset = fabs(axFrame.origin.x - bounds.origin.x) + let heightOffset = fabs(axFrame.origin.y - bounds.origin.y) + let rect = NSRect(x: bounds.origin.x - widthOffset, + y: bounds.origin.y - heightOffset, + width: bounds.width, + height: bounds.height) + setAccessibilityFrameInParentSpace(rect) } } diff --git a/Source/Charts/Utils/Platform.swift b/Source/Charts/Utils/Platform.swift index 8141ad960e..eb60c53dbf 100644 --- a/Source/Charts/Utils/Platform.swift +++ b/Source/Charts/Utils/Platform.swift @@ -394,7 +394,8 @@ types are aliased to either their UI* implementation (on iOS) or their NS* imple open class NSUIView: NSView { - /// A private constant to set the accessibility role during initialization + /// A private constant to set the accessibility role during initialization. + /// It ensures parity with the iOS element ordering as well as numbered counts of chart components. /// (See Platform+Accessibility for details) private let role: NSAccessibilityRole = .list From c78576648a172e3476107a6b2d0a727e989b83bd Mon Sep 17 00:00:00 2001 From: Adi Date: Wed, 20 Jun 2018 12:16:59 -0400 Subject: [PATCH 15/17] Unified accessible header creation for renderers. (#1060) Added createAccessibleHeader() to ChartRendererBase.swift that is used to create a descriptive header for all subclasses based on dataSet count and labels. RadarChartRenderer and PieChartRenderer updated to add dataSet label to each item and with documentation respectively. --- .../Charts/Renderers/BarChartRenderer.swift | 14 +++-------- .../Renderers/BubbleChartRenderer.swift | 17 +++---------- .../Renderers/CandleStickChartRenderer.swift | 19 ++++++-------- .../Renderers/ChartDataRendererBase.swift | 25 +++++++++++++++++++ .../Renderers/CombinedChartRenderer.swift | 16 ++++++++++++ .../Charts/Renderers/LineChartRenderer.swift | 14 +++-------- .../Charts/Renderers/PieChartRenderer.swift | 3 ++- .../Charts/Renderers/RadarChartRenderer.swift | 17 +++++-------- .../Renderers/ScatterChartRenderer.swift | 16 +++++++++++- 9 files changed, 84 insertions(+), 57 deletions(-) diff --git a/Source/Charts/Renderers/BarChartRenderer.swift b/Source/Charts/Renderers/BarChartRenderer.swift index 0b50f0b75d..b15efec09b 100644 --- a/Source/Charts/Renderers/BarChartRenderer.swift +++ b/Source/Charts/Renderers/BarChartRenderer.swift @@ -201,7 +201,7 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer } } } - + open override func drawData(context: CGContext) { guard @@ -215,15 +215,9 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer // Make the chart header the first element in the accessible elements array if let chart = dataProvider as? BarChartView { - let chartDescriptionText = chart.chartDescription?.text ?? "" - let dataSetDescriptions = barData.dataSets.map { $0.label ?? "" } - let dataSetDescriptionText = dataSetDescriptions.joined(separator: ", ") - let dataSetCount = barData.dataSets.count - let - element = NSUIAccessibilityElement(accessibilityContainer: chart) - element.accessibilityLabel = chartDescriptionText + ". \(dataSetCount) dataset\(dataSetCount == 1 ? "" : "s"). \(dataSetDescriptionText)" - element.accessibilityFrame = chart.bounds - element.isHeader = true + let element = createAccessibleHeader(usingChart: chart, + andData: barData, + withDefaultDescription: "Bar Chart") accessibleChartElements.append(element) } diff --git a/Source/Charts/Renderers/BubbleChartRenderer.swift b/Source/Charts/Renderers/BubbleChartRenderer.swift index c80ef31dde..e182a4be9c 100644 --- a/Source/Charts/Renderers/BubbleChartRenderer.swift +++ b/Source/Charts/Renderers/BubbleChartRenderer.swift @@ -44,15 +44,9 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer // 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 + let element = createAccessibleHeader(usingChart: chart, + andData: bubbleData, + withDefaultDescription: "Bubble Chart") accessibleChartElements.append(element) } @@ -324,7 +318,6 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer } /// 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 [] } @@ -335,9 +328,7 @@ open class BubbleChartRenderer: BarLineScatterCandleBubbleRenderer count: dataSetCount) } - /// 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. + /// Creates an NSUIAccessibleElement representing individual bubbles location and relative size. private func createAccessibleElement(withIndex idx: Int, container: BubbleChartView, dataSet: IBubbleChartDataSet, diff --git a/Source/Charts/Renderers/CandleStickChartRenderer.swift b/Source/Charts/Renderers/CandleStickChartRenderer.swift index 4487f58c21..7324e83a8a 100644 --- a/Source/Charts/Renderers/CandleStickChartRenderer.swift +++ b/Source/Charts/Renderers/CandleStickChartRenderer.swift @@ -35,6 +35,14 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer // If we redraw the data, remove and repopulate accessible elements to update label values and frames accessibleChartElements.removeAll() + // Make the chart header the first element in the accessible elements array + if let chart = dataProvider as? CandleStickChartView { + let element = createAccessibleHeader(usingChart: chart, + andData: candleData, + withDefaultDescription: "CandleStick Chart") + accessibleChartElements.append(element) + } + for set in candleData.dataSets as! [ICandleChartDataSet] { if set.isVisible @@ -70,17 +78,6 @@ open class CandleStickChartRenderer: LineScatterCandleRadarRenderer context.saveGState() context.setLineWidth(dataSet.shadowWidth) - - // Make the chart header the first element in the accessible elements array - let prefix: String = chart.data?.accessibilityEntryLabelPrefix ?? "Element" - let description = chart.chartDescription?.text ?? dataSet.label ?? "" - - let - element = NSUIAccessibilityElement(accessibilityContainer: chart) - element.accessibilityLabel = "Candle Stick chart: " + description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" - element.accessibilityFrame = chart.bounds - element.isHeader = true - accessibleChartElements.append(element) for j in stride(from: _xBounds.min, through: _xBounds.range + _xBounds.min, by: 1) { diff --git a/Source/Charts/Renderers/ChartDataRendererBase.swift b/Source/Charts/Renderers/ChartDataRendererBase.swift index 26b1f41167..ef21083966 100644 --- a/Source/Charts/Renderers/ChartDataRendererBase.swift +++ b/Source/Charts/Renderers/ChartDataRendererBase.swift @@ -65,4 +65,29 @@ open class DataRenderer: Renderer guard let data = dataProvider?.data else { return false } return data.entryCount < Int(CGFloat(dataProvider?.maxVisibleCount ?? 0) * viewPortHandler.scaleX) } + + /// Creates an ```NSUIAccessibilityElement``` that acts as the first and primary header describing a chart view. + /// + /// - Parameters: + /// - chart: The chartView object being described + /// - data: A non optional data source about the chart + /// - defaultDescription: A simple string describing the type/design of Chart. + /// - Returns: A header ```NSUIAccessibilityElement``` that can be added to accessibleChartElements. + @objc internal func createAccessibleHeader(usingChart chart: ChartViewBase, + andData data: ChartData, + withDefaultDescription defaultDescription: String = "Chart") -> NSUIAccessibilityElement + { + let chartDescriptionText = chart.chartDescription?.text ?? defaultDescription + let dataSetDescriptions = data.dataSets.map { $0.label ?? "" } + let dataSetDescriptionText = dataSetDescriptions.joined(separator: ", ") + let dataSetCount = data.dataSets.count + + let + element = NSUIAccessibilityElement(accessibilityContainer: chart) + element.accessibilityLabel = chartDescriptionText + ". \(dataSetCount) dataset\(dataSetCount == 1 ? "" : "s"). \(dataSetDescriptionText)" + element.accessibilityFrame = chart.bounds + element.isHeader = true + + return element + } } diff --git a/Source/Charts/Renderers/CombinedChartRenderer.swift b/Source/Charts/Renderers/CombinedChartRenderer.swift index d38ccb5f7a..bc80bd0a34 100644 --- a/Source/Charts/Renderers/CombinedChartRenderer.swift +++ b/Source/Charts/Renderers/CombinedChartRenderer.swift @@ -95,6 +95,22 @@ open class CombinedChartRenderer: DataRenderer open override func drawData(context: CGContext) { + // If we redraw the data, remove and repopulate accessible elements to update label values and frames + accessibleChartElements.removeAll() + + if + let combinedChart = chart, + let data = combinedChart.data { + // Make the chart header the first element in the accessible elements array + let element = createAccessibleHeader(usingChart: combinedChart, + andData: data, + withDefaultDescription: "Combined Chart") + accessibleChartElements.append(element) + } + + // TODO: Due to the potential complexity of data presented in Combined charts, a more usable way + // for VO accessibility would be to use axis based traversal rather than by dataset. + // Hence, accessibleChartElements is not populated below. (Individual renderers guard against dataSource being their respective views) for renderer in _renderers { renderer.drawData(context: context) diff --git a/Source/Charts/Renderers/LineChartRenderer.swift b/Source/Charts/Renderers/LineChartRenderer.swift index 38c72252f5..176a18c022 100644 --- a/Source/Charts/Renderers/LineChartRenderer.swift +++ b/Source/Charts/Renderers/LineChartRenderer.swift @@ -19,7 +19,7 @@ import CoreGraphics open class LineChartRenderer: LineRadarRenderer { - // TODO: Currently, this nesting isn't necessary. However, it will make it much easier to add a custom rotor + // TODO: Currently, this nesting isn't necessary for LineCharts. However, it will make it much easier to add a custom rotor // that navigates between datasets. // NOTE: Unlike the other renderers, LineChartRenderer populates accessibleChartElements in drawCircles due to the nature of its drawing options. /// A nested array of elements ordered logically (i.e not in visual/drawing order) for use with VoiceOver. @@ -615,15 +615,9 @@ open class LineChartRenderer: LineRadarRenderer // Make the chart header the first element in the accessible elements array if let chart = dataProvider as? LineChartView { - let chartDescriptionText = chart.chartDescription?.text ?? "" - let dataSetDescriptions = lineData.dataSets.map { $0.label ?? "" } - let dataSetDescriptionText = dataSetDescriptions.joined(separator: ", ") - let dataSetCount = lineData.dataSets.count - let - element = NSUIAccessibilityElement(accessibilityContainer: chart) - element.accessibilityLabel = chartDescriptionText + ". \(dataSetCount) dataset\(dataSetCount == 1 ? "" : "s"). \(dataSetDescriptionText)" - element.accessibilityFrame = chart.bounds - element.isHeader = true + let element = createAccessibleHeader(usingChart: chart, + andData: lineData, + withDefaultDescription: "Line Chart") accessibleChartElements.append(element) } diff --git a/Source/Charts/Renderers/PieChartRenderer.swift b/Source/Charts/Renderers/PieChartRenderer.swift index 4e0eaa5f6c..aab8bfe1ec 100644 --- a/Source/Charts/Renderers/PieChartRenderer.swift +++ b/Source/Charts/Renderers/PieChartRenderer.swift @@ -140,12 +140,13 @@ open class PieChartRenderer: DataRenderer // Make the chart header the first element in the accessible elements array // We can do this in drawDataSet, since we know PieChartView can have only 1 dataSet + // Also since there's only 1 dataset, we don't use the typical createAccessibleHeader() here. // NOTE: - Since we want to summarize the total count of slices/portions/elements, use a default string here // This is unlike when we are naming individual slices, wherein it's alright to not use a prefix as descriptor. // i.e. We want to VO to say "3 Elements" even if the developer didn't specify an accessibility prefix // If prefix is unspecified it is safe to assume they did not want to use "Element 1", so that uses a default empty string let prefix: String = chart.data?.accessibilityEntryLabelPrefix ?? "Element" - let description = chart.chartDescription?.text ?? dataSet.label ?? chart.centerText ?? "" + let description = chart.chartDescription?.text ?? dataSet.label ?? chart.centerText ?? "Pie Chart" let element = NSUIAccessibilityElement(accessibilityContainer: chart) diff --git a/Source/Charts/Renderers/RadarChartRenderer.swift b/Source/Charts/Renderers/RadarChartRenderer.swift index 11bf58e4fa..a295fad6ee 100644 --- a/Source/Charts/Renderers/RadarChartRenderer.swift +++ b/Source/Charts/Renderers/RadarChartRenderer.swift @@ -57,13 +57,10 @@ open class RadarChartRenderer: LineRadarRenderer self.accessibleChartElements.removeAll() // Make the chart header the first element in the accessible elements array - if let chartDescriptionText: String = chart.chartDescription?.text { - let dataSetCount = radarData!.dataSets.count - let - element = NSUIAccessibilityElement(accessibilityContainer: chart) - element.accessibilityLabel = chartDescriptionText + ". \(dataSetCount) dataset\(dataSetCount == 1 ? "" : "s")" - element.accessibilityFrame = chart.bounds - element.isHeader = true + if let accessibilityHeaderData = radarData as? RadarChartData { + let element = createAccessibleHeader(usingChart: chart, + andData: accessibilityHeaderData, + withDefaultDescription: "Radar Chart") self.accessibleChartElements.append(element) } @@ -110,7 +107,7 @@ open class RadarChartRenderer: LineRadarRenderer let accessibilityEntryValues = Array(0 ..< entryCount).map { (dataSet.entryForIndex($0)?.y ?? 0, $0) } let accessibilityAxisLabelValueTuples = zip(accessibilityXLabels, accessibilityEntryValues).map { ($0, $1.0, $1.1) }.sorted { $0.1 > $1.1 } let accessibilityDataSetDescription: String = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s")). " - let accessibilityFrameWidth: CGFloat = 9.0 + let accessibilityFrameWidth: CGFloat = 22.0 // To allow a tap target of 44x44 var accessibilityEntryElements: [NSUIAccessibilityElement] = [] @@ -139,13 +136,11 @@ open class RadarChartRenderer: LineRadarRenderer let accessibilityLabel = accessibilityAxisLabelValueTuples[j].0 let accessibilityValue = accessibilityAxisLabelValueTuples[j].1 let accessibilityValueIndex = accessibilityAxisLabelValueTuples[j].2 - // accessibilityDescription.append(accessibilityLabel + ": \(accessibilityValue) \(dataSet.accessibilityEntryLabelSuffix ?? "")") - // accessibilityDescription.append(j == (entryCount - 1) ? "." : ", ") let axp = center.moving(distance: CGFloat((accessibilityValue - chart.chartYMin) * Double(factor) * phaseY), atAngle: sliceangle * CGFloat(accessibilityValueIndex) * CGFloat(phaseX) + chart.rotationAngle) - let axDescription = accessibilityLabel + ": \(accessibilityValue) \(chart.data?.accessibilityEntryLabelSuffix ?? "")" + let axDescription = description + " - " + accessibilityLabel + ": \(accessibilityValue) \(chart.data?.accessibilityEntryLabelSuffix ?? "")" let axElement = createAccessibleElement(withDescription: axDescription, container: chart, dataSet: dataSet) diff --git a/Source/Charts/Renderers/ScatterChartRenderer.swift b/Source/Charts/Renderers/ScatterChartRenderer.swift index 010ca16791..acff926e0f 100644 --- a/Source/Charts/Renderers/ScatterChartRenderer.swift +++ b/Source/Charts/Renderers/ScatterChartRenderer.swift @@ -27,11 +27,25 @@ open class ScatterChartRenderer: LineScatterCandleRadarRenderer self.dataProvider = dataProvider } - + open override func drawData(context: CGContext) { guard let scatterData = dataProvider?.scatterData else { return } + + // If we redraw the data, remove and repopulate accessible elements to update label values and frames + accessibleChartElements.removeAll() + if let chart = dataProvider as? ScatterChartView { + // Make the chart header the first element in the accessible elements array + let element = createAccessibleHeader(usingChart: chart, + andData: scatterData, + withDefaultDescription: "Scatter Chart") + accessibleChartElements.append(element) + } + + // TODO: Due to the potential complexity of data presented in Scatter charts, a more usable way + // for VO accessibility would be to use axis based traversal rather than by dataset. + // Hence, accessibleChartElements is not populated below. (Individual renderers guard against dataSource being their respective views) for i in 0 ..< scatterData.dataSetCount { guard let set = scatterData.getDataSetByIndex(i) else { continue } From a77b5efb8d5bbaa70c7aee7b8642a3c782c8abe6 Mon Sep 17 00:00:00 2001 From: Adi Date: Wed, 20 Jun 2018 19:35:09 -0400 Subject: [PATCH 16/17] Fixed incorrect accessibility frames for Bar chart. (#1060) Added an offset to BarChartRenderer's barRect calculation in prepareBuffer() to prevent calculation of rects outside visible chart area, while still allowing automatic offset of the axis minima visually. This workaround is only used when using auto calculated y-axis minima and isn't used when a custom minimum is manually set. --- .../Charts/Renderers/BarChartRenderer.swift | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/Source/Charts/Renderers/BarChartRenderer.swift b/Source/Charts/Renderers/BarChartRenderer.swift index b15efec09b..9427d5e39e 100644 --- a/Source/Charts/Renderers/BarChartRenderer.swift +++ b/Source/Charts/Renderers/BarChartRenderer.swift @@ -114,10 +114,10 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer guard let e = dataSet.entryForIndex(i) as? BarChartDataEntry else { continue } let vals = e.yValues - + x = e.x y = e.y - + if !containsStacks || vals == nil { let left = CGFloat(x - barWidthHalf) @@ -128,7 +128,7 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer var bottom = isInverted ? (y >= 0.0 ? CGFloat(y) : 0) : (y <= 0.0 ? CGFloat(y) : 0) - + // multiply the height of the rect with the phase if top > 0 { @@ -138,12 +138,29 @@ open class BarChartRenderer: BarLineScatterCandleBubbleRenderer { bottom *= CGFloat(phaseY) } - + + // When drawing with an auto calculated y-axis minimum, the renderer actually draws each bar from 0 + // to the required value. This drawn bar is then clipped to the visible chart rect in BarLineChartViewBase's draw(rect:) using clipDataToContent. + // While this works fine when calculating the bar rects for drawing, it causes the accessibilityFrames to be oversized in some cases. + // This offset attempts to undo that unnecessary drawing when calculating barRects, particularly when not using custom axis minima. + // This allows the minimum to still be visually non zero, but the rects are only drawn where necessary. + // This offset calculation also avoids cases where there are positive/negative values mixed, since those won't need this offset. + var offset: CGFloat = 0.0 + if let offsetView = dataProvider as? BarChartView { + + let offsetAxis = offsetView.leftAxis.isEnabled ? offsetView.leftAxis : offsetView.rightAxis + + if barData.yMin.sign != barData.yMax.sign { offset = 0.0 } + else if !offsetAxis._customAxisMin { + offset = CGFloat(offsetAxis.axisMinimum) + } + } + barRect.origin.x = left barRect.size.width = right - left barRect.origin.y = top - barRect.size.height = bottom - top - + barRect.size.height = bottom - top + offset + buffer.rects[bufferIndex] = barRect bufferIndex += 1 } From 71f5c3405e6df05f25206210f30555b2d2a93c1f Mon Sep 17 00:00:00 2001 From: Adi Date: Thu, 28 Jun 2018 20:58:50 -0400 Subject: [PATCH 17/17] Minor change to adhere to UIAccessibility docs. (#1060) Updated return value for the index(of:) function to be NSNotFound in Platform+Accessibility's iOS section. This is as required by the documentation for UIAccessibilityContainer protocol. --- Source/Charts/Utils/Platform+Accessibility.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Charts/Utils/Platform+Accessibility.swift b/Source/Charts/Utils/Platform+Accessibility.swift index 0d36dc374f..37cd6fa07c 100644 --- a/Source/Charts/Utils/Platform+Accessibility.swift +++ b/Source/Charts/Utils/Platform+Accessibility.swift @@ -81,8 +81,8 @@ extension NSUIView open override func index(ofAccessibilityElement element: Any) -> Int { - guard let axElement = element as? NSUIAccessibilityElement else { return -1 } - return (accessibilityChildren() as? [NSUIAccessibilityElement])?.index(of: axElement) ?? -1 + guard let axElement = element as? NSUIAccessibilityElement else { return NSNotFound } + return (accessibilityChildren() as? [NSUIAccessibilityElement])?.index(of: axElement) ?? NSNotFound } }