WWDC22: 再谈 Swift Charts

发布
更新
字数 4041
阅读 21 分钟
阅读量 1781

Swift Charts 提供了丰富、精美的图表样式来实现数据可视化,从简单的到复杂的,从静态的到动态的,都是开箱即用的,你也可以借由 API 实现丰富的自定义效果。

Swift Charts 是基于 SwiftUI 的,因此使用同样的声明式语法,最基本的写法会是这样:

Chart(data, id: \.name) {
    BarMark(
        x: .value("Sales", $0.sales),
        y: .value("Name", $0.name)
    )
}

简短几行代码声明,提供一个数据集,并设置你关注的字段(x y 值),Swift Charts 会自动完成布局、绘制、自适应样式等。 截屏2022-06-29 09.17.48.png

Composition

Swift Charts 并没有提供很多预先定义好的样式,而是借助 Composition 概念来实现对于 Charts 的自定义,这样开发者可以通过简单的 API 实现高度个性化的 Charts 样式定义。

Mark and BarMark

作为展示数据的最基本的视觉元素,例如开篇例子中的 BarMark 即是一个柱状图标记。现在开始动手创建,首先像 SwiftUI 的其他视图一样,添加一个 Chart 视图

struct TopStyle: View {
    var body: some View {
        GroupBox("Most Sold Styles") {
            Text("Cachapa")
            Chart {
                // 在这里添加图表的 mark
            }
        }
    }
}

通过添加多个 BarMark 可以很容易的创建一个柱状图表,请参照 Swift Charts 初探

例 1

import SwiftUI
import Charts

struct TopStyleChart: View {
    // 结构化数据
    let data = [
        (name: "Cachapa", sales: 916),
        (name: "Injera", sales: 850),
        (name: "Crêpe", sales: 802),
        (name: "Jian Bing", sales: 753),
        (name: "Dosa", sales: 654),
        (name: "American", sales: 618)
    ]

    var body: some View {
        // 可以使用 `ForEach` 创建 mark,如果没有其他内容,也可直接把数据注入 `Chart`
        Chart(data, id: \.name) {
            BarMark(
                x: .value("Sales", $0.sales),
                y: .value("Name", $0.name)
            )
            // Mark 支持 SwiftUI 的多种修饰符,这里改变了前景色样式
            .foregroundStyle(.pink)
            // Chart 会自动创建可访问性元素,但也允许自定义可访问性标签或样式
            .accessibilityLabel($0.name)
            .accessibilityValue("\($0.sales) sold")
        }
    }
}

LineMark

使用 Swift Charts 非常方便,例如可以直接换一个 Mark,样式就会发生改变,例如我们要对比两个城市不同时间段销售变化情况:

  1. 首先需要两个城市的销售数据
  2. 为了呈现销售变化趋势,我们使用线状图,但是是两条

例 2

struct LocationsChart: View {
    var body: some View {
        Chart {
            // 2. 使用 `ForEach` 为每个 city 绘制一条折线
            ForEach(seriesData, id: \.city) { series in
                // 3. 为每天
                ForEach(series.data, id: \.weekday) {
                    // 绘制一个 `Mark`
                    LineMark(
                        x: .value("Weekday", $0.weekday, unit: .day),
                        y: .value("Sales", $0.sales)
                    )
                }
                // 4. Swift Charts 会自动配色,按指定值(city)区分
                .foregroundStyle(by: .value("City", series.city))
                .symbol(by: .value("City", series.city))
                // 5. 平滑曲线
                .interpolationMethod(.catmullRom)
            }
        }
    }
    
    // 1. 销售数据
    let seriesData = [
        (
            city: "Cupertino", data: [
                (weekday: date(year: 2022, month: 5, day: 2), sales: 54),
                (weekday: date(year: 2022, month: 5, day: 3), sales: 42),
                (weekday: date(year: 2022, month: 5, day: 4), sales: 88),
                (weekday: date(year: 2022, month: 5, day: 5), sales: 49),
                (weekday: date(year: 2022, month: 5, day: 6), sales: 42),
                (weekday: date(year: 2022, month: 5, day: 7), sales: 125),
                (weekday: date(year: 2022, month: 5, day: 8), sales: 67)
            ]
        ),
        (
            city: "San Francisco", data: [
                (weekday: date(year: 2022, month: 5, day: 2), sales: 81),
                (weekday: date(year: 2022, month: 5, day: 3), sales: 90),
                (weekday: date(year: 2022, month: 5, day: 4), sales: 52),
                (weekday: date(year: 2022, month: 5, day: 5), sales: 72),
                (weekday: date(year: 2022, month: 5, day: 6), sales: 84),
                (weekday: date(year: 2022, month: 5, day: 7), sales: 84),
                (weekday: date(year: 2022, month: 5, day: 8), sales: 137)
            ]
        )
    ]
}

func date(year: Int, month: Int, day: Int = 1) -> Date {
    Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}

以上,可以得到一个还不错的折线图,现在再把 LineMark 改回 BarMark ,然后删掉关联的修改器 .interpolationMethod() ,可以得到一个堆叠柱状图。为了增加对比效果,我们可以使用 .position(by:) 修改器为柱状图分组。

//...
.position(by: .value("City", series.city))
//...

注意,以上修改器作用在了ForEach 上并传入 (by: ) 参数来区分数据。

AreaMark

上例中都是同类 mark 的组合,不同类的 mark 也可以进行组合,例如我们在呈现历史销售数据的时候,也希望同时展示不同时间段销售情况的平均值、最大值和最小值,为此我们使用折线图显示平均值变化,并用区域图表现最大和最小值。

例 3

struct MonthlySalesChart: View {
    var body: some View {
        Chart {
            ForEach(data, id: \.month) {
                // 2. 先绘制最大和最小值
                AreaMark(
                    x: .value("Month", $0.month, unit: .month),
                    yStart: .value("Daily Min", $0.dailyMin),
                    yEnd: .value("Daily Max", $0.dailyMax)
                )
                // 3. 指定透明度,因为默认会给我们选择与折线一样的配色
                .opacity(0.3)
                // 4. 再绘制平均值
                LineMark(
                    x: .value("Month", $0.month, unit: .month),
                    y: .value("Daily Average", $0.dailyAverage)
                )
            }
        }
    }

    // 1. 在新数据中,增加了平均值、最大和最小值
    let data = [
        (month: date(year: 2021, month: 7), sales: 3952, dailyAverage: 127, dailyMin: 95, dailyMax: 194),
        (month: date(year: 2021, month: 8), sales: 4044, dailyAverage: 130, dailyMin: 96, dailyMax: 189),
        (month: date(year: 2021, month: 9), sales: 3930, dailyAverage: 131, dailyMin: 101, dailyMax: 184),
        (month: date(year: 2021, month: 10), sales: 4217, dailyAverage: 136, dailyMin: 96, dailyMax: 193),
        (month: date(year: 2021, month: 11), sales: 4006, dailyAverage: 134, dailyMin: 104, dailyMax: 202),
        (month: date(year: 2021, month: 12), sales: 3994, dailyAverage: 129, dailyMin: 96, dailyMax: 190),
        (month: date(year: 2022, month: 1), sales: 4202, dailyAverage: 136, dailyMin: 96, dailyMax: 203),
        (month: date(year: 2022, month: 2), sales: 3749, dailyAverage: 134, dailyMin: 98, dailyMax: 200),
        (month: date(year: 2022, month: 3), sales: 4329, dailyAverage: 140, dailyMin: 104, dailyMax: 218),
        (month: date(year: 2022, month: 4), sales: 4084, dailyAverage: 136, dailyMin: 93, dailyMax: 221),
        (month: date(year: 2022, month: 5), sales: 4559, dailyAverage: 147, dailyMin: 104, dailyMax: 242),
        (month: date(year: 2022, month: 6), sales: 1023, dailyAverage: 170, dailyMin: 120, dailyMax: 250)
    ]
}

func date(year: Int, month: Int, day: Int = 1) -> Date {
    Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}

RectangleMark

在上面的例子中,我们还可以用其他样式来表现,想象一下,我们要描述的是一个时间段的极值也就是两个端点和中间某个位置的平均值,我们可以用设置了起始和结束值的 BarMark 然后在某个位置用一条线标记出平均值,为此我们可以使用指定了高度的 RectangleMark 来实现。修改一下上面的代码:

Chart {
    ForEach(data, id: \.month) {
        // 2. 先绘制最大和最小值
        BarMark(
            x: .value("Month", $0.month, unit: .month),
            yStart: .value("Daily Min", $0.dailyMin),
            yEnd: .value("Daily Max", $0.dailyMax)
        )
        // 3. 指定透明度,因为默认会给我们选择与折线一样的配色
        .opacity(0.3)
        // 4. 再绘制平均值
        RectangleMark(
            x: .value("Month", $0.month, unit: .month),
            y: .value("Daily Average", $0.dailyAverage),
            // 5. 指定一个高度
            height: 2
        )
    }
}

可以看到 Swift Charts 自动对齐了 BarMarkRectangleMark 的宽度,也可以通过 width 参数设置宽度:

BarMark(
    x: .value("Month", $0.month, unit: .month),
    yStart: .value("Daily Min", $0.dailyMin),
    yEnd: .value("Daily Max", $0.dailyMax),
    // 宽度
    width: .ratio(0.6)
)

RuleMark

在上面的例子中,我们可以使用 RuleMark 创建一个参考线:

struct MonthlySalesChart: View {
    var body: some View {
        Chart {
            ForEach(data, id: \.month) {
                BarMark(
                    x: .value("Month", $0.month, unit: .month),
                    yStart: .value("Daily Min", $0.dailyMin),
                    yEnd: .value("Daily Max", $0.dailyMax),
                    width: .ratio(0.6)
                )
                .opacity(0.3)
                RectangleMark(
                    x: .value("Month", $0.month, unit: .month),
                    y: .value("Daily Average", $0.dailyAverage),
                    width: .ratio(0.6),
                    height: 2
                )
            }
            .foregroundStyle(.gray.opacity(0.5))

            // 2. 使用一个水平的 rule mark 绘制参考线,仅有一个 y 值,平均值
            RuleMark(
                y: .value("Average", averageValue)
            )
            // 3. 使用修改器设置样式
            .lineStyle(StrokeStyle(lineWidth: 3))
            // 4. 标注
            .annotation(position: .top, alignment: .leading) {
                Text("Average: \(averageValue, format: .number)")
                    .font(.headline)
                    .foregroundStyle(.blue)
            }
        }
    }

    let data = [
        (month: date(year: 2021, month: 7), sales: 3952, dailyAverage: 127, dailyMin: 95, dailyMax: 194),
        (month: date(year: 2021, month: 8), sales: 4044, dailyAverage: 130, dailyMin: 96, dailyMax: 189),
        (month: date(year: 2021, month: 9), sales: 3930, dailyAverage: 131, dailyMin: 101, dailyMax: 184),
        (month: date(year: 2021, month: 10), sales: 4217, dailyAverage: 136, dailyMin: 96, dailyMax: 193),
        (month: date(year: 2021, month: 11), sales: 4006, dailyAverage: 134, dailyMin: 104, dailyMax: 202),
        (month: date(year: 2021, month: 12), sales: 3994, dailyAverage: 129, dailyMin: 96, dailyMax: 190),
        (month: date(year: 2022, month: 1), sales: 4202, dailyAverage: 136, dailyMin: 96, dailyMax: 203),
        (month: date(year: 2022, month: 2), sales: 3749, dailyAverage: 134, dailyMin: 98, dailyMax: 200),
        (month: date(year: 2022, month: 3), sales: 4329, dailyAverage: 140, dailyMin: 104, dailyMax: 218),
        (month: date(year: 2022, month: 4), sales: 4084, dailyAverage: 136, dailyMin: 93, dailyMax: 221),
        (month: date(year: 2022, month: 5), sales: 4559, dailyAverage: 147, dailyMin: 104, dailyMax: 242),
        (month: date(year: 2022, month: 6), sales: 1023, dailyAverage: 170, dailyMin: 120, dailyMax: 250)
    ]

    // 1. 参考值
    let averageValue = 137
}

func date(year: Int, month: Int, day: Int = 1) -> Date {
    Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}

以上,可以看到通过使用不同的 mark 类型、参数和修改器,可以创建多种多样的图表, Swift Charts 还是很方便的。

Plotting data with mark properties

Swift Charts 把数据分为三个大类:Quantitative, nominal, temporal,对应着数值、分类(标量)和时间:

  • Quantitative,可定量的数据,如价格、销售量、温度等等,对应 Int、Float 或者 Double 类型的数值
  • Nominal,标量数据,处理分类或分组信息,如人名、城市区域或商品类型等,对应 String 或 String 类型等枚举
  • Temporal,时间点或间隔,如某一时间段或一个特定的交易时间点,对应 Swift Date 类型

Swift Charts 在使用时,会把数据抽象为这三种类型,并作为属性配置给 mark。例如 BarMark ,数据会被处理为 x, y 以及前景样式属性。在第一个例子中,x 属性代表销售情况也就是 quantitative 数据,y 属性代表 product name,即 nominal 数据,这是就会创建一个水平的柱状图,如果我们把 x y 值对调,就可以得到一个垂直柱状图,也就是说 BarMark 会根据 x y 的属性类型来绘制图表,而柱形的方向取决于哪一个坐标轴描述 quantitative 数据。

再看例 2,x 轴表示星期几,y 轴对应当日销售量,同时我们根据不同城市对数据分组着色。

Swift Charts 拥有 6 中 marks:bar, point, line, area, rule, rectangle,6 种属性:x, y, foreground style, line style, symbol, symbol size。基于三种类型的数据,通过结合不同的 mark 并设置属性,可以得到各式各样的图表。

当 Swift Charts 处理数据时,值会被缩放 (Scale),即提供的值会被通过一个类似于 scale function 的东西转换成合适的坐标值再进行绘制,这也是为什么图表可以自适应的原因。Swift Charts 提供了 scale 修改器,允许自定义。

struct LocationsChart: View {
    var body: some View {
        Chart {
            ForEach(seriesData, id: \.city) { series in
                ForEach(series.data, id: \.weekday) {
                    LineMark(
                        x: .value("Weekday", $0.weekday, unit: .day),
                        y: .value("Sales", $0.sales)
                    )
                }
                .foregroundStyle(by: .value("City", series.city))
                .symbol(by: .value("City", series.city))
                .interpolationMethod(.catmullRom)
            }
        }
        // 1. y scale,quantitative 对应区间值
        .chartYScale(domain: 0 ... 200)
        // 2. foreground style scale,nominal 对应一个字典(映射)
        .chartForegroundStyleScale([
            "San Francisco": .orange,
            "Cupertino": .pink
        ])
    }
  
    let seriesData = [
        (
            city: "Cupertino", data: [
                (weekday: date(year: 2022, month: 5, day: 2), sales: 54),
                (weekday: date(year: 2022, month: 5, day: 3), sales: 42),
                (weekday: date(year: 2022, month: 5, day: 4), sales: 88),
                (weekday: date(year: 2022, month: 5, day: 5), sales: 49),
                (weekday: date(year: 2022, month: 5, day: 6), sales: 42),
                (weekday: date(year: 2022, month: 5, day: 7), sales: 125),
                (weekday: date(year: 2022, month: 5, day: 8), sales: 67)
            ]
        ),
        (
            city: "San Francisco", data: [
                (weekday: date(year: 2022, month: 5, day: 2), sales: 81),
                (weekday: date(year: 2022, month: 5, day: 3), sales: 90),
                (weekday: date(year: 2022, month: 5, day: 4), sales: 52),
                (weekday: date(year: 2022, month: 5, day: 5), sales: 72),
                (weekday: date(year: 2022, month: 5, day: 6), sales: 84),
                (weekday: date(year: 2022, month: 5, day: 7), sales: 84),
                (weekday: date(year: 2022, month: 5, day: 8), sales: 137)
            ]
        )
    ]
}

func date(year: Int, month: Int, day: Int = 1) -> Date {
    Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}

其他图表定义

一个图表通常还包含:坐标轴、图例和绘图区域。坐标轴和图例用来描述图表,两个坐标轴间的区域就是绘图区域。

Axis

可以使用 chart 修改器 .chartXAxis.chartYAxis 分别定义 x 和 y 轴,修改器使用 AxisMarks 返回 AxisMark

struct MonthlySalesChart: View {
    var body: some View {
        Chart(data, id: \.month) {
            BarMark(
                x: .value("Month", $0.month, unit: .month),
                y: .value("Sales", $0.sales)
            )
        }
        // 1. 对 `Chart` 使用 `.chartXAxis` 修改器
        .chartXAxis {
            // 2. 内容为 `AxisMarks`,设置 `values` 属性,如果不设置,则为规则
            AxisMarks( values: .stride(by: .month) ) { value in
                // 3. 为每个 mark 填充内容
                AxisGridLine()
                AxisTick()
                // 4. 格式化标签内容,每个月用一个字符
                AxisValueLabel(
                    format: .dateTime.month(.narrow)
                )
            }
        }
    }

    let data = [
        (month: date(year: 2021, month: 7), sales: 3952),
        (month: date(year: 2021, month: 8), sales: 4044),
        (month: date(year: 2021, month: 9), sales: 3930),
        (month: date(year: 2021, month: 10), sales: 4217),
        (month: date(year: 2021, month: 11), sales: 4006),
        (month: date(year: 2021, month: 12), sales: 3994),
        (month: date(year: 2022, month: 1), sales: 4202),
        (month: date(year: 2022, month: 2), sales: 3749),
        (month: date(year: 2022, month: 3), sales: 4329),
        (month: date(year: 2022, month: 4), sales: 4084),
        (month: date(year: 2022, month: 5), sales: 4559),
        (month: date(year: 2022, month: 6), sales: 1023)
    ]

    let averageValue = 137
}

func date(year: Int, month: Int, day: Int = 1) -> Date {
    Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}

AxisMarks 构造器内,可以根据条件返回不同的内容:

// ...

AxisMarks(values: .stride(by: .month)) { value in
    // 在每季度的第一个月
    if value.as(Date.self)!.isFirstMonthOfQuarter {
        // 显示完整的内容,并做一些样式的调整以区别于其他的 axis marks
        AxisGridLine().foregroundStyle(.black)
        AxisTick().foregroundStyle(.black)
        AxisValueLabel(
            format: .dateTime.month(.narrow)
        )
    } else {
        // 其他时候仅显示网格线
        AxisGridLine()
    }
}

// ...
extension Date {
    var isFirstMonthOfQuarter: Bool {
        Calendar.current.component(.month, from: self) % 3 == 1
    }
}

此外还有其他一些设置。

标记的位置

Chart {
    // ...
}
.chartYAxis {
    // label 在外侧,`.leading` 对齐
    Axismarks(preset: .extended, position: .leading)
}

可见性

Chart {
    // ...
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)

可以查看文档学习更多关于坐标轴的自定义方法:https://developer.apple.com/documentation/charts/customizing-axes-in-swift-charts

Legend

legend 与 axis 类似,例如修改可见性

Chart {
    // ...
}
.chartLegend(.hidden)

Plot area

通过 .chartPlotStyle 修改器来定义绘图位置。

Chart {
    // ...
}
// 使用闭包对 `plotArea` 进行修改
.chartPlotStyle { plotArea in
    // 根据 category 计算绘图高度
    plotArea.frame(height: 60 * numberOfCategories) 
            // 背景色
            .background(.pink.opacity(0.2))
            // 描边
            .border(.pink, width: 1)
}

提示:chart 的修改器都是以 .chart 开头的。

ChartProxy

前面提到 Swift Charts 会通过 scale 的方式把数据映射到图表中的坐标或样式,Swift Charts 提供了 ChartProxy 来获取 x y 的 scale 值,可以使用 .position(for:) 获取数据到坐标轴的位置信息,相反,也可以使用 .value(atX:).value(atY:) 获取 scale 位置对应的数据值,基于这些信息,我们可以为 chart 添加一些自定义视图。

struct InteractiveBrushingChart: View {
    @State var range: (Date, Date)? = nil

    var body: some View {
        // 1. 首先创建 chart 视图,定义样式
        Chart {
            ForEach(data, id: \.day) {
                LineMark(
                    x: .value("Month", $0.day, unit: .day),
                    y: .value("Sales", $0.sales)
                )
                .interpolationMethod(.catmullRom)
                .symbol(Circle().strokeBorder(lineWidth: 2))
            }
            if let (start, end) = range {
                // 使用 `RectangleMark` 绘制选择区域
                RectangleMark(
                    xStart: .value("Selection Start", start),
                    xEnd: .value("Selection End", end)
                )
                .foregroundStyle(.gray.opacity(0.2))
            }
        }
        // 2. 使用 `.chartOverlay` 修改器,允许我们使用 chart proxy
        .chartOverlay { proxy in
            // 3. 使用 `GeometryReader` 来访问 overlay view 的 geometry 信息
            GeometryReader { nthGeoItem in
                // 4. 定义一个透明的 rectangle,用来响应手势
                Rectangle().fill(.clear).contentShape(Rectangle())
                    .gesture(DragGesture()
                        // 5. 当手势触发时
                        .onChanged { value in
                            // 在 chart 绘图区域内找到 x 坐标
                            let xStart = value.startLocation.x - nthGeoItem[proxy.plotAreaFrame].origin.x
                            let xCurrent = value.location.x - nthGeoItem[proxy.plotAreaFrame].origin.x
                            // 然后根据坐标,使用 proxy 转换为 date 值
                            if let dateStart: Date = proxy.value(atX: xStart),
                               let dateCurrent: Date = proxy.value(atX: xCurrent) {
                                // 6. 赋值给 State 变量 range,SwiftUI 会自动更新视图
                                range = (dateStart, dateCurrent)
                            }
                        }
                        .onEnded { _ in range = nil } // Clear the state on gesture end.
                    )
            }
        }
    }

    let data: [(day: Date, sales: Int)] = [
        (day: date(year: 2022, month: 5, day: 8), sales: 168),
        (day: date(year: 2022, month: 5, day: 9), sales: 117),
        (day: date(year: 2022, month: 5, day: 10), sales: 106),
        (day: date(year: 2022, month: 5, day: 11), sales: 119),
        (day: date(year: 2022, month: 5, day: 12), sales: 109),
        (day: date(year: 2022, month: 5, day: 13), sales: 104),
        (day: date(year: 2022, month: 5, day: 14), sales: 196),
        (day: date(year: 2022, month: 5, day: 15), sales: 172),
        (day: date(year: 2022, month: 5, day: 16), sales: 122),
        (day: date(year: 2022, month: 5, day: 17), sales: 115),
        (day: date(year: 2022, month: 5, day: 18), sales: 138),
        (day: date(year: 2022, month: 5, day: 19), sales: 110),
        (day: date(year: 2022, month: 5, day: 20), sales: 106),
        (day: date(year: 2022, month: 5, day: 21), sales: 187),
        (day: date(year: 2022, month: 5, day: 22), sales: 187),
        (day: date(year: 2022, month: 5, day: 23), sales: 119),
        (day: date(year: 2022, month: 5, day: 24), sales: 160),
        (day: date(year: 2022, month: 5, day: 25), sales: 144),
        (day: date(year: 2022, month: 5, day: 26), sales: 152),
        (day: date(year: 2022, month: 5, day: 27), sales: 148),
        (day: date(year: 2022, month: 5, day: 28), sales: 240),
        (day: date(year: 2022, month: 5, day: 29), sales: 242),
        (day: date(year: 2022, month: 5, day: 30), sales: 173),
        (day: date(year: 2022, month: 5, day: 31), sales: 143),
        (day: date(year: 2022, month: 6, day: 1), sales: 137),
        (day: date(year: 2022, month: 6, day: 2), sales: 123),
        (day: date(year: 2022, month: 6, day: 3), sales: 146),
        (day: date(year: 2022, month: 6, day: 4), sales: 214),
        (day: date(year: 2022, month: 6, day: 5), sales: 250),
        (day: date(year: 2022, month: 6, day: 6), sales: 146)
    ]
}

func date(year: Int, month: Int, day: Int = 1) -> Date {
    Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}

本文根据 WWDC22 Swift Charts: Raise the bar 总结:https://developer.apple.com/videos/play/wwdc2022/10137/