You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

修改说明

  1. 模型扩展:给Workout新增workoutType字段,标记每个数据点所属分组,同时合并所有数据到allWorkouts集合,简化查找逻辑。
  2. 点击匹配优化:在findElement方法中,通过proxy.value(atX:plot:)获取点击位置对应的分组类型,结合day和minutes精确匹配单个条形,避免返回同天的其他数据。
  3. 视图位置修正:使用proxy.position(forX:plot:)计算单个条形的精确X坐标,根据条形的Y值计算顶部位置,将信息视图和垂直线条精准定位到对应条形上方。
  4. BUG修复:处理原代码中未定义的hours变量,修正Y轴标签的计算方式,避免索引错误。

内容的提问来源于stack exchange,提问作者Asif Raza

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 06:47:03