上班我是 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?