Swift Charts 悬停显示注释
学会在 Swift Charts 中创建交互式条形图,当用户悬停在条形标记上时显示图表注释
https://nilcoalescing.com/blog/ChartAnnotationsOnHover/
概述
本文介绍如何创建一个交互式条形图,当用户悬停在条形标记上时显示图表注释。该条形图展示三种不同产品全年按月分组的销售数据,注释会提供特定月份每种产品的确切销售数字。
注意:这个功能主要针对 macOS 和 iPadOS 平台,用户可以使用鼠标或触控板将指针放在视图上。但该解决方案也可以适配到手机上,只需将悬停交互替换为点击或拖拽。
数据模型准备
首先定义一个简单的 Product
数据模型:
swift
struct Product: Identifiable {
let id = UUID()
let name: String
let salesData: [Double]
var salesDataByMonth: [(month: String, sales: Double)] {
salesData.enumerated().map { (offset, sales) in
return (
month: Calendar.current.monthSymbols[offset],
sales: sales
)
}
}
}
struct Product: Identifiable {
let id = UUID()
let name: String
let salesData: [Double]
var salesDataByMonth: [(month: String, sales: Double)] {
salesData.enumerated().map { (offset, sales) in
return (
month: Calendar.current.monthSymbols[offset],
sales: sales
)
}
}
}
然后创建示例数据:
swift
struct ContentView: View {
let products = [
Product(
name: "Ice Cream",
salesData: [
1000, 1200, 900, 950, 800, 700,
730, 600, 620, 800, 950, 1100
]
),
Product(
name: "Coffee",
salesData: [
1800, 2000, 1900, 1950, 2500, 2800,
2730, 2600, 1620, 1800, 1950, 2100
]
),
Product(
name: "Muffins",
salesData: [
900, 700, 800, 950, 900, 1000,
1200, 1100, 1000, 800, 950, 900
]
)
]
var body: some View {
// ...
}
}
struct ContentView: View {
let products = [
Product(
name: "Ice Cream",
salesData: [
1000, 1200, 900, 950, 800, 700,
730, 600, 620, 800, 950, 1100
]
),
Product(
name: "Coffee",
salesData: [
1800, 2000, 1900, 1950, 2500, 2800,
2730, 2600, 1620, 1800, 1950, 2100
]
),
Product(
name: "Muffins",
salesData: [
900, 700, 800, 950, 900, 1000,
1200, 1100, 1000, 800, 950, 900
]
)
]
var body: some View {
// ...
}
}
创建基础条形图
使用 Swift Charts 创建基础条形图:
swift
struct ContentView: View {
let products = [ /* ... */ ]
var body: some View {
VStack(alignment: .leading) {
Text("Product sales in 2022")
.font(.headline)
Chart {
ForEach(products) { product in
ForEach(product.salesDataByMonth, id: \.month) { salesData in
BarMark(
x: .value("Month", salesData.0),
y: .value("Sales", salesData.sales)
)
.position(by: .value("Product", product.name))
.foregroundStyle(by: .value("Product", product.name))
}
}
}
}
.padding()
}
}
struct ContentView: View {
let products = [ /* ... */ ]
var body: some View {
VStack(alignment: .leading) {
Text("Product sales in 2022")
.font(.headline)
Chart {
ForEach(products) { product in
ForEach(product.salesDataByMonth, id: \.month) { salesData in
BarMark(
x: .value("Month", salesData.0),
y: .value("Sales", salesData.sales)
)
.position(by: .value("Product", product.name))
.foregroundStyle(by: .value("Product", product.name))
}
}
}
}
.padding()
}
}
关键点:
- 使用
position()
修饰符确保每个产品的标记并排显示而不是堆叠 - 使用
foregroundStyle()
API 通过颜色区分产品
添加悬停交互性
为了检测用户何时悬停在图表视图上并获取指针的确切位置,我们为图表添加一个覆盖层并附加 onContinuousHover()
修饰符:
swift
struct ContentView: View {
let products = [ /* ... */ ]
@State private var selectedMonth: String?
var body: some View {
VStack(alignment: .leading) {
// ...
Chart { /* ... */ }
.chartOverlay { (chartProxy: ChartProxy) in
Color.clear
.onContinuousHover { hoverPhase in
switch hoverPhase {
case .active(let hoverLocation):
selectedMonth = chartProxy.value(
atX: hoverLocation.x, as: String.self
)
case .ended:
selectedMonth = nil
}
}
}
}
}
}
struct ContentView: View {
let products = [ /* ... */ ]
@State private var selectedMonth: String?
var body: some View {
VStack(alignment: .leading) {
// ...
Chart { /* ... */ }
.chartOverlay { (chartProxy: ChartProxy) in
Color.clear
.onContinuousHover { hoverPhase in
switch hoverPhase {
case .active(let hoverLocation):
selectedMonth = chartProxy.value(
atX: hoverLocation.x, as: String.self
)
case .ended:
selectedMonth = nil
}
}
}
}
}
}
高亮选中月份
通过在属于选中月份的条形图上添加 RectangleMark
来高亮显示:
swift
Chart {
ForEach(products) { product in
ForEach(product.salesDataByMonth, id: \.month) { salesData in
// ... BarMark 代码 ...
}
}
if let selectedMonth {
RectangleMark(x: .value("Month", selectedMonth))
.foregroundStyle(.primary.opacity(0.2))
}
}
Chart {
ForEach(products) { product in
ForEach(product.salesDataByMonth, id: \.month) { salesData in
// ... BarMark 代码 ...
}
}
if let selectedMonth {
RectangleMark(x: .value("Month", selectedMonth))
.foregroundStyle(.primary.opacity(0.2))
}
}
添加图表注释
最后添加显示销售信息的注释:
swift
Chart {
ForEach(products) { product in
ForEach(product.salesDataByMonth, id: \.month) { salesData in
// ... BarMark 代码 ...
}
}
if
let selectedMonth,
let monthNumber = Calendar.current.monthSymbols.firstIndex(
of: selectedMonth
)
{
RectangleMark(x: .value("Month", selectedMonth))
.foregroundStyle(.primary.opacity(0.2))
.annotation(
position: monthNumber < 6 ? .trailing : .leading,
alignment: .center, spacing: 0
) {
SalesAnnotationView(
products: products,
month: selectedMonth,
monthNumber: monthNumber
)
}
.accessibilityHidden(true)
}
}
Chart {
ForEach(products) { product in
ForEach(product.salesDataByMonth, id: \.month) { salesData in
// ... BarMark 代码 ...
}
}
if
let selectedMonth,
let monthNumber = Calendar.current.monthSymbols.firstIndex(
of: selectedMonth
)
{
RectangleMark(x: .value("Month", selectedMonth))
.foregroundStyle(.primary.opacity(0.2))
.annotation(
position: monthNumber < 6 ? .trailing : .leading,
alignment: .center, spacing: 0
) {
SalesAnnotationView(
products: products,
month: selectedMonth,
monthNumber: monthNumber
)
}
.accessibilityHidden(true)
}
}
注释视图实现
创建自定义注释视图:
swift
struct SalesAnnotationView: View {
let products: [Product]
let month: String
let monthNumber: Int
var body: some View {
VStack(alignment: .leading) {
Text(month)
.font(.headline)
Divider()
ForEach(products) { product in
let name = product.name
let sales = product.salesData[monthNumber]
Text("\(name): \(sales, format: .currency(code: "CNY"))")
}
}
.padding()
.background(Color.annotationBackground)
}
}
extension Color {
static var annotationBackground: Color {
#if os(macOS)
return Color(nsColor: .controlBackgroundColor)
#else
return Color(uiColor: .secondarySystemBackground)
#endif
}
}
struct SalesAnnotationView: View {
let products: [Product]
let month: String
let monthNumber: Int
var body: some View {
VStack(alignment: .leading) {
Text(month)
.font(.headline)
Divider()
ForEach(products) { product in
let name = product.name
let sales = product.salesData[monthNumber]
Text("\(name): \(sales, format: .currency(code: "CNY"))")
}
}
.padding()
.background(Color.annotationBackground)
}
}
extension Color {
static var annotationBackground: Color {
#if os(macOS)
return Color(nsColor: .controlBackgroundColor)
#else
return Color(uiColor: .secondarySystemBackground)
#endif
}
}
核心要点
ChartProxy 的作用:
chartOverlay()
API 提供的ChartProxy
对于将视图坐标转换为图表值非常有用注释位置策略:
- 上半年(前6个月):注释显示在尾随边缘
- 下半年(后6个月):注释显示在前导边缘
- 这样确保注释不会超出图表的绘图区域
无障碍性:记得对
RectangleMark
和注释使用.accessibilityHidden(true)
,避免干扰 Voice Over跨平台兼容性:使用系统颜色确保在 macOS 和 iPadOS 上都有良好的视觉效果
参考链接
- 原文链接
- Swift Charts 官方文档
标签: SwiftUI, Swift Charts, 交互性, 数据可视化, macOS, iPadOS