如何使用 Swift Chart 框架在 SwiftUI 中分组条形图中的特定条形顶部显示信息视图

问题描述 投票:0回答:1

我可以通过点击顶部的单个栏来显示信息视图,如下所示 -

enter image description here

现在我尝试在分组条形图中点击特定条形时显示信息视图,如下所示 -

enter image description here

当我尝试实现它时,我只能弄清楚如何获取组选择并从组栏的中间/中心显示信息视图,如下所示 - 我想在选择时在顶部信息视图中获取特定的栏信息。

enter image description here

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 dateInterval = Calendar.current.dateInterval(of: .hour, for: selectedElement.date)!
                            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 {
                                        // If tapping the same element, clear the selection.
                                        selectedElement = nil
                                    } else {
                                        selectedElement = element
                                    }
                                }
                                .exclusively(
                                    before: DragGesture()
                                        .onChanged { value in
                                            selectedElement = findElement(location: value.location, proxy: proxy, geometry: nthGeometryItem)
                                        }
                                )
                        )
                }
            }
        }
        .padding()
    }
}
swiftui charts bar-chart
1个回答
0
投票

您可以通过设置组的

span
来手动计算条形的位置。

.position(by: .value("Workout(type)", element.workoutType), span: 40)

这里我将组跨度设置为40。这意味着左/右条的中心将是组中心左/右10个点(即组跨度除以4)。

在我们做任何其他事情之前,能够轻松识别

Workout
是什么类型的锻炼(步行或跑步)将非常有用。

struct Workout: Identifiable, Hashable {
    let id = UUID()
    let day: String
    let minutes: Int
    let kind: Kind
    
    enum Kind {
        case walk, run
    }
}

findElement
中,查看
location.x
是在组中心 X 的左侧还是右侧。

func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> Workout? {
    guard let frame = proxy.plotFrame,
          let x = proxy.value(atX: location.x - geometry[frame].origin.x, as: String.self),
          let xRange = proxy.positionRange(forX: x) else { return nil }
    let midPoint = (xRange.lowerBound + xRange.upperBound) / 2
    return if location.x - geometry[frame].origin.x < midPoint { // walk
        workoutData[0].data.first { $0.day == x }
    } else { // run
        workoutData[1].data.first { $0.day == x }
    }
}

chartBackground
中,检查锻炼的
kind
,并将其向左/向右移动 10 点。

.chartBackground { proxy in
    ZStack (alignment: .topLeading) {
        GeometryReader { geo in
            if let selectedElement, let frame = proxy.plotFrame, let x = proxy.position(forX: selectedElement.day) {
                let offset: CGFloat = selectedElement.kind == .run ? 10 : -10
                let height = geo[frame].height
                Rectangle()
                    .fill(.quaternary)
                    .frame(width: 2, height: height)
                    .position(x: x + offset + geo[frame].minX, y: height / 2)
                // the rectangle containing the info goes here...
            }
        }
    }
}

这是一个最小的可重现示例。处理从右到左的布局留作练习。

struct Workout: Identifiable, Hashable {
    let id = UUID()
    let day: String
    let minutes: Int
    let kind: Kind
    
    enum Kind {
        case walk, run
    }
}

extension Workout {
    static let walkWorkout: [Workout] = [
        .init(day: NSLocalizedString("mon", comment: ""), minutes: 23, kind: .walk),
        .init(day: "Tue", minutes: 35, kind: .walk),
        .init(day: "Wed", minutes: 55, kind: .walk),
        .init(day: "Thu", minutes: 30, kind: .walk),
        .init(day: "Fri", minutes: 15, kind: .walk),
        .init(day: "Sat", minutes: 65, kind: .walk),
        .init(day: "Sun", minutes: 81, kind: .walk),
    ]
    
    static let runWorkout: [Workout] = [
        .init(day: NSLocalizedString("mon", comment: ""), minutes: 16, kind: .run),
        .init(day: "Tue", minutes: 12, kind: .run),
        .init(day: "Wed", minutes: 55, kind: .run),
        .init(day: "Thu", minutes: 34, kind: .run),
        .init(day: "Fri", minutes: 22, kind: .run),
        .init(day: "Sat", minutes: 43, kind: .run),
        .init(day: "Sun", minutes: 90, kind: .run),
    ]
}

let workoutData = [
    (workoutType: "Walk", data: Workout.walkWorkout),
    (workoutType: "Run", data: Workout.runWorkout)
]

struct ContentView: View {
    
    @State var selectedElement: Workout?
    
    func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> Workout? {
        guard let frame = proxy.plotFrame,
              let x = proxy.value(atX: location.x - geometry[frame].origin.x, as: String.self),
              let xRange = proxy.positionRange(forX: x) else { return nil }
        let midPoint = (xRange.lowerBound + xRange.upperBound) / 2
        return if location.x - geometry[frame].origin.x < midPoint { // walk
            workoutData[0].data.first { $0.day == x }
        } else { // run
            workoutData[1].data.first { $0.day == x }
        }
    }
    
    var body: some View {
        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)
                    )
                }
                .foregroundStyle(by: .value("Workout(type)", element.workoutType))
                .position(by: .value("Workout(type)", element.workoutType), span: 40)
            }
        }
        .chartYAxis {
            AxisMarks(position: .leading, values: Array(stride(from: 0, through: 100, by: 10))) {
                axis in
                AxisTick()
                AxisGridLine()
                AxisValueLabel("\((axis.index * 10))", centered: false)
            }
        }
        .chartBackground { proxy in
            ZStack (alignment: .topLeading) {
                GeometryReader { geo in
                    if let selectedElement, let frame = proxy.plotFrame, let x = proxy.position(forX: selectedElement.day) {
                        let offset: CGFloat = selectedElement.kind == .run ? 10 : -10
                        let height = geo[frame].height
                        Rectangle()
                            .fill(.quaternary)
                            .frame(width: 2, height: height)
                            .position(x: x + offset + geo[frame].minX, y: height / 2)
                        VStack(alignment: .leading) {
                            Text("\(selectedElement.id)")
                                .font(.callout)
                                .foregroundStyle(.secondary)
                            Text("\(selectedElement.day)\n\(selectedElement.minutes)")
                                .font(.body.bold())
                                .foregroundColor(.primary)
                        }
                        .frame(width: 150, 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: min(x + offset + geo[frame].minX, geo[frame].maxX - 150))
                    }
                }

            }
        }
        .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()
    }
}
© www.soinside.com 2019 - 2024. All rights reserved.