我可以通过点击顶部的单个栏来显示信息视图,如下所示 -
现在我尝试在分组条形图中点击特定条形时显示信息视图,如下所示 -
当我尝试实现它时,我只能弄清楚如何获取组选择并从组栏的中间/中心显示信息视图,如下所示 - 我想在选择时在顶部信息视图中获取特定的栏信息。
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()
}
}
您可以通过设置组的
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()
}
}