SwiftUI Charts: 修复 iOS 16 BarMark Annotation 遮挡问题
2025-05-02 16:41:23
搞定 SwiftUI Charts:让 BarMark Annotation 不再被遮挡
用 SwiftUI Charts 画条形图 (Bar Chart) 时,你可能遇到过一个头疼的问题:给 BarMark
添加的 annotation
(比如一个提示框 Tooltip),会被后面绘制的 BarMark
给盖住,就像下图这样:
代码看起来可能是这样的,我们在 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 里的元素,是按你代码里写的顺序一层层画上去的。在这个例子里:
Chart
遍历model.data
。- 对第一个数据项,绘制
BarMark
。 - 紧接着,为这个
BarMark
绘制annotation
(如果此时它被选中)。 - 对第二个数据项,绘制
BarMark
。这个BarMark
自然就画在了第一个BarMark
的annotation
之上(如果它们在位置上重叠的话)。 - 以此类推...
结果就是,前面的 annotation
被后面的 BarMark
无情地盖住了。.zIndex
本来是解决这种层叠问题的利器,它允许你手动指定视图的绘制层级,数值越大越靠上。可惜 iOS 16 用不了。
怎么破?(iOS 16 兼容方案)
既然不能直接控制单个 annotation
的层级,我们就得换个思路,让那个需要显示的 annotation
在所有 BarMark
都画完之后 再绘制。
这里提供两种可行的方案:
方案一:利用 chartOverlay
统一绘制选中的 Annotation
这个方法比较推荐,因为它利用了我们已经用来做点击交互的 chartOverlay
。chartOverlay
顾名思义,它绘制的内容会覆盖在整个图表(包括所有的 Mark)之上。
原理:
不在每个 BarMark
上单独附加 annotation
,而是在 chartOverlay
中,根据当前选中的 selectedItem
,计算出对应 BarMark
的位置,然后只绘制一个 TooltipView
。
步骤:
- 移除
BarMark
上的.annotation
修饰符。 - 在
chartOverlay
中获取ChartProxy
。 这个proxy
能帮助我们把数据值映射到图表上的具体位置。 - 判断
selectedItem
是否存在。 如果有选中的项,才需要显示TooltipView
。 - 计算
TooltipView
的位置。 这是关键一步。我们需要根据selectedItem
的 x 值 (item.text
) 和 y 值 (item.consumption
),使用chartProxy
提供的方法(比如position(forX:y:)
)来找到它在绘图区域 (plotArea
) 内的准确坐标。通常,我们会定位到对应BarMark
的顶部中心点。 - 在计算出的位置放置
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
都添加完之后,再添加一个专门用来显示 annotation
的 Mark
。
原理:
利用 Chart
按顺序绘制的特性。先用 ForEach
绘制所有的 BarMark
(不带 annotation
)。然后,再次检查 selectedItem
,如果存在,就添加一个(通常是不可见的)Mark
(比如 PointMark
或 RuleMark
)在选中数据点的位置,并只给这个 Mark 附加 .annotation
。
步骤:
- 移除
BarMark
上的.annotation
修饰符。 - 在
Chart
内容构建器中,ForEach
绘制完BarMark
之后 ,添加一个if let selectedItem = selectedItem
判断。 - 在
if
语句块内,添加一个新的Mark
类型 (例如PointMark
),它的 x 和 y 值设为selectedItem
的值。这个Mark
本身可以设置成透明或者尺寸为 0,我们只关心它的位置。 - 给这个新的
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
不被遮挡,它也可能因为出现在图表顶部边缘而被裁切。你可以结合方案一中的chartProxy
和geometryProxy
来获取plotAreaFrame
的边界信息。在计算annotation
位置时,判断是否过于靠近顶部,如果是,可以将annotation
的position
参数从.top
改为.bottom
,或者调整alignment
让它不超出边界。 - 性能 :对于非常多的数据点,频繁地在
chartOverlay
中进行位置计算或者渲染多个Mark
(方案二)可能会有性能影响。如果遇到性能瓶颈,考虑:- 优化
selectedItem
的更新逻辑,避免不必要的重绘。 - 对
TooltipView
本身进行性能优化(如果它很复杂的话)。 - 节流或防抖处理
onTapGesture
更新selectedItem
的操作。
- 优化
- 代码可读性与维护性 :方案一将交互和选中项的展示逻辑集中在
chartOverlay
,可能更易于管理。方案二则将annotation
的显示逻辑保留在主Chart
内容区域,也符合直觉。选择哪个取决于你的项目风格和偏好。
选择合适的方案,调整代码,就能让你的 BarMark Annotation
在 iOS 16 上也能优雅地展示在最顶层,不再被后面的条形图遮挡了。