返回

SwiftUI Charts: 修复 iOS 16 BarMark Annotation 遮挡问题

IOS

搞定 SwiftUI Charts:让 BarMark Annotation 不再被遮挡

用 SwiftUI Charts 画条形图 (Bar Chart) 时,你可能遇到过一个头疼的问题:给 BarMark 添加的 annotation(比如一个提示框 Tooltip),会被后面绘制的 BarMark 给盖住,就像下图这样:

Annotation被遮挡的示例图

代码看起来可能是这样的,我们在 BarMark 上用 .annotation 修饰符添加了一个 TooltipView:

import Foundation
import SwiftUI
import Charts

struct ConsumptionData: Identifiable { // 假设的数据结构
    let id = UUID()
    var text: String // X轴标签 (例如日期或时间)
    var consumption: Double? // Y轴值 (例如消耗量)
}

class ConsumptionDataModel: ObservableObject { // 假设的数据模型
    @Published var data: [ConsumptionData] = []
    @Published var average: Double? = nil
    // ... 可能还有其他属性和方法 ...
}

// 假设的 TooltipView
struct TooltipView: View {
    let value: String
    var body: some View {
        Text(value)
            .font(.caption)
            .padding(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
            .background(Color.black.opacity(0.7))
            .foregroundColor(.white)
            .cornerRadius(4)
    }
}

// 假设的字体和颜色定义 (仅为编译通过)
enum Fonts {
    enum Inter {
        static let regular = Font.system(size: 10) // 简化处理
    }
}
extension Font {
    func of(size: CGFloat) -> Font { return self } // 简化处理
}
extension Color {
    static let primarySwiftUI = Color.blue // 简化处理
    static let groheLink = Color.cyan // 简化处理
    static let greyE1E3E7 = Color.gray.opacity(0.5) // 简化处理
}

struct WaterConsumptionChart: View {
    
    @ObservedObject var model: ConsumptionDataModel // 修改为 model
    @State private var selectedItem: ConsumptionData?
    // @State private var showTooltip: Bool = false // Tooltip显示逻辑已整合
    
    // 为了编译通过,使用 model 替换 viewModel
    // @ObservedObject var viewModel: ConsumptionDataModel -> @ObservedObject var model: ConsumptionDataModel
    // 并调整 viewModel.period -> model.period (假设 model 里有 period)
    // class ConsumptionDataModel 里需要添加 @Published var period: Period? = Period()
    // struct Period { var period: ChartPeriod = .day } // 假设的 Period 结构
    // enum ChartPeriod { case day, week, month, year } // 假设的 ChartPeriod 枚举

    private var gradientColors = [Color.primarySwiftUI.opacity(0.8),
                                  Color.groheLink.opacity(0.8)]

    var body: some View {
        VStack {
            Chart(model.data) { item in
                // --- 问题所在的代码段 ---
                BarMark(
                    x: .value("X 轴", item.text), // 更通用的标签
                    y: .value("消耗量", item.consumption ?? 0), // 更通用的标签
                    width: .automatic
                )
                .foregroundStyle(LinearGradient(
                    gradient: Gradient(colors: gradientColors),
                    startPoint: .top,
                    endPoint: .bottom
                ))
                .cornerRadius(4)
                .annotation(position: .top, alignment: .center) { // Annotation 直接附加在 BarMark 上
                    // 根据选中状态决定是否显示 Tooltip
                    let show = (selectedItem?.id == item.id) && ((item.consumption ?? 0) > 0)
                    TooltipView(value: "\(item.text)\(item.consumption ?? 0, specifier: "%.1f")l") // 使用 item 数据
                        .opacity(show ? 1 : 0)
                        .offset(y: -2) // 轻微向上偏移
                        // .zIndex(1) // <--- iOS 17+ 才可用,无法解决 iOS 16 的问题
                }
                // --- 问题所在的代码段结束 ---
                
                // 平均值标线
                if let average = model.average, average > 0 {
                    RuleMark(y: .value("平均值", average))
                        .foregroundStyle(.primarySwiftUI)
                        .lineStyle(.init(lineWidth: 1))
                } else {
                    // 确保即使平均值为0或nil也有一个占位的不可见RuleMark,避免布局跳动
                    RuleMark(y: .value("平均值", 0.0))
                        .foregroundStyle(.clear) // 设为透明
                        .lineStyle(.init(lineWidth: 0)) // 线宽为0
                }
            }
            .padding([.leading, .trailing], 16)
            .chartXAxis {
                // X轴配置 (保持不变, 略作简化以便理解)
                AxisMarks(values: .automatic(minimumStride: 1)) { value in
                   if let label = value.as(String.self) {
                       let uiFont = Fonts.Inter.regular.of(size: 10)
                       AxisValueLabel(label)
                           .foregroundStyle(.primarySwiftUI)
                           .font(Font(uiFont))
                           .offset(y: 10)
                       AxisGridLine()
                           .foregroundStyle(.greyE1E3E7)
                   }
                }
            }
            .chartYAxis {
                // Y轴配置 (保持不变, 略作简化以便理解)
                AxisMarks(position: .leading, values: .automatic(desiredCount: 5)) { value in
                    if let intValue = value.as(Double.self) { // 改为 Double 以处理可能的非整数值
                         let uiFont = Fonts.Inter.regular.of(size: 10)
                        AxisValueLabel("\(intValue, specifier: "%.0f")l") // 显示为整数升数
                            .foregroundStyle(.primarySwiftUI)
                            .font(Font(uiFont))
                        AxisGridLine()
                            .foregroundStyle(.greyE1E3E7)
                    }
                }
            }
            // .animation(.smooth, value: model.data) // 数据变化时动画效果
            // 点击交互,用于选择 BarMark 并更新 selectedItem
            .chartOverlay { chartProxy in // 使用 chartProxy 而不是 pr
                GeometryReader { geometryProxy in // 使用 geometryProxy 而不是 geoProxy
                    Rectangle()
                        .fill(.clear)
                        .contentShape(Rectangle()) // 确保透明区域也能响应点击
                        // .padding([.leading, .trailing], 16) // 注意:这里的 padding 可能导致边缘 Bar 点击区域不准,通常不需要再加 padding
                        .onTapGesture { location in // 直接获取点击位置
                            // 将点击位置转换为图表绘图区域的坐标
                            let origin = geometryProxy[chartProxy.plotAreaFrame].origin
                            let chartLocation = CGPoint(
                                x: location.x - origin.x,
                                y: location.y - origin.y
                            )
                            
                            // 查找点击位置对应的 X 轴值
                            if let selectedX = chartProxy.value(atX: chartLocation.x, as: String.self),
                               let dataItem = model.data.first(where: { $0.text == selectedX }) {
                                if (dataItem.consumption ?? 0) > 0 {
                                    // 可以加个触觉反馈
                                    // UIImpactFeedbackGenerator(style: .light).impactOccurred()
                                }
                                self.selectedItem = dataItem // 更新选中的数据项
                            } else {
                                self.selectedItem = nil // 点击空白区域,取消选中
                            }
                        }
                }
            }
        }
    }
}

用户尝试了 .zIndex,但这玩意儿要 iOS 17 才支持,对于需要兼容 iOS 16 的项目来说,行不通。那该怎么办呢?

问题在哪?

简单说,SwiftUI Charts 里的元素,是按你代码里写的顺序一层层画上去的。在这个例子里:

  1. Chart 遍历 model.data
  2. 对第一个数据项,绘制 BarMark
  3. 紧接着,为这个 BarMark 绘制 annotation (如果此时它被选中)。
  4. 对第二个数据项,绘制 BarMark。这个 BarMark 自然就画在了第一个 BarMarkannotation 之上(如果它们在位置上重叠的话)。
  5. 以此类推...

结果就是,前面的 annotation 被后面的 BarMark 无情地盖住了。.zIndex 本来是解决这种层叠问题的利器,它允许你手动指定视图的绘制层级,数值越大越靠上。可惜 iOS 16 用不了。

怎么破?(iOS 16 兼容方案)

既然不能直接控制单个 annotation 的层级,我们就得换个思路,让那个需要显示的 annotation所有 BarMark 都画完之后 再绘制。

这里提供两种可行的方案:

方案一:利用 chartOverlay 统一绘制选中的 Annotation

这个方法比较推荐,因为它利用了我们已经用来做点击交互的 chartOverlaychartOverlay 顾名思义,它绘制的内容会覆盖在整个图表(包括所有的 Mark)之上。

原理:

不在每个 BarMark 上单独附加 annotation,而是在 chartOverlay 中,根据当前选中的 selectedItem,计算出对应 BarMark 的位置,然后只绘制一个 TooltipView

步骤:

  1. 移除 BarMark 上的 .annotation 修饰符。
  2. chartOverlay 中获取 ChartProxy 这个 proxy 能帮助我们把数据值映射到图表上的具体位置。
  3. 判断 selectedItem 是否存在。 如果有选中的项,才需要显示 TooltipView
  4. 计算 TooltipView 的位置。 这是关键一步。我们需要根据 selectedItem 的 x 值 ( item.text) 和 y 值 (item.consumption),使用 chartProxy 提供的方法(比如 position(forX:y:))来找到它在绘图区域 (plotArea) 内的准确坐标。通常,我们会定位到对应 BarMark 的顶部中心点。
  5. 在计算出的位置放置 TooltipView

代码示例:

struct WaterConsumptionChart: View {
    
    @ObservedObject var model: ConsumptionDataModel
    @State private var selectedItem: ConsumptionData?
    
    private var gradientColors = [Color.primarySwiftUI.opacity(0.8),
                                  Color.groheLink.opacity(0.8)]

    var body: some View {
        VStack {
            Chart { // 简化了 Chart 初始化,直接在闭包内提供数据
                ForEach(model.data) { item in // 显式使用 ForEach
                    BarMark(
                        x: .value("X 轴", item.text),
                        y: .value("消耗量", item.consumption ?? 0),
                        width: .automatic
                    )
                    .foregroundStyle(LinearGradient(
                        gradient: Gradient(colors: gradientColors),
                        startPoint: .top,
                        endPoint: .bottom
                    ))
                    .cornerRadius(4)
                    // !!! 移除这里的 .annotation !!!
                    
                    // 平均值标线 (放在 ForEach 里每个数据点都画一次平均线好像不太对,
                    // 应该放在 ForEach 外面,只需要画一次)
                    // if let average = model.average, average > 0 { ... } 
                }
                
                // --- 平均值标线应该放在这里 ---
                if let average = model.average, average > 0 {
                    RuleMark(y: .value("平均值", average))
                        .foregroundStyle(.primarySwiftUI)
                        .lineStyle(.init(lineWidth: 1))
                        .annotation(position: .trailing, alignment: .leading) {
                            // 可以给平均线也加个标签
                            Text("Avg \(average, specifier: "%.1f")l")
                                .font(.caption2)
                                .foregroundColor(.primarySwiftUI)
                        }
                } 
                // 不需要 else 分支画透明线了,没有平均值就不显示 RuleMark
                // --- 平均值标线结束 ---

            } // Chart 闭包结束
            .padding([.leading, .trailing], 16)
            .chartXAxis { /* ... X轴配置不变 ... */ }
            .chartYAxis { /* ... Y轴配置不变 ... */ }
            // 点击交互逻辑不变
            .chartOverlay { chartProxy in
                GeometryReader { geometryProxy in
                    Rectangle()
                        .fill(.clear)
                        .contentShape(Rectangle())
                        .onTapGesture { location in
                            let origin = geometryProxy[chartProxy.plotAreaFrame].origin
                            let chartLocation = CGPoint(x: location.x - origin.x, y: location.y - origin.y)
                            
                            if let selectedX = chartProxy.value(atX: chartLocation.x, as: String.self),
                               let dataItem = model.data.first(where: { $0.text == selectedX }) {
                                self.selectedItem = dataItem
                            } else {
                                self.selectedItem = nil
                            }
                        }
                    
                    // --- 新增:在 Overlay 中绘制选中的 Annotation ---
                    if let selectedItem = selectedItem, (selectedItem.consumption ?? 0) > 0 {
                        // 1. 获取绘图区域的 Frame
                        let plotAreaFrame = chartProxy.plotAreaFrame
                        
                        // 2. 计算选中项在绘图区域的位置
                        // 注意: position(forX:y:) 需要 Plottable 值,我们需要正确转换
                        // x 通常是 PlottableValue.value("X 轴", selectedItem.text)
                        // y 通常是 PlottableValue.value("消耗量", selectedItem.consumption ?? 0)
                        // 这个计算可能需要一些尝试和调整,确保类型匹配
                        let xPosition = chartProxy.position(forX: selectedItem.text) ?? 0
                        let yPosition = chartProxy.position(forY: selectedItem.consumption ?? 0) ?? 0
                        
                        // 3. 计算 Tooltip 的目标中心点 (通常在 Bar 顶部中心)
                        // 我们需要考虑 plotAreaFrame 的原点
                        let annotationX = plotAreaFrame.origin.x + xPosition
                        let annotationY = plotAreaFrame.origin.y + yPosition
                        
                        // 4. 放置 TooltipView
                        TooltipView(value: "\(selectedItem.text) • \(selectedItem.consumption ?? 0, specifier: "%.1f")l")
                            .position(x: annotationX, y: annotationY - 15) // -15 是向上偏移量,可调整
                            // 可以加个过渡动画让显示/隐藏更柔和
                            .transition(.opacity.animation(.easeInOut(duration: 0.2)))
                            // 确保 Tooltip 不会被 GeometryReader 裁剪掉 (如果计算出的位置在边缘)
                            // 这取决于具体布局,有时可能需要 .offset() 或调整 GeometryReader 范围
                    }
                    // --- Annotation 绘制结束 ---
                }
            }
        }
        .onChange(of: model.data) { _ , _ in
             // 数据变化时,重置选中项,避免选中了已经不存在的数据
             selectedItem = nil
        }
    }
}

// 注意: 上述代码中 position(forX:y:) 的使用是示意性的。
// 你可能需要更精确地处理 PlottableValue 的创建,或者使用其他 ChartProxy 方法
// (例如 `frame(forX:yStart:yEnd:)` 获取整个 bar 的 frame)来定位 annotation。
// 例如,更稳妥的方式可能是找到对应 BarMark 的 frame:
// if let frame = chartProxy.frame(forX: selectedItem.text, y: selectedItem.consumption ?? 0) {
//     let midX = frame.midX
//     let topY = frame.minY
//     TooltipView(...)
//        .position(x: midX, y: topY - 15) // 放置在 Bar 顶部中点上方
// }
// 但 `frame(forX:y:)` 需要精确匹配用于 BarMark 的 x 和 y 的 .value 参数,且只在某些 Mark 类型下有效。
// 需要根据实际情况选择最合适的定位方法。

优点:

  • 逻辑清晰:交互和展示集中在 chartOverlay
  • 性能较好:只绘制一个 TooltipView
  • 利用现有结构:不需要大的代码结构调整。

注意事项:

  • 位置计算是关键chartProxy 提供的坐标是相对于 plotAreaFrame 的,你需要正确地将数据值映射到屏幕坐标。可能需要反复调试才能精确对齐。position(forX:)position(forY:) 返回的是相对于绘图区域原点的位置。
  • Tooltip 边界问题 :如果 TooltipView 太宽或 BarMark 靠近图表边缘,TooltipView 可能会被裁切掉一部分。你可能需要根据计算出的位置动态调整 TooltipView 的对齐方式 (alignment) 或者使用 .offset() 微调,甚至判断是否靠近边缘来改变弹出方向。

方案二:使用独立的 Mark 类型专门渲染 Annotation

这个方法是,在 Chart 的内容构建器里,等所有 BarMark 都添加完之后,再添加一个专门用来显示 annotationMark

原理:

利用 Chart 按顺序绘制的特性。先用 ForEach 绘制所有的 BarMark(不带 annotation)。然后,再次检查 selectedItem,如果存在,就添加一个(通常是不可见的)Mark(比如 PointMarkRuleMark)在选中数据点的位置,并只给这个 Mark 附加 .annotation

步骤:

  1. 移除 BarMark 上的 .annotation 修饰符。
  2. Chart 内容构建器中,ForEach 绘制完 BarMark 之后 ,添加一个 if let selectedItem = selectedItem 判断。
  3. if 语句块内,添加一个新的 Mark 类型 (例如 PointMark),它的 x 和 y 值设为 selectedItem 的值。这个 Mark 本身可以设置成透明或者尺寸为 0,我们只关心它的位置。
  4. 给这个新的 Mark 添加 .annotation 修饰符 ,并显示 TooltipView

代码示例:

struct WaterConsumptionChart: View {
    
    @ObservedObject var model: ConsumptionDataModel
    @State private var selectedItem: ConsumptionData?
    
    private var gradientColors = [Color.primarySwiftUI.opacity(0.8),
                                  Color.groheLink.opacity(0.8)]

    var body: some View {
        VStack {
            Chart { // 使用显式 ForEach
                // --- 第一步:绘制所有的 BarMark (无 Annotation) ---
                ForEach(model.data) { item in
                    BarMark(
                        x: .value("X 轴", item.text),
                        y: .value("消耗量", item.consumption ?? 0),
                        width: .automatic
                    )
                    .foregroundStyle(LinearGradient(
                        gradient: Gradient(colors: gradientColors),
                        startPoint: .top,
                        endPoint: .bottom
                    ))
                    .cornerRadius(4)
                    // .annotation 被移除
                }
                
                // 平均值标线 (保持独立绘制)
                if let average = model.average, average > 0 {
                   RuleMark(y: .value("平均值", average))
                       .foregroundStyle(.primarySwiftUI)
                       .lineStyle(.init(lineWidth: 1))
                       // ... annotation for average ...
                }

                // --- 第二步:如果存在选中项,则为其添加一个带 Annotation 的 (不可见) Mark ---
                if let selectedItem = selectedItem, (selectedItem.consumption ?? 0) > 0 {
                    // 使用 PointMark 定位,并设为透明
                    PointMark(
                        x: .value("X 轴", selectedItem.text),
                        y: .value("消耗量", selectedItem.consumption ?? 0)
                    )
                    .symbolSize(0) // 使点不可见
                    .foregroundStyle(.clear) // 设为透明
                    .annotation(position: .top, alignment: .center) { // 在这个 PointMark 上添加 Annotation
                        TooltipView(value: "\(selectedItem.text) • \(selectedItem.consumption ?? 0, specifier: "%.1f")l")
                            // 不需要 .opacity 控制了,因为这个 Mark 本身就是条件渲染的
                            .offset(y: -2) // 轻微向上偏移
                            // 可以加过渡效果
                             .transition(.opacity.animation(.easeInOut(duration: 0.2)))
                    }
                }
            } // Chart 闭包结束
            .padding([.leading, .trailing], 16)
            .chartXAxis { /* ... X轴配置不变 ... */ }
            .chartYAxis { /* ... Y轴配置不变 ... */ }
            .chartOverlay { chartProxy in /* ... 点击交互逻辑不变 ... */ }
        }
        .onChange(of: model.data) { _, _ in selectedItem = nil } // 数据变化重置选中
    }
}

优点:

  • 概念上简单:绘制顺序决定层级,最后画的在最上面。
  • 代码结构:Annotation 的逻辑仍然在 Chart 的内容构建器内部。

缺点:

  • 可能略微增加 Chart 的渲染负担(虽然只是一个额外的不可见 Mark)。
  • 需要确保定位用的 PointMark 的 x, y 值与 BarMark 完全一致,否则 annotation 会错位。

(补充) iOS 17+ 的解法:.zIndex

虽然题目要求兼容 iOS 16,但为了知识的完整性,提一下 iOS 17+ 的简单做法。只需要在 .annotation 修饰符或者包含它的 BarMark 上加上 .zIndex() 即可。

// ... BarMark(...) ...
.annotation(position: .top, alignment: .center) {
    // ... TooltipView ...
}
.zIndex(1) // 给 Annotation (或整个 BarMark) 提升层级

// 或者直接给 TooltipView 加 (如果 SwiftUI 支持的话,需要验证)
// .annotation(...) {
//     TooltipView(...)
//         .zIndex(1) // 可能不直接支持在 annotation 内容里加 zIndex
// }

// 最稳妥的是给包含 annotation 的 Mark 加 zIndex
BarMark(...)
    .cornerRadius(4)
    .annotation(...) { ... }
    .zIndex( (selectedItem?.id == item.id) ? 1 : 0 ) // 选中的 BarMark 及其 Annotation 绘制在更高层级

.zIndex 值越大,绘制越靠上。你可以给选中的 BarMark (及其附带的 annotation)设置一个比默认值 (0) 更大的 zIndex,比如 1。

进阶技巧与考量

  • 动态 Annotation 位置 :有时候,即使 Annotation 不被遮挡,它也可能因为出现在图表顶部边缘而被裁切。你可以结合方案一中的 chartProxygeometryProxy 来获取 plotAreaFrame 的边界信息。在计算 annotation 位置时,判断是否过于靠近顶部,如果是,可以将 annotationposition 参数从 .top 改为 .bottom,或者调整 alignment 让它不超出边界。
  • 性能 :对于非常多的数据点,频繁地在 chartOverlay 中进行位置计算或者渲染多个 Mark(方案二)可能会有性能影响。如果遇到性能瓶颈,考虑:
    • 优化 selectedItem 的更新逻辑,避免不必要的重绘。
    • TooltipView 本身进行性能优化(如果它很复杂的话)。
    • 节流或防抖处理 onTapGesture 更新 selectedItem 的操作。
  • 代码可读性与维护性 :方案一将交互和选中项的展示逻辑集中在 chartOverlay,可能更易于管理。方案二则将 annotation 的显示逻辑保留在主 Chart 内容区域,也符合直觉。选择哪个取决于你的项目风格和偏好。

选择合适的方案,调整代码,就能让你的 BarMark Annotation 在 iOS 16 上也能优雅地展示在最顶层,不再被后面的条形图遮挡了。