Guest User

Untitled

a guest
Jul 11th, 2026
201
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 27.24 KB | None | 0 0
  1. ================================================================================
  2. 2-FINGER RANGE TOUCH ON A SWIFTUI CHART
  3. ================================================================================
  4.  
  5. How I let users put two fingers on a price chart and get the % change / price
  6. difference between those two points (with a moving hover overlay + haptics).
  7.  
  8. The trick: SwiftUI's built-in gestures don't give you clean access to multiple
  9. simultaneous touch locations, so I drop down to a UIKit UIGestureRecognizer via
  10. UIViewRepresentable (MultiTouchView). That recognizer reports every touch
  11. location back up to SwiftUI. A small HoverHelper turns those touch X positions
  12. into chart indices -> prices -> times. A .delayTouches() modifier stops the
  13. parent ScrollView from stealing the gesture.
  14.  
  15. The pieces:
  16. 1. MultiTouchView - UIKit gesture recognizer that reports touch points
  17. 2. .delayTouches() - keeps the ScrollView from hijacking the touches
  18. 3. mainGraph + overlay - where it's all wired together in the chart view
  19. 4. HoverHelper - maps touch X -> chart index -> price/time (+ haptics)
  20. 5. NewChartView - draws the line + dims everything outside the range
  21. 6. ChartHoverView - the dashed line + dot drawn under each finger
  22.  
  23. Everything below is real code from my app (CryptoCurrently).
  24.  
  25.  
  26. ================================================================================
  27. 1. MultiTouchView.swift
  28. The core: a UIGestureRecognizer that tracks every touch and reports the (up
  29. to 2) left-to-right sorted touch points back to SwiftUI on every change.
  30. ================================================================================
  31.  
  32. import SwiftUI
  33.  
  34. struct MultiTouchView: UIViewRepresentable {
  35. var tappedCallback: (([CGPoint]) -> Void)
  36.  
  37. func makeUIView(context: UIViewRepresentableContext<MultiTouchView>) -> MultiTouchView.UIViewType {
  38. let v = UIView(frame: .zero)
  39. v.isAccessibilityElement = false
  40. v.accessibilityElementsHidden = true
  41. let gesture = MultiTouchGestureRecognizer(target: context.coordinator, tappedCallback: tappedCallback)
  42. v.addGestureRecognizer(gesture)
  43. return v
  44. }
  45.  
  46. func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<MultiTouchView>) { }
  47.  
  48. static func dismantleUIView(_ uiView: UIView, coordinator: ()) {
  49. uiView.isAccessibilityElement = false
  50. uiView.accessibilityElementsHidden = true
  51. }
  52. }
  53.  
  54. public class MultiTouchGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
  55. private var trackedTouches = [UITouch: CGPoint]()
  56.  
  57. var tappedCallback: ([CGPoint]) -> Void
  58.  
  59. init(target: Any?, tappedCallback: @escaping ([CGPoint]) -> ()) {
  60. self.tappedCallback = tappedCallback
  61. super.init(target: target, action: nil)
  62. self.delegate = self
  63. }
  64.  
  65. public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
  66. state = .began
  67.  
  68. for touch in touches {
  69. trackedTouches[touch] = touch.location(in: view)
  70. }
  71.  
  72. tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
  73. }
  74.  
  75. public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
  76. state = .changed
  77.  
  78. for touch in touches {
  79. trackedTouches[touch] = touch.location(in: view)
  80. }
  81.  
  82. tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
  83. }
  84.  
  85. public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
  86. for touch in touches {
  87. trackedTouches[touch] = nil
  88. }
  89.  
  90. if trackedTouches.count == 0 {
  91. state = .ended
  92. }
  93.  
  94. tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
  95. }
  96.  
  97. public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
  98. state = .cancelled
  99.  
  100. for touch in touches {
  101. trackedTouches[touch] = nil
  102. }
  103.  
  104. tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
  105. }
  106.  
  107. public override func reset() {
  108. super.reset()
  109.  
  110. self.trackedTouches = [:]
  111. }
  112.  
  113. // Allow this to run alongside SwiftUI's own gestures (scroll etc.)
  114. public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
  115. return true
  116. }
  117. }
  118.  
  119.  
  120. ================================================================================
  121. 2. .delayTouches() (from View+Extension.swift)
  122. Without this, the parent ScrollView grabs the touches and the chart gesture
  123. never fires reliably. Wrapping the chart in a Button + highPriorityGesture
  124. delays the touch delivery just enough that the multi-touch recognizer wins.
  125. (Disabled under VoiceOver so accessibility still works.)
  126. ================================================================================
  127.  
  128. struct NoButtonStyle: ButtonStyle {
  129. func makeBody(configuration: Configuration) -> some View {
  130. configuration.label
  131. }
  132. }
  133.  
  134. private struct DelayTouchesModifier: ViewModifier {
  135. @Environment(\.accessibilityVoiceOverEnabled) private var accessibilityVoiceOverEnabled
  136.  
  137. func body(content: Content) -> some View {
  138. if accessibilityVoiceOverEnabled {
  139. content
  140. } else {
  141. Button(action: {}) {
  142. content
  143. .highPriorityGesture(TapGesture())
  144. }
  145. .buttonStyle(NoButtonStyle())
  146. }
  147. }
  148. }
  149.  
  150. extension View {
  151. func delayTouches() -> some View {
  152. modifier(DelayTouchesModifier())
  153. }
  154. }
  155.  
  156.  
  157. ================================================================================
  158. 3. Wiring it up in the chart (from CryptoDetailScreen.swift)
  159. The MultiTouchView sits on top of the drawn chart (mainGraph) inside a ZStack.
  160. .delayTouches() is applied to the whole ZStack. Every touch update calls
  161. viewModel.updateHoverStats(touches:).
  162. ================================================================================
  163.  
  164. ZStack {
  165. ChartBackground(
  166. biggestValue: $biggestChartValue,
  167. values: viewModel.mainGraphStats
  168. )
  169.  
  170. if viewModel.mainReloading {
  171. ActivityIndicator()
  172. .padding(.trailing, biggestChartValue.width)
  173. .accessibilityHidden(true)
  174. } else {
  175. mainGraph(halfBiggestChartValueHeight: halfBiggestChartValueHeight)
  176. .opacity(viewModel.showMainPriceError ? 0 : 1)
  177. .frame(height: max(1, reader.size.height * 0.35))
  178.  
  179. // <-- the multi-touch layer sits on top of the drawn chart
  180. MultiTouchView { touches in
  181. viewModel.updateHoverStats(touches: touches)
  182. }
  183. .accessibilityHidden(true)
  184. }
  185. }
  186. .animation(.none, value: viewModel.isDragging)
  187. .delayTouches() // <-- stops the ScrollView stealing the gesture
  188. .frame(height: max(1, reader.size.height * 0.35))
  189. .padding(.horizontal, 16)
  190.  
  191.  
  192. // mainGraph: draws the line + the hover overlay (dots/lines under each finger)
  193. private func mainGraph(halfBiggestChartValueHeight: CGFloat) -> some View {
  194. NewChartView(
  195. prices: viewModel.getPrices(),
  196. color: viewModel.graphColor,
  197. lineWidth: 2,
  198. dragPositions: viewModel.dragPositions, // <-- one point per finger
  199. size: viewModel.chartSize,
  200. isDragging: viewModel.isDragging
  201. )
  202. .padding(.leading, (viewModel.timeStamps.normalized.first ?? 0) * viewModel.chartSize.width)
  203. .readSize {
  204. viewModel.chartSize = $0
  205. }
  206. .padding(
  207. EdgeInsets(
  208. top: halfBiggestChartValueHeight,
  209. leading: 0,
  210. bottom: halfBiggestChartValueHeight,
  211. trailing: biggestChartValue.width
  212. )
  213. )
  214.  
  215. ChartHoverView(
  216. positions: viewModel.dragPositions,
  217. size: viewModel.chartSize,
  218. color: viewModel.graphColor,
  219. topPadding: halfBiggestChartValueHeight
  220. )
  221. .opacity(viewModel.isDragging ? 1 : 0)
  222. .frame(height: viewModel.chartSize.height + halfBiggestChartValueHeight * 2)
  223. }
  224.  
  225.  
  226. ================================================================================
  227. 4. View model glue (from CryptoDetailViewModel.swift)
  228. Turns the raw touch points into: isDragging state, clamped offsets,
  229. drag positions (dots on the line), the line color (green/red based on the
  230. range) and kicks off the price/time lookup in HoverHelper.
  231. ================================================================================
  232.  
  233. // Called on every touch update from MultiTouchView
  234. func updateHoverStats(touches: [CGPoint]) {
  235. guard !mainReloading else { return }
  236. guard !timeStamps.isEmpty && !touches.isEmpty else {
  237. isDragging = false
  238. timer?.invalidate()
  239. return
  240. }
  241.  
  242. // Small delay before we consider it a real "drag" (avoids accidental taps)
  243. if !isDragging {
  244. timer?.invalidate()
  245. timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in
  246. Task { @MainActor in
  247. guard let self = self else { return }
  248. self.isDragging = !touches.isEmpty
  249.  
  250. if self.isDragging {
  251. UIImpactFeedbackGenerator(style: .light).impactOccurred()
  252. }
  253. }
  254. }
  255. }
  256.  
  257. hoverHelper.offset = touches.map {
  258. CGPoint(x: max(0, min($0.x, chartSize.width.rounded(.down) - 0.00001)), y: $0.y)
  259. }
  260.  
  261. hoverHelper.getHoverPrice(
  262. readerSize: chartSize,
  263. offset: hoverHelper.offset,
  264. prices: getPrices(),
  265. dates: getTimestamps(),
  266. timeChart: selectedTimeFrame,
  267. isDragging: isDragging
  268. )
  269. }
  270.  
  271. // Color of the line: green/red depending on the change *between the 2 fingers*
  272. var graphColor: Color {
  273. guard hoverHelper.hoverPrices.count == 2, isDragging else {
  274. return changePercent?.changeColor ?? .onSurfaceDim
  275. }
  276.  
  277. let first = hoverHelper.hoverPrices[safely: 0] ?? 0
  278. let second = hoverHelper.hoverPrices[safely: 1] ?? 0
  279. let change = (second - first).roundTo(places: 7)
  280.  
  281. if change == 0 {
  282. let percent = Double.getDifferenceInPercent(between: second, and: first)?.roundTo(places: 7)
  283. return percent?.changeColor ?? .onSurfaceDim
  284. }
  285.  
  286. return change.changeColor
  287. }
  288.  
  289. // One chart index per finger (used to draw the dots on the line)
  290. var mainDragIndex: [Int] {
  291. let timestamps = getTimestamps()
  292. let pricesCount = CGFloat(getPrices().count)
  293. let firstTimestamp = timeStamps.normalized.first ?? 0
  294. let timestampCount = Double(timestamps.count)
  295.  
  296. return hoverHelper.offset.map { offsetPoint in
  297. let offsetRatio = offsetPoint.x / chartSize.width
  298. guard offsetRatio >= firstTimestamp else { return -1 }
  299.  
  300. let indexOffset = timestampCount * firstTimestamp
  301. let maxIndex = max(0, Int(pricesCount))
  302. let spatialIndex = Double(maxIndex) * Double(offsetPoint.x / chartSize.width)
  303. let index = spatialIndex - indexOffset
  304.  
  305. if index.isNaN || index.isInfinite {
  306. return -1
  307. }
  308.  
  309. return Int(index)
  310. }
  311. }
  312.  
  313. // Screen position for each finger's point on the line
  314. var dragPositions: [CGPoint] {
  315. return mainDragIndex.map({
  316. point(at: $0, rect: CGSize(width: chartSize.width, height: chartSize.height))
  317. })
  318. }
  319.  
  320. func point(at index: Int, rect: CGSize) -> CGPoint {
  321. return calculatePoint(
  322. at: index,
  323. rect: rect,
  324. timestamps: getTimestamps(),
  325. prices: getPrices()
  326. )
  327. }
  328.  
  329. private func calculatePoint(
  330. at index: Int,
  331. rect: CGSize,
  332. timestamps: [Double],
  333. prices: [Double],
  334. adjustXByHalfPixel: Bool = true
  335. ) -> CGPoint {
  336. let normalizedPrices = prices.normalized
  337. let pointValue = normalizedPrices[safely: index] ?? 0
  338.  
  339. let denominator = Double(normalizedPrices.count - 1)
  340. let x = rect.width * Double(index) / denominator
  341. let y = (1 - pointValue) * rect.height
  342.  
  343. return CGPoint(
  344. x: adjustXByHalfPixel ? x - 0.5 : x,
  345. y: y.isNaN ? 0.5 * rect.height : y
  346. )
  347. }
  348.  
  349.  
  350. ================================================================================
  351. 5. HoverHelper.swift
  352. The @Observable state object that maps each touch X position to a chart
  353. index, then to a price + formatted time. Fires a haptic every time the
  354. hovered time bucket changes so scrubbing "ticks". hoverPrices ends up with
  355. up to 2 values -> the two ends of the range.
  356. ================================================================================
  357.  
  358. import Foundation
  359. import SwiftUI
  360.  
  361. @Observable @MainActor
  362. class HoverHelper {
  363. let impactMed = UIImpactFeedbackGenerator(style: .light)
  364.  
  365. var hoverPrices = [Double]()
  366. var SMAHoverPrices = [Double]()
  367. var hoverTimes = [String]()
  368. var offset = [CGPoint]()
  369. var dateFormatter: DateFormatter = DateFormatter()
  370. var tempHoverTimes = [String]()
  371.  
  372. var compareHoverPrice = 0.0
  373. var latestPositions = [Int]()
  374. var latestComparePosition = 0
  375.  
  376. init() {
  377. dateFormatter.locale = Locale(identifier: "en_US_POSIX")
  378. dateFormatter.amSymbol = "AM"
  379. dateFormatter.pmSymbol = "PM"
  380. }
  381.  
  382. func resetValues() {
  383. offset = []
  384. hoverPrices = []
  385. hoverTimes = []
  386. }
  387.  
  388. // The main one for line/area charts: takes the (up to 2) touch offsets and
  389. // fills hoverPrices + hoverTimes for each finger.
  390. func getHoverPrice(readerSize: CGSize, offset: [CGPoint], prices: [Double], dates: [Double], timeChart: TimeCharts?, compareDates: [Double]? = nil, comparePrices: [Double]? = nil, inFearGreedView: Bool = false, isDragging: Bool) {
  391. // Pre-compute loop-invariant values
  392. let mainMinTime = dates.lazy.filter { $0 != -1 }.min() ?? .infinity
  393. let compareMinTime = compareDates?.lazy.filter { $0 != -1 }.min() ?? .infinity
  394. let lowestTimeStamp = mainMinTime < compareMinTime ? dates : (compareDates ?? [])
  395.  
  396. let offsetCount = offset.count
  397. var indices = [Int]()
  398. var localPricesArray = [Double]()
  399. var localTimes = [String]()
  400. var temporaryTimes = [String]()
  401.  
  402. indices.reserveCapacity(offsetCount)
  403. localPricesArray.reserveCapacity(offsetCount)
  404. localTimes.reserveCapacity(offsetCount)
  405. temporaryTimes.reserveCapacity(offsetCount)
  406.  
  407. // First pass: compute indices and prices (x = width * i / (n - 1))
  408. for point in offset {
  409. let index = chartLinearSeriesIndex(x: point.x, width: readerSize.width, pointCount: prices.count)
  410. indices.append(index)
  411. localPricesArray.append(prices[safely: index] ?? 0.0)
  412. }
  413.  
  414. // Second pass: format hover times (one dateFormat set)
  415. dateFormatter.dateFormat = getHoverDateFormat(timeChart: timeChart, dontShowTime: inFearGreedView)
  416. for index in indices {
  417. let date = Date(timeIntervalSince1970: (lowestTimeStamp[safely: index] ?? 0.0) / 1000)
  418. localTimes.append(dateFormatter.string(from: date))
  419. }
  420.  
  421. // Third pass: format temporary times (used to detect bucket changes)
  422. let timeSuffix = Date.uses24HourClock ? "HH:mm" : "hh:mm a"
  423. dateFormatter.dateFormat = "dd MMM 'at' \(timeSuffix)"
  424. for index in indices {
  425. let date = Date(timeIntervalSince1970: (lowestTimeStamp[safely: index] ?? 0.0) / 1000)
  426. temporaryTimes.append(dateFormatter.string(from: date))
  427. }
  428.  
  429. // Haptic "tick" whenever the hovered time bucket changes while dragging
  430. if tempHoverTimes != temporaryTimes && isDragging {
  431. hapticFeedback()
  432. }
  433.  
  434. tempHoverTimes = temporaryTimes
  435. hoverPrices = localPricesArray
  436. hoverTimes = localTimes
  437. }
  438.  
  439. func getHoverDateFormat(timeChart: TimeCharts?, dontShowTime: Bool = false) -> String {
  440. let timeSuffix = Date.uses24HourClock ? "HH:mm" : "hh:mm a"
  441.  
  442. switch timeChart {
  443. case .aWeek:
  444. return dontShowTime ? "MMM dd, YYYY" : "MMM dd 'at' \(timeSuffix)"
  445. case .aMonth, .threeMonths, .aYear:
  446. return "MMM dd, YYYY"
  447. case .max:
  448. return "MMM YYYY"
  449. default:
  450. return "MMM dd 'at' \(timeSuffix)"
  451. }
  452. }
  453.  
  454. private func hapticFeedback() {
  455. impactMed.impactOccurred()
  456. }
  457. }
  458.  
  459. // MARK: - Chart index <-> touch helpers
  460.  
  461. /// Index for line/area charts where point `i` is at `x = width * i / (pointCount - 1)`.
  462. func chartLinearSeriesIndex(x: CGFloat, width: CGFloat, pointCount: Int) -> Int {
  463. guard pointCount > 0, width > 0 else { return 0 }
  464. if pointCount == 1 { return 0 }
  465. let clampedX = min(max(x, 0), width)
  466. let maxIndex = pointCount
  467. return min(maxIndex, Int(Double(maxIndex) * Double(clampedX / width)))
  468. }
  469.  
  470.  
  471. ================================================================================
  472. 6. NewChartView.swift
  473. Draws the price line + filled area. The important part for the range touch:
  474. it builds gradients so the segment *between the two fingers* is drawn in the
  475. full color while everything outside it is dimmed. Uses dragPositions to know
  476. where the two ends are (as a fraction of the width).
  477. ================================================================================
  478.  
  479. import SwiftUI
  480.  
  481. struct NewChartView: View {
  482. let prices: [Double]
  483. let color: Color?
  484. var isGraphFilled = true
  485. var lineWidth: CGFloat = 1
  486. var dragPositions = [CGPoint]()
  487. var size: CGSize = .zero
  488. var isDragging: Bool = false
  489. var dropCount: Int = 0
  490. var isEmptyAtBottom = false
  491.  
  492. // Normalize prices into 0...1 (with -1 kept as a "gap" sentinel)
  493. private var realPrices: [Double] {
  494. guard !prices.isEmpty, let firstPrice = prices.first else { return [] }
  495. var allSame = true
  496. var minValue = Double.infinity
  497. var maxValue = -Double.infinity
  498.  
  499. for price in prices {
  500. if price != -1 {
  501. if price < minValue { minValue = price }
  502. if price > maxValue { maxValue = price }
  503. if price != firstPrice {
  504. allSame = false
  505. }
  506. }
  507. }
  508.  
  509. if allSame && minValue != Double.infinity {
  510. return isEmptyAtBottom ? [0, 0] : [0.5, 0.5]
  511. }
  512.  
  513. guard minValue != Double.infinity && maxValue != -Double.infinity else {
  514. return isEmptyAtBottom ? [0, 0] : [0.5, 0.5]
  515. }
  516.  
  517. let range = maxValue - minValue
  518. if range == 0 {
  519. return isEmptyAtBottom ? [0, 0] : [0.5, 0.5]
  520. }
  521.  
  522. let normalized = prices.map { price in
  523. price == -1 ? -1 : (price - minValue) / range
  524. }
  525.  
  526. return Array(normalized.dropLast(dropCount))
  527. }
  528.  
  529. var body: some View {
  530. let color = color ?? Color.onSurfaceDim
  531. let sizeWidth = max(1, size.width)
  532. let firstPosition = (dragPositions.first?.x ?? 0) / sizeWidth
  533. let lastPosition = (dragPositions.last?.x ?? 0) / sizeWidth
  534.  
  535. let singleLineGradient = Gradient(stops: [
  536. Gradient.Stop(color: color, location: 0),
  537. Gradient.Stop(color: color, location: 1)
  538. ])
  539.  
  540. let singleHoverLineGradient = Gradient(stops: [
  541. Gradient.Stop(color: color, location: -1),
  542. Gradient.Stop(color: color, location: min(max(firstPosition, 0), 1)),
  543. Gradient.Stop(color: color.opacity(0.15), location: min(max(lastPosition, 0), 1)),
  544. Gradient.Stop(color: color.opacity(0.15), location: 1.1)
  545. ])
  546.  
  547. // The two-finger case: dim outside [firstPosition, lastPosition], full color inside
  548. let multiLineGradient = Gradient(stops: [
  549. Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: -1),
  550. Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: min(max(firstPosition, 0), 1)),
  551. Gradient.Stop(color: color, location: min(max(firstPosition, 0), 1)),
  552. Gradient.Stop(color: color, location: min(max(lastPosition, 0), 1)),
  553. Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: min(max(lastPosition, 0), 1)),
  554. Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: 1)
  555. ])
  556.  
  557. let singleFillGradient = Gradient(stops: [
  558. Gradient.Stop(color: color, location: 0),
  559. Gradient.Stop(color: color, location: 1)
  560. ])
  561.  
  562. let multiFillGradient = Gradient(stops: [
  563. Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: -1),
  564. Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: min(max(firstPosition, 0), 1)),
  565. Gradient.Stop(color: color, location: min(max(firstPosition, 0), 1)),
  566. Gradient.Stop(color: color, location: min(max(lastPosition, 0), 1)),
  567. Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: min(max(lastPosition, 0), 1)),
  568. Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: 1)
  569. ])
  570.  
  571. let lineGradient: Gradient = {
  572. if isGraphFilled {
  573. if dragPositions.count == 2 && isDragging {
  574. return multiLineGradient
  575. } else if dragPositions.count == 1 && isDragging {
  576. return singleHoverLineGradient
  577. } else {
  578. return singleLineGradient
  579. }
  580. } else {
  581. return Gradient(colors: [color])
  582. }
  583. }()
  584.  
  585. let fillGradient = dragPositions.count == 2 && isDragging ? multiFillGradient : singleFillGradient
  586. let mask = LinearGradient(colors: [.gray.opacity(0.3), .gray.opacity(0.05)], startPoint: .top, endPoint: .bottom)
  587. let rp = realPrices
  588.  
  589. ZStack {
  590. ChartLineShape(prices: rp)
  591. .stroke(
  592. LinearGradient(gradient: lineGradient, startPoint: .leading, endPoint: .trailing),
  593. style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
  594. )
  595. .accessibilityHidden(true)
  596.  
  597. if isGraphFilled {
  598. ChartAreaShape(prices: rp)
  599. .fill(LinearGradient(gradient: fillGradient, startPoint: .leading, endPoint: .trailing))
  600. .mask { mask }
  601. .accessibilityHidden(true)
  602. }
  603. }
  604. .clipped()
  605. }
  606. }
  607.  
  608. private struct ChartLineShape: Shape {
  609. let prices: [Double]
  610.  
  611. func path(in rect: CGRect) -> Path {
  612. var path = Path()
  613. guard prices.count > 1 else { return path }
  614.  
  615. let step = rect.width / CGFloat(prices.count - 1)
  616. var needsMove = true
  617.  
  618. for (index, value) in prices.enumerated() {
  619. guard value != -1 else { needsMove = true; continue }
  620. let point = CGPoint(
  621. x: rect.minX + CGFloat(index) * step,
  622. y: rect.minY + (1 - value) * rect.height
  623. )
  624. if needsMove {
  625. path.move(to: point)
  626. needsMove = false
  627. } else {
  628. path.addLine(to: point)
  629. }
  630. }
  631.  
  632. return path
  633. }
  634. }
  635.  
  636. private struct ChartAreaShape: Shape {
  637. let prices: [Double]
  638.  
  639. func path(in rect: CGRect) -> Path {
  640. var path = Path()
  641. guard prices.count > 1 else { return path }
  642.  
  643. let step = rect.width / CGFloat(prices.count - 1)
  644. var needsMove = true
  645. var firstValidX: CGFloat?
  646. var lastValidX: CGFloat?
  647.  
  648. for (index, value) in prices.enumerated() {
  649. guard value != -1 else { needsMove = true; continue }
  650. let x = rect.minX + CGFloat(index) * step
  651. let y = rect.minY + (1 - value) * rect.height
  652. if needsMove {
  653. path.move(to: CGPoint(x: x, y: y))
  654. needsMove = false
  655. if firstValidX == nil { firstValidX = x }
  656. } else {
  657. path.addLine(to: CGPoint(x: x, y: y))
  658. }
  659. lastValidX = x
  660. }
  661.  
  662. if let lastX = lastValidX, let firstX = firstValidX {
  663. path.addLine(to: CGPoint(x: lastX, y: rect.maxY))
  664. path.addLine(to: CGPoint(x: firstX, y: rect.maxY))
  665. path.closeSubpath()
  666. }
  667.  
  668. return path
  669. }
  670. }
  671.  
  672.  
  673. ================================================================================
  674. 7. ChartHoverView.swift
  675. The overlay drawn on top of the line: one dashed vertical line + a dot for
  676. each finger (positions == dragPositions from the view model).
  677. ================================================================================
  678.  
  679. import SwiftUI
  680.  
  681. struct VerticalHoverLine: Shape {
  682. func path(in rect: CGRect) -> Path {
  683. var path = Path()
  684. path.move(to: CGPoint(x: 0, y: 0))
  685. path.addLine(to: CGPoint(x: 0, y: rect.height))
  686. return path
  687. }
  688. }
  689.  
  690. struct HorizontalHoverLine: Shape {
  691. func path(in rect: CGRect) -> Path {
  692. var path = Path()
  693. path.move(to: CGPoint(x: 0, y: 0))
  694. path.addLine(to: CGPoint(x: rect.width, y: 0))
  695. return path
  696. }
  697. }
  698.  
  699. struct ChartHoverView: View {
  700. let positions: [CGPoint]
  701. let size: CGSize
  702. let color: Color?
  703. var topPadding: CGFloat = 0
  704.  
  705. var extraSize: Bool = false
  706.  
  707. var body: some View {
  708. let color = color ?? Color.onSurfaceDim
  709.  
  710. ForEach(0..<positions.count, id: \.self) { index in
  711. let position = positions[index]
  712.  
  713. ZStack {
  714. VerticalHoverLine()
  715. .stroke(style: StrokeStyle(lineWidth: 2, dash: [5]))
  716. .foregroundStyle(Color.outline.opacity(0.5))
  717. .frame(width: 2)
  718. .position(x: position.x + 1.25, y: (size.height / 2))
  719.  
  720. Circle()
  721. .fill(color.opacity(0.25))
  722. .frame(width: 24, height: 24, alignment: .center)
  723. .overlay(
  724. Circle()
  725. .fill(color)
  726. .frame(width: 6, height: 6, alignment: .center)
  727. .zIndex(2)
  728. )
  729. .position(x: position.x + 0.25, y: position.y)
  730. .padding(.vertical, topPadding)
  731. }
  732. }
  733. .animation(.none, value: positions)
  734. .accessibilityHidden(true)
  735. }
  736. }
  737.  
  738.  
  739. ================================================================================
  740. TL;DR / flow
  741. ================================================================================
  742. finger(s) on chart
  743. -> MultiTouchGestureRecognizer.touchesBegan/Moved/Ended
  744. -> tappedCallback([CGPoint]) (up to 2 points, sorted left->right)
  745. -> viewModel.updateHoverStats(touches:)
  746. -> sets isDragging (after 0.2s), clamps offsets
  747. -> HoverHelper.getHoverPrice(...) -> hoverPrices / hoverTimes (one per finger)
  748. -> UI reads dragPositions (dots), graphColor (green/red for the range),
  749. hoverPrices/hoverTimes (labels)
  750.  
  751. Key gotcha: .delayTouches() is what makes the multi-touch gesture reliably win
  752. against the enclosing ScrollView. Without it the whole thing feels broken.
  753.  
Advertisement
Add Comment
Please, Sign In to add comment