SwiftUI中用Swift Chart实现分组柱状图指定条形顶部信息视图
分组柱状图点击单个条形显示信息视图问题
我已经实现单柱状图点击条形时,在条形顶部显示信息视图:
现在尝试在分组柱状图中实现相同交互,但目前只能选中整个分组,信息视图显示在分组中间:
我需要实现点击分组中的单个条形时,在对应条形的位置显示该条形的信息视图,预期效果如下:
附上当前实现代码:
import Charts import SwiftUI struct Workout: Identifiable { let id = UUID() let day: String let minutes: Int } extension Workout { static let walkWorkout: [Workout] = [ .init(day: NSLocalizedString("mon", comment: ""), minutes: 23), .init(day: "Tue", minutes: 35), .init(day: "Wed", minutes: 55), .init(day: "Thu", minutes: 30), .init(day: "Fri", minutes: 15), .init(day: "Sat", minutes: 65), .init(day: "Sun", minutes: 81), ] static let runWorkout: [Workout] = [ .init(day: NSLocalizedString("mon", comment: ""), minutes: 16), .init(day: "Tue", minutes: 12), .init(day: "Wed", minutes: 55), .init(day: "Thu", minutes: 34), .init(day: "Fri", minutes: 22), .init(day: "Sat", minutes: 43), .init(day: "Sun", minutes: 90), ] } struct GroupedBarChartWithStartYAxisTap: View { @State private var selectedElement: Workout? = nil @Environment(\.layoutDirection) var layoutDirection var body: some View { List { VStack(alignment: .leading) { VStack(alignment: .leading) { Text("Day and Minutes") .font(.callout) .foregroundStyle(.secondary) Text("\(hours.first?.date ?? Date(), format: .dateTime)") .font(.title2.bold()) } .opacity(selectedElement == nil ? 1 : 0) InteractiveGroupedBarChartWithStartYAxisTap(selectedElement: $selectedElement) .frame(height: 600) } .chartBackground { proxy in ZStack(alignment: .topLeading) { GeometryReader { nthGeoItem in if let selectedElement = selectedElement { let startPositionX1 = proxy.position(forX: selectedElement.day) ?? 0 let startPositionX2 = proxy.position(forX: selectedElement.day) ?? 0 let midStartPositionX = (startPositionX1 + startPositionX2) / 2 + nthGeoItem[proxy.plotAreaFrame].origin.x let lineX = layoutDirection == .rightToLeft ? nthGeoItem.size.width - midStartPositionX : midStartPositionX let lineHeight = nthGeoItem[proxy.plotAreaFrame].maxY let boxWidth: CGFloat = 150 let boxOffset = max(0, min(nthGeoItem.size.width - boxWidth, lineX - boxWidth / 2)) Rectangle() .fill(.quaternary) .frame(width: 2, height: lineHeight) .position(x: lineX, y: lineHeight / 2) VStack(alignment: .leading) { Text("\(selectedElement.id)") .font(.callout) .foregroundStyle(.secondary) Text("\(selectedElement.day)\n\(selectedElement.minutes)") .font(.body.bold()) .foregroundColor(.primary) } .frame(width: boxWidth, alignment: .leading) .background { ZStack { RoundedRectangle(cornerRadius: 8) .fill(.background) RoundedRectangle(cornerRadius: 8) .fill(.quaternary.opacity(0.7)) } .padding([.leading, .trailing], -8) .padding([.top, .bottom], -4) } .offset(x: boxOffset) } } } } .listRowSeparator(.hidden) } .listStyle(.plain) .navigationBarTitle("Interactive Lollipop", displayMode: .inline) } } struct InteractiveGroupedBarChartWithStartYAxisTap: View { let workoutData = [ (workoutType: "Walk", data: Workout.walkWorkout), (workoutType: "Run", data: Workout.runWorkout) ] @Binding var selectedElement: Workout? func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> Workout? { let relativeXPosition = location.x - geometry[proxy.plotAreaFrame].origin.x let relativeYPosition = location.y - geometry[proxy.plotAreaFrame].origin.y if let day = proxy.value(atX: relativeXPosition, as: String.self), let minutes = proxy.value(atY: relativeYPosition, as: Int.self) { var workout: Workout? = nil for salesDataIndex in workoutData.indices { let nthSalesDataDistance = workoutData[salesDataIndex].data workout = nthSalesDataDistance.filter { $0.day == day/* && $0.minutes == minutes */}.first } if workout != nil { return workout } } return nil } var body: some View { VStack { Chart { ForEach(workoutData, id: \.workoutType) { element in ForEach(element.data) { BarMark(x: .value("Day", $0.day), y: .value("Workout(in minutes)", $0.minutes)) } .foregroundStyle(by: .value("Workout(type)", element.workoutType)) .position(by: .value("Workout(type)", element.workoutType)) } } .chartYAxis { AxisMarks(position: .leading, values: Array(stride(from: 0, through: 100, by: 10))) { axis in AxisTick() AxisGridLine() AxisValueLabel("\((axis.index * 10))", centered: false) } } .chartOverlay { proxy in GeometryReader { nthGeometryItem in Rectangle().fill(.clear).contentShape(Rectangle()) .gesture( SpatialTapGesture() .onEnded { value in let element = findElement(location: value.location, proxy: proxy, geometry: nthGeometryItem) if selectedElement?.id == element?.id { selectedElement = nil } else { selectedElement = element } } .exclusively( before: DragGesture() .onChanged { value in selectedElement = findElement(location: value.location, proxy: proxy, geometry: nthGeometryItem) } ) ) } } } .padding() } }
解决方案
问题核心在于两点:点击匹配逻辑无法区分同天不同分组的条形,以及信息视图的坐标计算未考虑分组偏移。以下是修改后的完整代码:
import Charts import SwiftUI struct Workout: Identifiable { let id = UUID() let day: String let minutes: Int let workoutType: String // 新增字段标记运动类型 } extension Workout { static let walkWorkout: [Workout] = [ .init(day: NSLocalizedString("mon", comment: ""), minutes: 23, workoutType: "Walk"), .init(day: "Tue", minutes: 35, workoutType: "Walk"), .init(day: "Wed", minutes: 55, workoutType: "Walk"), .init(day: "Thu", minutes: 30, workoutType: "Walk"), .init(day: "Fri", minutes: 15, workoutType: "Walk"), .init(day: "Sat", minutes: 65, workoutType: "Walk"), .init(day: "Sun", minutes: 81, workoutType: "Walk"), ] static let runWorkout: [Workout] = [ .init(day: NSLocalizedString("mon", comment: ""), minutes: 16, workoutType: "Run"), .init(day: "Tue", minutes: 12, workoutType: "Run"), .init(day: "Wed", minutes: 55, workoutType: "Run"), .init(day: "Thu", minutes: 34, workoutType: "Run"), .init(day: "Fri", minutes: 22, workoutType: "Run"), .init(day: "Sat", minutes: 43, workoutType: "Run"), .init(day: "Sun", minutes: 90, workoutType: "Run"), ] static let allWorkouts = walkWorkout + runWorkout // 合并数据方便查找 } struct GroupedBarChartWithStartYAxisTap: View { @State private var selectedElement: Workout? = nil @Environment(\.layoutDirection) var layoutDirection var body: some View { List { VStack(alignment: .leading) { VStack(alignment: .leading) { Text("Day and Minutes") .font(.callout) .foregroundStyle(.secondary) Text("\(Date(), format: .dateTime)") // 修复未定义的hours变量 .font(.title2.bold()) } .opacity(selectedElement == nil ? 1 : 0) InteractiveGroupedBarChartWithStartYAxisTap(selectedElement: $selectedElement) .frame(height: 600) } .chartBackground { proxy in ZStack(alignment: .topLeading) { GeometryReader { nthGeoItem in if let selectedElement = selectedElement { // 计算单个条形的精确X位置 let barPosition = proxy.position( forX: selectedElement.day, plot: .value("Workout(type)", selectedElement.workoutType) ) ?? 0 let midStartPositionX = barPosition + nthGeoItem[proxy.plotAreaFrame].origin.x let lineX = layoutDirection == .rightToLeft ? nthGeoItem.size.width - midStartPositionX : midStartPositionX let lineHeight = nthGeoItem[proxy.plotAreaFrame].maxY let barTopY = proxy.position(forY: selectedElement.minutes) ?? 0 let boxWidth: CGFloat = 150 let boxOffset = max(0, min(nthGeoItem.size.width - boxWidth, lineX - boxWidth / 2)) // 垂直线条从底部延伸到条形顶部 Rectangle() .fill(.quaternary) .frame(width: 2, height: lineHeight - barTopY) .position(x: lineX, y: barTopY + (lineHeight - barTopY)/2) // 信息视图定位到条形顶部 VStack(alignment: .leading) { Text(selectedElement.workoutType) .font(.callout) .foregroundStyle(.secondary) Text("\(selectedElement.day)\n\(selectedElement.minutes) mins") .font(.body.bold()) .foregroundColor(.primary) } .frame(width: boxWidth, alignment: .leading) .background { ZStack { RoundedRectangle(cornerRadius: 8) .fill(.background) RoundedRectangle(cornerRadius: 8) .fill(.quaternary.opacity(0.7)) } .padding([.leading, .trailing], -8) .padding([.top, .bottom], -4) } .offset(x: boxOffset, y: barTopY - 60) } } } } .listRowSeparator(.hidden) } .listStyle(.plain) .navigationBarTitle("Interactive Grouped Bar Chart", displayMode: .inline) } } struct InteractiveGroupedBarChartWithStartYAxisTap: View { let workoutData = [ (workoutType: "Walk", data: Workout.walkWorkout), (workoutType: "Run", data: Workout.runWorkout) ] @Binding var selectedElement: Workout? func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> Workout? { let relativeXPosition = location.x - geometry[proxy.plotAreaFrame].origin.x let relativeYPosition = location.y - geometry[proxy.plotAreaFrame].origin.y // 获取点击位置对应的day、分组类型和minutes guard let day = proxy.value(atX: relativeXPosition, as: String.self), let workoutType = proxy.value(atX: relativeXPosition, plot: .value("Workout(type)", String.self), as: String.self), let minutes = proxy.value(atY: relativeYPosition, as: Int.self) else { return nil } // 精确匹配单个条形 return Workout.allWorkouts.first { $0.day == day && $0.workoutType == workoutType && $0.minutes == minutes } } var body: some View { VStack { Chart { ForEach(workoutData, id: \.workoutType) { element in ForEach(element.data) { workout in BarMark( x: .value("Day", workout.day), y: .value("Workout(in minutes)", workout.minutes) ) .position(by: .value("Workout(type)", workout.workoutType)) } .foregroundStyle(by: .value("Workout(type)", element.workoutType)) } } .chartYAxis { AxisMarks(position: .leading, values: Array(stride(from: 0, through: 100, by: 10))) { axis in AxisTick() AxisGridLine() AxisValueLabel("\(axis.value)", centered: false) // 修正Y轴标签计算 } } .chartOverlay { proxy in GeometryReader { nthGeometryItem in Rectangle().fill(.clear).contentShape(Rectangle()) .gesture( SpatialTapGesture() .onEnded { value in let element = findElement(location: value.location, proxy: proxy, geometry: nthGeometryItem) if selectedElement?.id == element?.id { selectedElement = nil } else { selectedElement = element } } .exclusively( before: DragGesture() .onChanged { value in selectedElement = findElement(location: value.location, proxy: proxy, geometry: nthGeometryItem) } ) ) } } } .padding() } }
修改说明
- 模型扩展:给
Workout新增workoutType字段,标记每个数据点所属分组,同时合并所有数据到allWorkouts集合,简化查找逻辑。 - 点击匹配优化:在
findElement方法中,通过proxy.value(atX:plot:)获取点击位置对应的分组类型,结合day和minutes精确匹配单个条形,避免返回同天的其他数据。 - 视图位置修正:使用
proxy.position(forX:plot:)计算单个条形的精确X坐标,根据条形的Y值计算顶部位置,将信息视图和垂直线条精准定位到对应条形上方。 - BUG修复:处理原代码中未定义的
hours变量,修正Y轴标签的计算方式,避免索引错误。
内容的提问来源于stack exchange,提问作者Asif Raza
相关产品推荐
相关产品推荐

