关于 charts-3d
面向 Swift Charts 开发者的 3D 可视化与常规图表指南,涵盖曲面、相机交互、样式、图表构建、选择和无障碍设计。
主要功能
- 01数学函数与数据驱动曲面
- 02交互式相机姿态控制
- 03曲面渐变与着色
- 042D 图表标记、坐标轴与选择
- 05图表无障碍设计模式
适用场景
- 01将数学函数绘制为 3D 曲面
- 02在同一图表中比较多个曲面
- 03构建支持选择交互的 2D 图表
- 04让 VoiceOver 访问图表数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rshankras/ claude-code-apple-skills --skill charts-3d安装后在 Claude Code 中输入/charts-3d确认已加载。
详细介绍
使用 Swift Charts 创建 3D 图表
使用 Chart3D 和 SurfacePlot 创建 3D 数据可视化。涵盖数学驱动的曲面、数据驱动的曲面、交互式相机姿态控制、曲面样式和相机投影模式。
此技能的触发场景
在以下情况下使用此技能:
- 想要创建 3D 图表或 3D 数据可视化
- 询问
Chart3D、SurfacePlot或 3D 曲面图 - 需要将数学函数可视化为 3D 曲面
- 想要在 3D 图表上实现交互式拖动旋转
- 询问 3D 图表的相机角度、姿态或投影
- 需要使用渐变或基于高度的颜色为 3D 曲面设置样式
- 想要在单个 3D 图表中渲染多个曲面
- 询问如何根据点数组创建数据驱动的 3D 图
- 需要 2D Swift Charts API 层:标记、坐标轴、系列样式、选择、SectorMark 饼图/圆环图或可滚动图表
决策树
What 3D chart feature do you need?
|
+-- Visualize a math function f(x, y) -> z
| +-- Use SurfacePlot(x:y:z:function:)
|
+-- Visualize data points as a surface
| +-- Use Chart3D(data) { point in SurfacePlot(...) }
|
+-- Interactive drag-to-rotate
| +-- Bind pose: .chart3DPose($pose) with @State var pose: Chart3DPose
|
+-- Fixed viewing angle (no interaction)
| +-- Read-only pose: .chart3DPose(Chart3DPose.front) or custom
|
+-- Style the surface color
| +-- Solid color -> .foregroundStyle(Color.blue)
| +-- Gradient -> .foregroundStyle(LinearGradient(...))
| +-- Height-based -> .foregroundStyle(.heightBased(gradient, yRange:))
| +-- Normal-based -> .foregroundStyle(.normalBased)
|
+-- Camera projection
| +-- Perspective (depth) -> .chart3DCameraProjection(.perspective)
| +-- Orthographic (flat) -> .chart3DCameraProjection(.orthographic)
| +-- System default -> .chart3DCameraProjection(.automatic)
|
+-- Multiple surfaces in one chart
+-- Place multiple SurfacePlot calls inside a single Chart3D { }
API 可用性
| API | 最低版本 | 导入 | 备注 |
|---|---|---|---|
Chart3D | iOS 26 / macOS 26 | Charts | 主要的 3D 图表容器 |
SurfacePlot | iOS 26 / macOS 26 | Charts | 3D 曲面标记 |
Chart3DPose | iOS 26 / macOS 26 | Charts | 视角控制 |
Chart3DCameraProjection | iOS 26 / macOS 26 | Charts | .automatic、.perspective、.orthographic |
Chart3DSurfaceStyle | iOS 26 / macOS 26 | Charts | .heightBased、.normalBased |
快速入门
数学驱动的曲面
根据函数 f(x, y) -> z 渲染曲面:
import SwiftUI
import Charts
struct WaveSurfaceView: View {
var body: some View {
Chart3D {
SurfacePlot(
x: "X",
y: "Height",
z: "Z",
function: { x, z in
sin(x) * cos(z)
}
)
.foregroundStyle(.blue)
}
}
}
数据驱动的曲面
根据数据点数组渲染曲面:
import SwiftUI
import Charts
struct DataPoint: Identifiable {
let id = UUID()
let x: Double
let y: Double
let z: Double
}
struct DataSurfaceView: View {
let points: [DataPoint]
var body: some View {
Chart3D(points) { point in
SurfacePlot(
x: .value("X", point.x),
y: .value("Height", point.y),
z: .value("Z", point.z)
)
}
}
}
交互式旋转
允许用户拖动以旋转图表:
import SwiftUI
import Charts
struct InteractiveChartView: View {
@State private var pose = Chart3DPose.default
var body: some View {
Chart3D {
SurfacePlot(
x: "X",
y: "Height",
z: "Z",
function: { x, z in
sin(x) * cos(z)
}
)
.foregroundStyle(.blue)
}
.chart3DPose($pose)
}
}
曲面样式模式
纯色
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in x * z })
.foregroundStyle(.blue)
线性渐变
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in x * z })
.foregroundStyle(
LinearGradient(
colors: [.blue, .green, .yellow],
startPoint: .bottom,
endPoint: .top
)
)
基于高度的曲面样式
根据高度值为曲面着色,在 y 轴范围内映射渐变:
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) })
.foregroundStyle(
Chart3DSurfaceStyle.heightBased(
Gradient(colors: [.blue, .cyan, .green, .yellow, .red]),
yRange: -1...1
)
)
基于法线的曲面样式
根据曲面法线着色,呈现受光照影响的外观:
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) })
.foregroundStyle(Chart3DSurfaceStyle.normalBased)
曲面粗糙度
控制曲面的光泽度(0 = 反光,1 = 哑光):
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) })
.foregroundStyle(.blue)
.roughness(0.3)
交互式姿态控制
预设姿态
Chart3DPose 提供常见视角的内置预设:
.chart3DPose(.default) // Standard 3/4 angle
.chart3DPose(.front) // Viewing from front
.chart3DPose(.back) // Viewing from back
.chart3DPose(.top) // Top-down view
.chart3DPose(.bottom) // Bottom-up view
.chart3DPose(.right) // Right side view
.chart3DPose(.left) // Left side view
自定义姿态
指定精确的方位角(水平旋转)和倾角(垂直倾斜):
.chart3DPose(
Chart3DPose(azimuth: .degrees(45), inclination: .degrees(30))
)
反模式
// ❌ Passing a literal where a binding is needed for interactivity
.chart3DPose(.default) // This is read-only; drag gestures will not work
// ✅ Use a @State binding for interactive rotation
@State private var pose = Chart3DPose.default
// ...
.chart3DPose($pose)
相机投影
控制 3D 深度的渲染方式:
Chart3D {
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) })
.foregroundStyle(.blue)
}
.chart3DCameraProjection(.perspective) // Objects farther away appear smaller
// .chart3DCameraProjection(.orthographic) // No perspective distortion
// .chart3DCameraProjection(.automatic) // System decides
多个曲面
在单个图表中渲染多个曲面以便比较:
import SwiftUI
import Charts
struct ComparisonChartView: View {
@State private var pose = Chart3DPose.default
var body: some View {
Chart3D {
SurfacePlot(
x: "X",
y: "Wave A",
z: "Z",
function: { x, z in sin(x) * cos(z) }
)
.foregroundStyle(.blue.opacity(0.8))
SurfacePlot(
x: "X",
y: "Wave B",
z: "Z",
function: { x, z in cos(x) * sin(z) }
)
.foregroundStyle(.red.opacity(0.8))
}
.chart3DPose($pose)
.chart3DCameraProjection(.perspective)
}
}
完整示例
一个功能齐全的 3D 图表,包含基于高度的着色、交互式旋转和透视投影:
import SwiftUI
import Charts
struct TerrainView: View {
@State private var pose = Chart3DPose(
azimuth: .degrees(30),
inclination: .degrees(25)
)
var body: some View {
VStack {
Text("Terrain Visualization")
.font(.headline)
Chart3D {
SurfacePlot(
x: "Longitude",
y: "Elevation",
z: "Latitude",
function: { x, z in
let distance = sqrt(x * x + z * z)
return sin(distance) / max(distance, 0.1)
}
)
.foregroundStyle(
Chart3DSurfaceStyle.heightBased(
Gradient(colors: [
.blue, .cyan, .green, .yellow, .orange, .red
]),
yRange: -0.5...1.0
)
)
.roughness(0.4)
}
.chart3DPose($pose)
.chart3DCameraProjection(.perspective)
}
.padding()
}
}
图表设计基础(WWDC22)
这些原则适用于你构建的每个图表,无论是 2D 还是 3D。三大支柱:聚焦、易用、无障碍。
- 根据目标选择标记:用条形图呈现模式、范围和单个值;用点图发现离群值;用折线图呈现变化率。
- 尽早使用真实且嘈杂的数据进行测试——占位用的正弦波会掩盖布局问题。明确设计边缘情况(例如,折线图中数据缺失时的间隙)。
- 约 4 条水平网格线是不错的基准;选择直观的间隔——计数轴用 20 的倍数,按天划分的轴用 7 的倍数。
- 标题应概括图表结论(“总销售额:1,234 个煎饼”),而不是使用通用标签(“销售额”);并为图表提供上下文。
- 触控目标应延伸至图表的完整高度——点按条形所在的任意位置都能选中该条,而不只是绘制出的像素。
- 同等支持触控、键盘、Voice Control、Switch Control 和 VoiceOver——每种交互都需要无需指针的操作方式。
- 颜色用于增强,而非单独传达信息——先用符号或形状区分系列,再用颜色。检查深色、浅色和增强对比度模式下的对比度,并平衡饱和度,避免某个系列在视觉上压过其他系列。
- 每种视觉编码都需要非视觉表示——为每个标记提供 VoiceOver 标签和 Audio Graph 描述符;Swift Charts 默认提供两者,因此不要用自定义绘制破坏它们。自定义绘制的图表需要手动实现这些功能——参见下文“Audio Graphs: AXChartDescriptor”。
Swift Charts 构建参考(2D)
上述设计基础适用于每个图表;以下是标准 2D 图表的 API 层(除非另有说明,适用于 iOS 16 及以上版本;选择、SectorMark 和滚动功能适用于 iOS 17 及以上版本)。
标记组合
Chart 由多个标记组合而成——BarMark、LineMark、PointMark、AreaMark、RuleMark、RectangleMark。.value("Label", v) 工厂参数有双重作用:它们会绑定数据,也会驱动自动生成的坐标轴和图例,因此请使用有意义的标签:
Chart(salesData) { sale in // Identifiable data — no explicit ForEach needed
BarMark(
x: .value("Day", sale.day, unit: .day), // unit: .day buckets temporal values per day
y: .value("Sales", sale.count)
)
}
- 交换 x/y 值对即可转换图表方向——水平条形图无需其他改动。
unit:(.day、.month、.hour)控制时间分桶;如果省略,每个时间戳都会占据一个独立位置。
系列:样式与符号,不要只用颜色
.foregroundStyle(by:) 会将标记划分为不同系列;配合 .symbol(by:) 使用,即使不依赖颜色,系列也能保持易于区分(WWDC22):
Chart(data) { point in
LineMark(x: .value("Day", point.day), y: .value("Sales", point.sales))
.foregroundStyle(by: .value("City", point.city))
.symbol(by: .value("City", point.city))
}
- 使用
.chartForegroundStyleScale(["Cupertino": .indigo, "San Francisco": .teal])重新映射系列颜色。 .position(by: .value("City", point.city))可将堆叠条形图转换为分组条形图。
固定比例尺以保持稳定
固定比例尺,避免筛选数据集后整个图表发生跳动:
.chartYScale(domain: 0...maxExpectedSales)
.chartXScale(domain: startDate...endDate)
将统计数据组合到图表中
标记可以自由组合,因此汇总统计只是更多标记:
Chart {
ForEach(data) { point in
AreaMark( // min–max band
x: .value("Day", point.day),
yStart: .value("Min", point.min),
yEnd: .value("Max", point.max)
)
.opacity(0.3)
LineMark(x: .value("Day", point.day), y: .value("Average", point.average))
}
RuleMark(y: .value("Overall", overallAverage)) // overall average line
.foregroundStyle(.secondary)
.annotation(position: .top, alignment: .leading) {
Text("Avg: \(overallAverage, format: .number.precision(.fractionLength(0)))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
坐标轴使用构建器
.chartXAxis {
AxisMarks(values: .stride(by: .month)) { value in
AxisGridLine()
AxisTick()
AxisValueLabel(format: .dateTime.month(.narrow))
}
}
.chartYAxis {
AxisMarks(position: .leading) // move the value axis to the leading edge
}
- 条件样式应放在构建器内部——例如,在闭包中检查
value.as(Date.self),只将每个季度的第一个月份设为粗体。 .chartXAxis(.hidden)会彻底移除坐标轴;.chartPlotStyle { $0.frame(height: 200).background(.gray.opacity(0.05)).border(.quaternary) }可设置绘图区本身的尺寸和样式。
交互:优先使用选择功能
优先使用内置的选择绑定,而不是手动实现覆盖层手势(iOS 17):
@State private var selectedDay: Date?
Chart(data) { ... }
.chartXSelection(value: $selectedDay)
将选择结果渲染为标记——使用 zIndex(-1) 的 RuleMark,使其绘制在数据后方,并添加保持在绘图区内的注释:
if let selectedDay {
RuleMark(x: .value("Selected", selectedDay, unit: .day))
.foregroundStyle(.gray.opacity(0.3))
.zIndex(-1)
.annotation(
position: .top, spacing: 0,
overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
) {
SelectionDetailCard(day: selectedDay)
}
}
如需完全自定义命中测试,可在 .chartOverlay 中使用 GeometryReader 调用 ChartProxy:proxy.value(atX:) 会将手势位置转换为数据值,proxy.position(forX:) 则会反向转换。
SectorMark:饼图和圆环图(iOS 17 及以上版本)
Chart(data) { item in
SectorMark(
angle: .value("Sales", item.sales),
innerRadius: .ratio(0.62), // donut hole
angularInset: 1.5 // 1.5 per side = 3pt gaps between sectors
)
.cornerRadius(4)
.foregroundStyle(by: .value("Name", item.name))
}
.chartBackground { proxy in
GeometryReader { geo in // headline metric in the donut hole
if let anchor = proxy.plotFrame {
let frame = geo[anchor]
Text("Best: \(topSellerName)")
.position(x: frame.midX, y: frame.midY)
}
}
}
大型数据集:滚动查看窗口
不要试图在一个屏幕中塞入一整年的数据——显示一个窗口并允许滚动(WWDC23):
Chart(yearOfData) { ... }
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 3600 * 24 * 30) // 30-day window
.chartScrollPosition(x: $scrollDate) // read/write the scroll offset
.chartScrollTargetBehavior(
.valueAligned(
matching: DateComponents(hour: 0), // land on day boundaries
majorAlignment: .matching(DateComponents(day: 1)) // snap paging to month starts
)
)
无障碍功能:每个标记
VoiceOver 自动生成的描述会朗读原始值;为每个标记添加标签可覆盖这些描述:
BarMark(x: .value("Day", sale.day, unit: .day), y: .value("Sales", sale.count))
.accessibilityLabel(sale.day.formatted(date: .abbreviated, time: .omitted))
.accessibilityValue("\(sale.count) pancakes sold")
对于数百或数千个点,应将其归入合理的区间,并为每个区间而非每个点提供一个无障碍元素(WWDC21 10122)。
音频图表:AXChartDescriptor(WWDC21 10122)
音频图表可让 VoiceOver 将数据系列作为连续音调播放——音高 = Y 值,时间 = X 位置——并提供探索器视图(转子 →“音频图表”→“图表详细信息”),用于播放数据转化为声音的内容、拖动浏览(双击并按住;暂停时会朗读当前位置的值),以及显示自动计算的摘要统计数据。Swift Charts 会生成默认描述符;自定义绘制的图表需遵循 AXChart(UIKit),或在 SwiftUI(iOS 15+)中使用 .accessibilityChartDescriptor(_:) 修饰符和 AXChartDescriptorRepresentable:
var accessibilityChartDescriptor: AXChartDescriptor? {
let xAxis = AXNumericDataAxisDescriptor(
title: "Cups of coffee",
range: 0...10,
gridlinePositions: [], // gridlines render as haptics during playback
valueDescriptionProvider: { "\(Int($0)) cups" }) // "5 cups", never a bare "5"
let yAxis = AXNumericDataAxisDescriptor(
title: "Lines of code",
range: 0...100,
gridlinePositions: [],
valueDescriptionProvider: { "\(Int($0)) lines of code" })
let series = AXDataSeriesDescriptor(
name: "Productivity",
isContinuous: true, // line → one continuous tone; false for bars/points → discrete tones
dataPoints: model.points.map { AXDataPoint(x: $0.x, y: $0.y) })
return AXChartDescriptor(
title: model.title,
summary: model.summary, // 1–2 sentence alt text; spoken in the explorer view
xAxis: xAxis,
yAxis: yAxis,
additionalAxes: [],
series: [series])
}
- 分类轴使用
AXCategoricalDataAxisDescriptor;在生产环境中,应本地化并正确处理valueDescriptionProvider输出的复数形式。 - 所有图表的视觉最低标准(WWDC21 10122):前景与背景对比度 ≥ 4.5:1(使用 Accessibility Inspector 的 Color Contrast Calculator 验证);切勿搭配红色和绿色(最常见的色盲类型),并避免搭配蓝色和黄色(第二常见的色盲类型);除颜色外还要使用符号,确保即使完全无法辨别颜色也能区分各个系列。
常见错误
| # | 错误 | 修正方法 |
|---|---|---|
| 1 | 忘记 import Charts | 必须同时导入 SwiftUI 和 Charts |
| 2 | 使用 .chart3DPose(.default) 并期望可通过拖动旋转 | 使用 @State 绑定:.chart3DPose($pose),以启用交互式旋转 |
| 3 | 设置的 yRange 未涵盖函数的实际输出 | 使 .heightBased() 中的 yRange 与函数实际输出的最小值和最大值相符 |
| 4 | 未应用 .foregroundStyle() 就使用 .roughness() | Roughness 会修改现有表面外观;请先设置前景样式 |
| 5 | 在演示或展示场景中使用正交投影 | 视觉效果优先时,建议使用 .perspective;需要精确读取数据时,使用 .orthographic |
检查清单
导入和设置
- 已同时导入
import SwiftUI和import Charts - 部署目标为 iOS 26 / macOS 26 或更高版本
- 所有
SurfacePlot内容都由Chart3D包裹
表面配置
- 坐标轴标签(
x:、y:、z:)清晰且有意义 - 每个
SurfacePlot都应用了foregroundStyle,以便清楚地区分 -
.heightBased()中的yRange与函数的实际输出范围相符 -
roughness值适合使用场景(0 = 反光,1 = 哑光)
交互
- 如果需要拖动旋转,Pose 应为
@State绑定 - 如果需要锁定旋转,Pose 应为常量(非绑定)
- 初始 Pose 角度能为数据提供良好的默认视图
摄像机
- 摄像机投影设置合适(视觉效果优先时使用
.perspective,精度优先时使用.orthographic) - 如果使用
.automatic,已确认系统的选择效果可以接受
多个表面
- 每个表面都有不同的颜色或不透明度,以便区分
- 各表面的坐标轴标签彼此不同,或在适当时共用
无障碍功能
- 对比度 ≥ 4.5:1;不搭配红色与绿色或蓝色与黄色的系列;使用符号辅助颜色(WWDC21 10122)
- 自定义绘制的图表提供
AXChartDescriptor(音频图表)——或保留 Swift Charts 的默认设置 - 大型数据集按区间而非按点提供无障碍元素