Skip to content

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
    }
}

核心要点

  1. ChartProxy 的作用chartOverlay() API 提供的 ChartProxy 对于将视图坐标转换为图表值非常有用

  2. 注释位置策略

    • 上半年(前6个月):注释显示在尾随边缘
    • 下半年(后6个月):注释显示在前导边缘
    • 这样确保注释不会超出图表的绘图区域
  3. 无障碍性:记得对 RectangleMark 和注释使用 .accessibilityHidden(true),避免干扰 Voice Over

  4. 跨平台兼容性:使用系统颜色确保在 macOS 和 iPadOS 上都有良好的视觉效果

参考链接


标签: SwiftUI, Swift Charts, 交互性, 数据可视化, macOS, iPadOS