SwiftUI 使用 Charts 库绘制带有圆角的饼图

上班我是 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 的几个扩展,也是因为可见性的原因需要复制出来。

然后我们来看原版的实现。原版通过 drawInnerArcPieChart#isDrawRoundedSlicesEnabled() 来控制是否需要绘制圆角,也就是第 236 行的 final boolean drawRoundedSlices = drawInnerArc && mChart.isDrawRoundedSlicesEnabled(); 这个常量。同时还有 roundedRadiusroundedCircleBox 来分别控制圆角的半径和位置。那么在我们自定义的 RoundedSlicesPieChartRendererdrawDataSet 函数中也照着定义一下。方便起见,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?

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇