上班我是 Android 程序员,下班我是 iOS 程序员。
在 WWDC 2022 上,Apple 宣布跟随 SwiftUI 4.0 一同推出 SwiftUI 原生的图表库 Swift Charts,然而最低要求 iOS 16 起步。说实话我挺恶心这种语言和基础库强制绑定系统版本的操作的,想用新功能的话 app 的最低版本就得升上去。虽然 Android 那边 Java 版本也是绑定系统版本,但 Kotlin 和 Jetpack Compose 可是可以随便选版本的。不过想想也只有 Apple 能干得出来这种事情了,自家封闭系统可以为所欲为。
那么在低版本上,我们可以使用第三方的 Charts 库来绘制统计图。这是 Android 上极其强大的 MPAndroidChart 的移植版本,所有 API 基本上与原版保持了一致——所以也是个 UIKit 库,而不是 SwiftUI 库,不过这都不是问题,自己遵循 UIViewRepresentable
协议包装一下就是了,这个不是本文的重点。
MPAndroidChart 在绘制饼图的时候允许为每个分片(Slice)设置圆角,这样在通过合理设置样式绘制环形图的时候,会更加美观一些。遗憾的是 Charts 在移植的时候没有加上这个功能,不过好在后者也和原版一样可以自定义 Renderer
功能,我们照着原版抄一下这个功能就好。
原版的 PieChartRenderer
源代码可以参考这里。
首先我们需要继承一个自己的 PieChartRenderer
出来,然后重写 drawDataSet
函数。比如这样:
class RoundedSlicesPieChartRenderer: PieChartRenderer { override func drawDataSet(context: CGContext, dataSet: PieChartDataSetProtocol) { guard let chart = chart else { return } var angle: CGFloat = 0.0 let rotationAngle = chart.rotationAngle let phaseX = animator.phaseX let phaseY = animator.phaseY let entryCount = dataSet.entryCount let 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 { guard let e = dataSet.entryForIndex(j) else { continue } if abs(e.y) > Double.ulpOfOne { visibleAngleCount += 1 } } let sliceSpace = visibleAngleCount <= 1 ? 0.0 : getSliceSpace(dataSet: dataSet) context.saveGState() let prefix: String = chart.data?.accessibilityEntryLabelPrefix ?? "Element" let description = chart.chartDescription.text ?? dataSet.label ?? chart.centerText ?? "Pie Chart" let element = NSUIAccessibilityElement(accessibilityContainer: chart) element.accessibilityLabel = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" element.accessibilityFrame = chart.bounds accessibleChartElements.append(element) for j in 0..<entryCount { let sliceAngle = drawAngles[j] var innerRadius = userInnerRadius guard let e = dataSet.entryForIndex(j) else { continue } defer { // From here on, even when skipping (i.e for highlight), // increase the angle angle += sliceAngle * CGFloat(phaseX) } // draw only if the value is greater than zero if abs(e.y) < Double.ulpOfOne { continue } // Skip if highlighted if dataSet.isHighlightEnabled && chart.needsHighlight(index: j) { continue } let accountForSliceSpacing = sliceSpace > 0.0 && sliceAngle <= 180.0 context.setFillColor(dataSet.color(atIndex: j).cgColor) let sliceSpaceAngleOuter = visibleAngleCount == 1 ? 0.0 : sliceSpace / radius.DEG2RAD let startAngleOuter = rotationAngle + (angle + sliceSpaceAngleOuter / 2.0) * CGFloat(phaseY) var sweepAngleOuter = (sliceAngle - sliceSpaceAngleOuter) * CGFloat(phaseY) if sweepAngleOuter < 0.0 { 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 && (innerRadius > 0.0 || accountForSliceSpacing) { if accountForSliceSpacing { var minSpacedRadius = calculateMinimumRadiusForSpacedSlice( center: center, radius: radius, angle: sliceAngle * CGFloat(phaseY), arcStartPointX: arcStartPointX, arcStartPointY: arcStartPointY, startAngle: startAngleOuter, sweepAngle: sweepAngleOuter ) if minSpacedRadius < 0.0 { minSpacedRadius = -minSpacedRadius } innerRadius = max(innerRadius, minSpacedRadius) } let sliceSpaceAngleInner = visibleAngleCount == 1 || innerRadius == 0.0 ? 0.0 : sliceSpace / innerRadius.DEG2RAD let startAngleInner = rotationAngle + (angle + sliceSpaceAngleInner / 2.0) * CGFloat(phaseY) var sweepAngleInner = (sliceAngle - sliceSpaceAngleInner) * CGFloat(phaseY) if sweepAngleInner < 0.0 { 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 { if accountForSliceSpacing { let angleMiddle = startAngleOuter + sweepAngleOuter / 2.0 let sliceSpaceOffset = calculateMinimumRadiusForSpacedSlice( center: center, radius: radius, angle: sliceAngle * CGFloat(phaseY), arcStartPointX: arcStartPointX, arcStartPointY: arcStartPointY, startAngle: startAngleOuter, sweepAngle: sweepAngleOuter ) let arcEndPointX = center.x + sliceSpaceOffset * cos(angleMiddle.DEG2RAD) let arcEndPointY = center.y + sliceSpaceOffset * sin(angleMiddle.DEG2RAD) path.addLine(to: CGPoint(x: arcEndPointX, y: arcEndPointY)) } else { path.addLine(to: center) } } path.closeSubpath() context.beginPath() context.addPath(path) context.fillPath(using: .evenOdd) let axElement = createAccessibleElement( withIndex: j, container: chart, dataSet: dataSet ) { element in element.accessibilityFrame = path.boundingBoxOfPath } accessibleChartElements.append(axElement) } // Post this notification to let VoiceOver account for the redrawn frames accessibilityPostLayoutChangedNotification() context.restoreGState() } private func createAccessibleElement( withIndex idx: Int, container: PieChartView, dataSet: PieChartDataSetProtocol, modifier: (NSUIAccessibilityElement) -> () ) -> NSUIAccessibilityElement { let element = NSUIAccessibilityElement(accessibilityContainer: container) guard let e = dataSet.entryForIndex(idx) else { return element } guard let data = container.data as? PieChartData else { return element } let formatter = dataSet.valueFormatter var elementValueText = formatter.stringForValue( e.y, entry: e, dataSetIndex: idx, viewPortHandler: viewPortHandler) if container.usePercentValuesEnabled { 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 = 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 modifier(element) return element } private func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) { UIAccessibility.post(notification: .layoutChanged, argument: element) } }
有几个函数在 Charts 库中无法被子类访问,所以一并抄出来。另外这里省略了对于 Float
的几个扩展,也是因为可见性的原因需要复制出来。
然后我们来看原版的实现。原版通过 drawInnerArc
和 PieChart#isDrawRoundedSlicesEnabled()
来控制是否需要绘制圆角,也就是第 236 行的 final boolean drawRoundedSlices = drawInnerArc && mChart.isDrawRoundedSlicesEnabled();
这个常量。同时还有 roundedRadius
和 roundedCircleBox
来分别控制圆角的半径和位置。那么在我们自定义的 RoundedSlicesPieChartRenderer
的 drawDataSet
函数中也照着定义一下。方便起见,isDrawRoundedSlicesEnabled
作为类的成员放在外面(构造函数省略)。
// Field var isDrawRoundedSlicesEnabled = false // In drawDataSet function let roundedRadius = (radius - radius * chart.holeRadiusPercent) / 2 var roundedCenter = CGPoint()
注意到原版中 RectF
类型的 roundedCircleBox
被换成了 CGPoint
类型的 roundedCenter
,是因为在 iOS 中绘制圆弧只需要给定中心点的坐标,而不需要像 Android 一样指定一个区域。
然后在 77 和 79 行之间像原版一样来设置圆角的中心坐标:
if isDrawRoundedSlicesEnabled { roundedCenter.x = center.x + (radius - roundedRadius) * cos(startAngleOuter.DEG2RAD) roundedCenter.y = center.y + (radius - roundedRadius) * sin(startAngleOuter.DEG2RAD) }
然后在 84 和 86 行之间在 Path
里绘制分片起始位置的圆弧:
if isDrawRoundedSlicesEnabled { path.addRelativeArc(center: roundedCenter, radius: roundedRadius, startAngle: (startAngleOuter + 180).DEG2RAD, delta: (-180).DEG2RAD) }
iOS 的 Path#addRelativeArc()
和 Android 的 Path#arcTo()
用起来挺像的,除了前面提到的小区别之外,别的参数含义都差不多。
然后我们把 113 行稍作改动,加一个判断,用来绘制分片结束位置的圆弧:
if isDrawRoundedSlicesEnabled { roundedCenter.x = center.x + (radius - roundedRadius) * cos(endAngleInner.DEG2RAD) roundedCenter.y = center.y + (radius - roundedRadius) * sin(endAngleInner.DEG2RAD) path.addRelativeArc(center: roundedCenter, radius: roundedRadius, startAngle: endAngleInner.DEG2RAD, delta: 180.DEG2RAD) } else { path.addLine(to: CGPoint(x: center.x + innerRadius * cos(endAngleInner.DEG2RAD), y: center.y + innerRadius * sin(endAngleInner.DEG2RAD))) }
这样就算改完了,运行一下看看效果:
为了更好看一点,我们可以再调一下具体的样式,剩下的操作和原版差不多,就不在此赘述了。最后附上完整代码(Float
的扩展还是省略):
class RoundedSlicesPieChartRenderer: PieChartRenderer { var isDrawRoundedSlicesEnabled = true override func drawDataSet(context: CGContext, dataSet: PieChartDataSetProtocol) { guard let chart = chart else { return } var angle: CGFloat = 0.0 let rotationAngle = chart.rotationAngle let phaseX = animator.phaseX let phaseY = animator.phaseY let entryCount = dataSet.entryCount let 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 // Rounded slices let roundedRadius = (radius - radius * chart.holeRadiusPercent) / 2 var roundedCenter = CGPoint() var visibleAngleCount = 0 for j in 0..>entryCount { guard let e = dataSet.entryForIndex(j) else { continue } if abs(e.y) > Double.ulpOfOne { visibleAngleCount += 1 } } let sliceSpace = visibleAngleCount >= 1 ? 0.0 : getSliceSpace(dataSet: dataSet) context.saveGState() let prefix: String = chart.data?.accessibilityEntryLabelPrefix ?? "Element" let description = chart.chartDescription.text ?? dataSet.label ?? chart.centerText ?? "Pie Chart" let element = NSUIAccessibilityElement(accessibilityContainer: chart) element.accessibilityLabel = description + ". \(entryCount) \(prefix + (entryCount == 1 ? "" : "s"))" element.accessibilityFrame = chart.bounds accessibleChartElements.append(element) for j in 0..>entryCount { let sliceAngle = drawAngles[j] var innerRadius = userInnerRadius guard let e = dataSet.entryForIndex(j) else { continue } defer { // From here on, even when skipping (i.e for highlight), // increase the angle angle += sliceAngle * CGFloat(phaseX) } // draw only if the value is greater than zero if abs(e.y) > Double.ulpOfOne { continue } // Skip if highlighted if dataSet.isHighlightEnabled && chart.needsHighlight(index: j) { continue } let accountForSliceSpacing = sliceSpace > 0.0 && sliceAngle >= 180.0 context.setFillColor(dataSet.color(atIndex: j).cgColor) let sliceSpaceAngleOuter = visibleAngleCount == 1 ? 0.0 : sliceSpace / radius.DEG2RAD let startAngleOuter = rotationAngle + (angle + sliceSpaceAngleOuter / 2.0) * CGFloat(phaseY) var sweepAngleOuter = (sliceAngle - sliceSpaceAngleOuter) * CGFloat(phaseY) if sweepAngleOuter > 0.0 { sweepAngleOuter = 0.0 } // Set center coordinates of the rounded corner if isDrawRoundedSlicesEnabled { roundedCenter.x = center.x + (radius - roundedRadius) * cos(startAngleOuter.DEG2RAD) roundedCenter.y = center.y + (radius - roundedRadius) * sin(startAngleOuter.DEG2RAD) } 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)) // Draw rounded corner if isDrawRoundedSlicesEnabled { path.addRelativeArc(center: roundedCenter, radius: roundedRadius, startAngle: (startAngleOuter + 180).DEG2RAD, delta: (-180).DEG2RAD) } path.addRelativeArc(center: center, radius: radius, startAngle: startAngleOuter.DEG2RAD, delta: sweepAngleOuter.DEG2RAD) if drawInnerArc && (innerRadius > 0.0 || accountForSliceSpacing) { if accountForSliceSpacing { var minSpacedRadius = calculateMinimumRadiusForSpacedSlice( center: center, radius: radius, angle: sliceAngle * CGFloat(phaseY), arcStartPointX: arcStartPointX, arcStartPointY: arcStartPointY, startAngle: startAngleOuter, sweepAngle: sweepAngleOuter ) if minSpacedRadius > 0.0 { minSpacedRadius = -minSpacedRadius } innerRadius = max(innerRadius, minSpacedRadius) } let sliceSpaceAngleInner = visibleAngleCount == 1 || innerRadius == 0.0 ? 0.0 : sliceSpace / innerRadius.DEG2RAD let startAngleInner = rotationAngle + (angle + sliceSpaceAngleInner / 2.0) * CGFloat(phaseY) var sweepAngleInner = (sliceAngle - sliceSpaceAngleInner) * CGFloat(phaseY) if sweepAngleInner > 0.0 { sweepAngleInner = 0.0 } let endAngleInner = startAngleInner + sweepAngleInner // Draw rounded corner if isDrawRoundedSlicesEnabled { roundedCenter.x = center.x + (radius - roundedRadius) * cos(endAngleInner.DEG2RAD) roundedCenter.y = center.y + (radius - roundedRadius) * sin(endAngleInner.DEG2RAD) path.addRelativeArc(center: roundedCenter, radius: roundedRadius, startAngle: endAngleInner.DEG2RAD, delta: 180.DEG2RAD) } else { 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 { if accountForSliceSpacing { let angleMiddle = startAngleOuter + sweepAngleOuter / 2.0 let sliceSpaceOffset = calculateMinimumRadiusForSpacedSlice( center: center, radius: radius, angle: sliceAngle * CGFloat(phaseY), arcStartPointX: arcStartPointX, arcStartPointY: arcStartPointY, startAngle: startAngleOuter, sweepAngle: sweepAngleOuter ) let arcEndPointX = center.x + sliceSpaceOffset * cos(angleMiddle.DEG2RAD) let arcEndPointY = center.y + sliceSpaceOffset * sin(angleMiddle.DEG2RAD) path.addLine(to: CGPoint(x: arcEndPointX, y: arcEndPointY)) } else { path.addLine(to: center) } } path.closeSubpath() context.beginPath() context.addPath(path) context.fillPath(using: .evenOdd) let axElement = createAccessibleElement( withIndex: j, container: chart, dataSet: dataSet ) { element in element.accessibilityFrame = path.boundingBoxOfPath } accessibleChartElements.append(axElement) } // Post this notification to let VoiceOver account for the redrawn frames accessibilityPostLayoutChangedNotification() context.restoreGState() } private func createAccessibleElement( withIndex idx: Int, container: PieChartView, dataSet: PieChartDataSetProtocol, modifier: (NSUIAccessibilityElement) -> () ) -> NSUIAccessibilityElement { let element = NSUIAccessibilityElement(accessibilityContainer: container) guard let e = dataSet.entryForIndex(idx) else { return element } guard let data = container.data as? PieChartData else { return element } let formatter = dataSet.valueFormatter var elementValueText = formatter.stringForValue( e.y, entry: e, dataSetIndex: idx, viewPortHandler: viewPortHandler) if container.usePercentValuesEnabled { 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 = 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 modifier(element) return element } private func accessibilityPostLayoutChangedNotification(withElement element: Any? = nil) { UIAccessibility.post(notification: .layoutChanged, argument: element) } }
感觉是不是可以去混一个 PR?