Skip to content

Instantly share code, notes, and snippets.

@globulus
Created June 14, 2022 08:04
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save globulus/e937b745bfd6770e78c468c0d17e3303 to your computer and use it in GitHub Desktop.
Save globulus/e937b745bfd6770e78c468c0d17e3303 to your computer and use it in GitHub Desktop.
Chart Scan Line / Lollipop in SwiftUI with Charts Framework
// Check out https://swiftuirecipes.com/blog/chart-scan-line-lollipop-in-swiftui-with-charts-framework
import SwiftUI
import Charts
struct FoodIntake: Hashable {
let date: Date
let calories: Int
}
func date(year: Int, month: Int, day: Int) -> Date {
Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}
let intake = stride(from: 1, to: 31, by: 1).map { day in
FoodIntake(date: date(year: 2022, month: 5, day: day), calories: Int.random(in: 1800...2200))
}
struct ChartTest: View {
@State var selectedElement: FoodIntake?
var body: some View {
Chart {
ForEach(intake, id: \.self) { data in
BarMark(x: .value("Date", data.date),
y: .value("Calories", data.calories))
}
}
.chartOverlay { proxy in
GeometryReader { geo in
Rectangle().fill(.clear).contentShape(Rectangle())
.gesture(
SpatialTapGesture()
.onEnded { value in
let element = findElement(location: value.location, proxy: proxy, geometry: geo)
if selectedElement?.date == element?.date {
// 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: geo)
}
)
)
}
}
.chartBackground { proxy in
ZStack(alignment: .topLeading) {
GeometryReader { geo in
if let selectedElement = selectedElement {
let dateInterval = Calendar.current.dateInterval(of: .day, for: selectedElement.date)!
let startPositionX = proxy.position(forX: dateInterval.start) ?? 0
let midStartPositionX = startPositionX + geo[proxy.plotAreaFrame].origin.x
let lineHeight = geo[proxy.plotAreaFrame].maxY
let boxWidth: CGFloat = 150
let boxOffset = max(0, min(geo.size.width - boxWidth, midStartPositionX - boxWidth / 2))
Rectangle()
.fill(.quaternary)
.frame(width: 2, height: lineHeight)
.position(x: midStartPositionX, y: lineHeight / 2)
VStack(alignment: .leading) {
Text("\(selectedElement.date, format: .dateTime.year().month().day())")
.font(.callout)
.foregroundStyle(.secondary)
Text("\(selectedElement.calories, format: .number) calories")
.font(.title2.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)
}
}
}
}
.frame(height: 250)
.padding()
}
func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> FoodIntake? {
let relativeXPosition = location.x - geometry[proxy.plotAreaFrame].origin.x
if let date = proxy.value(atX: relativeXPosition) as Date? {
// Find the closest date element.
var minDistance: TimeInterval = .infinity
var index: Int? = nil
for dataIndex in intake.indices {
let nthDataDistance = intake[dataIndex].date.distance(to: date)
if abs(nthDataDistance) < minDistance {
minDistance = abs(nthDataDistance)
index = dataIndex
}
}
if let index = index {
return intake[index]
}
}
return nil
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment