使用 Swift Charts 框架创建饼图或圆环图

了解如何使用 Swift Charts 框架,为应用添加美观具有交互性的饼图或圆环图。

使用 Swift Charts 框架创建饼图或圆环图

Swift Charts 是 Apple 在 iOS 16 上推出的一个图表绘制框架,它提供了一套直观且强大的 API,帮助开发者快速构建各种类型的图表。相比于第三方开源库(如 Charts 框架),Swift Charts 是系统原生的,拥有更高的性能优化,同时与 SwiftUI 紧密集成,使图表的添加更加方便和高效。

Swift Charts 提供了多种常见的图表类型,包括:

  • 条形图(Bar Chart)
  • 折线图(Line Chart)
  • 饼图(Sector Chart)
  • 散点图(Point Chart)
  • 等等

要开始使用 Swift Charts,首先确保你的开发环境是 iOS 16 或更高版本,并且项目支持 SwiftUI

Swift Chart 基础用法

如果你还不了解 Swift Charts 的基本用法,或希望使用它创建条形图、折线图或散点图,建议你先阅读我之前的这篇教程:

使用 Swift Charts 框架为 iOS 应用添加图表
了解如何通过使用 Swift 的 Charts 框架,为你的应用添加动态、交互性强的图表,包括条形图、折线图等不同类型的图表。

创建饼图(SectorMark)

WWDC23 的这个 Session 对饼图做了比较详细的介绍,你可以作为补充阅读:

Explore pie charts and interactivity in Swift Charts - WWDC23 - Videos - Apple Developer
Swift Charts has come full circle: Get ready to bake up pie and donut charts in your app with the latest improvements to the framework…

SectorMark 的基本语法如下:

SectorMark(
    angle: .value("角度", 数据),
    innerRadius: .value("内半径", 半径值),
    outerRadius: .value("外半径", 半径值)
)
  • angle: 这是扇形的角度值,通常根据数据的大小来决定。它定义了每个扇区在圆环中占据的比例。
  • innerRadius: 定义扇形的内半径,决定了图表是否为饼图还是圆环图。如果内半径为0,则是饼图;否则就是圆环图。
  • outerRadius: 定义外半径,决定了扇形的外边界。

这些参数都是 PlottableValue 类型,可以通过 .value().plottedValue() 传入具体的值或数据映射。

下面的例子展示了如何使用 SectorMark 来生成一个展示不同水果销量占比的圆环图。

//
//  FruitSalesData.swift
//
//  Created by Ivens Liao on 2024/10/14.
//

import Charts
import SwiftUI

// 数据模型
struct FruitSalesData: Identifiable {
    let id = UUID()
    let fruit: String
    let sales: Double
}

// 示例数据
let fruitSales: [FruitSalesData] = [
    FruitSalesData(fruit: "苹果", sales: 35),
    FruitSalesData(fruit: "香蕉", sales: 25),
    FruitSalesData(fruit: "橙子", sales: 20),
    FruitSalesData(fruit: "葡萄", sales: 10),
    FruitSalesData(fruit: "草莓", sales: 10),
]

// Sector Chart 视图
struct SectorChartView: View {
    var body: some View {
        Chart(fruitSales) { item in
            SectorMark(
                angle: .value("销售额", item.sales),
                outerRadius: .ratio(1.0)  // 外半径设置为全图半径
            )
            .foregroundStyle(by: .value("水果", item.fruit))
        }
        .frame(height: 300)
        .chartLegend(position: .bottom)  // 将图例显示在图表下方
        .padding()
    }
}

// Preview,提供实时预览
struct SectorChartView_Previews: PreviewProvider {
    static var previews: some View {
        SectorChartView()
    }
}

我们通过 SectorMarkangle 属性将每种水果的销售数据映射为扇形的角度,而 outerRadius 设置为 1.0,表示占据整个图的外半径。

上面这个示例比较简单。在实际中,我们经常需要根据基础数据,合并计算某些类别的总和值,然后统计类别的占比。

示例代码:

//
//  FoodSalesData.swift
//  MONO
//
//  Created by Ivens Liao on 2024/10/14.
//

import Charts
import SwiftUI

// 数据模型
struct FoodSalesData: Identifiable {
    let id = UUID()
    let name: String
    let category: String
    let sales: Double
}

// 示例数据
let individualFoodSales: [FoodSalesData] = [
    FoodSalesData(name: "香蕉", category: "水果", sales: 25),
    FoodSalesData(name: "苹果", category: "水果", sales: 35),
    FoodSalesData(name: "橙子", category: "水果", sales: 20),
    FoodSalesData(name: "鸡肉", category: "肉类", sales: 40),
    FoodSalesData(name: "猪肉", category: "肉类", sales: 30),
    FoodSalesData(name: "牛肉", category: "肉类", sales: 20),
    FoodSalesData(name: "可乐", category: "饮料", sales: 50),
    FoodSalesData(name: "薯片", category: "零食", sales: 30),
]

// 按类别汇总销售额
func groupSalesByCategory(_ salesData: [FoodSalesData]) -> [FoodSalesData] {
    var categorySales: [String: Double] = [:]

    // 汇总每个类别的销售额
    for item in salesData {
        categorySales[item.category, default: 0] += item.sales
    }

    // 将汇总结果转换为 FoodSalesData 数组
    return categorySales.map { FoodSalesData(name: $0.key, category: $0.key, sales: $0.value) }
}

// Sector Chart 视图
struct SectorChartView: View {
    let groupedSales = groupSalesByCategory(individualFoodSales)  // 分类后的销售数据

    var body: some View {
        Chart(groupedSales) { item in
            SectorMark(
                angle: .value("销售额", item.sales),
                outerRadius: .ratio(1.0)
            )
            .foregroundStyle(by: .value("类别", item.category))
        }
        //.frame(height: 300)
        .chartLegend(position: .bottom)
        .padding()
    }
}

// Preview,提供实时预览
struct SectorChartView_Previews: PreviewProvider {
    static var previews: some View {
        SectorChartView()
    }
}

构建圆环图(innerRadius)

只需调整 innerRadius的指,即可得到圆环图效果:

Chart(groupedSales) { item in
    SectorMark(
        angle: .value("销售额", item.sales),
        innerRadius: .ratio(0.5),
        outerRadius: .ratio(1.0)
    )
    .foregroundStyle(by: .value("类别", item.category))
}
.chartLegend(position: .bottom)
.padding()
💡
推荐设置 innerRadius: .ratio(0.618), 黄金比例。

自定义属性

为每个区间添加标签(annotation)

使用 annotation为每个扇区中显示对应的标签值,方便用户理解具体的比例。

Chart(groupedSales) { item in
    SectorMark(
        angle: .value("销售额", item.sales),
        innerRadius: .ratio(0.5),
        outerRadius: .ratio(1.0)
    )
    .foregroundStyle(by: .value("类别", item.category))
    .annotation(position: .overlay) {
        VStack {
            Text("\(item.category)")
            Text("\(item.sales, specifier: "%.1f")%")
        }
        .font(.caption)
        .foregroundColor(.white)
    }

}
.chartLegend(position: .bottom)
.padding()

设置扇形的角度内缩量(angularInset)

通过指定 angularInset,你可以在每个扇形之间增加一个内缩的角度,使扇形之间产生分隔效果,从而避免各个部分直接相连。

Chart(groupedSales) { item in
    SectorMark(
        angle: .value("销售额", item.sales),
        innerRadius: .ratio(0.5),
        outerRadius: .ratio(1.0),
        angularInset: 2  // 设置 2 度的角度内缩量
    )
    .foregroundStyle(by: .value("类别", item.category))
    .annotation(position: .overlay) {
        VStack {
            Text("\(item.category)")
            Text("\(item.sales, specifier: "%.1f")%")
        }
        .font(.caption)
        .foregroundColor(.white)
    }

}
.chartLegend(position: .automatic)
.padding()

如果你将 angularInset 设置为 0,所有的扇形部分会直接相连。

创建个性化外观效果

组合使用 angularInset.cornerRadius(8.0),可以得到如下效果:

Chart(groupedSales) { item in
    SectorMark(
        angle: .value("销售额", item.sales),
        innerRadius: .ratio(0.5),
        outerRadius: .ratio(1.0),
        angularInset: 5.0  // 设置 2 度的角度内缩量
    )
    .cornerRadius(6.0)
    .foregroundStyle(by: .value("类别", item.category))
    .annotation(position: .overlay) {
        VStack {
            Text("\(item.category)")
            Text("\(item.sales, specifier: "%.1f")%")
        }

        .font(.caption)
        .foregroundColor(.white)
    }

}
.chartLegend(position: .automatic)
.padding()

或者创建扇形图效果:

突出显示某个扇形区域

通过条件判断设置 outerRadius 参数的值,可以实现类似这样的效果:

Chart(groupedSales) { item in
    SectorMark(
        angle: .value("销售额", item.sales),
        outerRadius: item.category
            == "水果" ? 160 : 150,
        angularInset: 4.0  // 设置 2 度的角度内缩量
    )
    .cornerRadius(8.0)
    .foregroundStyle(by: .value("类别", item.category))
    .annotation(position: .overlay) {
        VStack {
            Text("\(item.category)")
            Text("\(item.sales, specifier: "%.1f")%")
        }

        .font(.caption)
        .foregroundColor(.white)
    }

}
.chartLegend(position: .automatic)
.padding()

这种方式更加使用扇形图而不是圆环图。

添加背景标题说明(chartBackground)

Chart(groupedSales) { item in
    SectorMark(
        angle: .value("销售额", item.sales),
        innerRadius: .ratio(0.618),  // 可选,设置内半径,形成环形图
        outerRadius: .ratio(1.0),
        angularInset: 2.0  // 设置 2 度的角度内缩量
    )
    .cornerRadius(8.0)
    .foregroundStyle(by: .value("类别", item.category))
    .annotation(position: .overlay) {
        VStack {
            Text("\(item.category)")
            Text("\(item.sales, specifier: "%.1f")")
        }

        .font(.caption)
        .foregroundColor(.white)
    }

}
.chartBackground { proxy in
    VStack {
        Text("最佳销售类别")
            .font(.callout)
            .foregroundStyle(Color.secondary)
        Text("肉类")
            .font(.title2.bold())
            .foregroundStyle(Color.primary)
    }
}
.chartLegend(position: .automatic)
.padding()

为扇形区域添加点击交互(chartAngleSelection)

[TODO] - 如果我忘记更新,记得评论提醒我。

欢迎你订阅我的博客 Code with Ivens,获取更多关于 Swift 和 iOS 开发的实用教程与技巧。