Created
June 14, 2022 08:04
-
-
Save globulus/e937b745bfd6770e78c468c0d17e3303 to your computer and use it in GitHub Desktop.
Chart Scan Line / Lollipop in SwiftUI with Charts Framework
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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