Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- ================================================================================
- 2-FINGER RANGE TOUCH ON A SWIFTUI CHART
- ================================================================================
- How I let users put two fingers on a price chart and get the % change / price
- difference between those two points (with a moving hover overlay + haptics).
- The trick: SwiftUI's built-in gestures don't give you clean access to multiple
- simultaneous touch locations, so I drop down to a UIKit UIGestureRecognizer via
- UIViewRepresentable (MultiTouchView). That recognizer reports every touch
- location back up to SwiftUI. A small HoverHelper turns those touch X positions
- into chart indices -> prices -> times. A .delayTouches() modifier stops the
- parent ScrollView from stealing the gesture.
- The pieces:
- 1. MultiTouchView - UIKit gesture recognizer that reports touch points
- 2. .delayTouches() - keeps the ScrollView from hijacking the touches
- 3. mainGraph + overlay - where it's all wired together in the chart view
- 4. HoverHelper - maps touch X -> chart index -> price/time (+ haptics)
- 5. NewChartView - draws the line + dims everything outside the range
- 6. ChartHoverView - the dashed line + dot drawn under each finger
- Everything below is real code from my app (CryptoCurrently).
- ================================================================================
- 1. MultiTouchView.swift
- The core: a UIGestureRecognizer that tracks every touch and reports the (up
- to 2) left-to-right sorted touch points back to SwiftUI on every change.
- ================================================================================
- import SwiftUI
- struct MultiTouchView: UIViewRepresentable {
- var tappedCallback: (([CGPoint]) -> Void)
- func makeUIView(context: UIViewRepresentableContext<MultiTouchView>) -> MultiTouchView.UIViewType {
- let v = UIView(frame: .zero)
- v.isAccessibilityElement = false
- v.accessibilityElementsHidden = true
- let gesture = MultiTouchGestureRecognizer(target: context.coordinator, tappedCallback: tappedCallback)
- v.addGestureRecognizer(gesture)
- return v
- }
- func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<MultiTouchView>) { }
- static func dismantleUIView(_ uiView: UIView, coordinator: ()) {
- uiView.isAccessibilityElement = false
- uiView.accessibilityElementsHidden = true
- }
- }
- public class MultiTouchGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
- private var trackedTouches = [UITouch: CGPoint]()
- var tappedCallback: ([CGPoint]) -> Void
- init(target: Any?, tappedCallback: @escaping ([CGPoint]) -> ()) {
- self.tappedCallback = tappedCallback
- super.init(target: target, action: nil)
- self.delegate = self
- }
- public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
- state = .began
- for touch in touches {
- trackedTouches[touch] = touch.location(in: view)
- }
- tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
- }
- public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
- state = .changed
- for touch in touches {
- trackedTouches[touch] = touch.location(in: view)
- }
- tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
- }
- public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
- for touch in touches {
- trackedTouches[touch] = nil
- }
- if trackedTouches.count == 0 {
- state = .ended
- }
- tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
- }
- public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
- state = .cancelled
- for touch in touches {
- trackedTouches[touch] = nil
- }
- tappedCallback(Array(trackedTouches.map({ $0.value }).sorted(by: { $0.x < $1.x }).prefix(2)))
- }
- public override func reset() {
- super.reset()
- self.trackedTouches = [:]
- }
- // Allow this to run alongside SwiftUI's own gestures (scroll etc.)
- public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
- return true
- }
- }
- ================================================================================
- 2. .delayTouches() (from View+Extension.swift)
- Without this, the parent ScrollView grabs the touches and the chart gesture
- never fires reliably. Wrapping the chart in a Button + highPriorityGesture
- delays the touch delivery just enough that the multi-touch recognizer wins.
- (Disabled under VoiceOver so accessibility still works.)
- ================================================================================
- struct NoButtonStyle: ButtonStyle {
- func makeBody(configuration: Configuration) -> some View {
- configuration.label
- }
- }
- private struct DelayTouchesModifier: ViewModifier {
- @Environment(\.accessibilityVoiceOverEnabled) private var accessibilityVoiceOverEnabled
- func body(content: Content) -> some View {
- if accessibilityVoiceOverEnabled {
- content
- } else {
- Button(action: {}) {
- content
- .highPriorityGesture(TapGesture())
- }
- .buttonStyle(NoButtonStyle())
- }
- }
- }
- extension View {
- func delayTouches() -> some View {
- modifier(DelayTouchesModifier())
- }
- }
- ================================================================================
- 3. Wiring it up in the chart (from CryptoDetailScreen.swift)
- The MultiTouchView sits on top of the drawn chart (mainGraph) inside a ZStack.
- .delayTouches() is applied to the whole ZStack. Every touch update calls
- viewModel.updateHoverStats(touches:).
- ================================================================================
- ZStack {
- ChartBackground(
- biggestValue: $biggestChartValue,
- values: viewModel.mainGraphStats
- )
- if viewModel.mainReloading {
- ActivityIndicator()
- .padding(.trailing, biggestChartValue.width)
- .accessibilityHidden(true)
- } else {
- mainGraph(halfBiggestChartValueHeight: halfBiggestChartValueHeight)
- .opacity(viewModel.showMainPriceError ? 0 : 1)
- .frame(height: max(1, reader.size.height * 0.35))
- // <-- the multi-touch layer sits on top of the drawn chart
- MultiTouchView { touches in
- viewModel.updateHoverStats(touches: touches)
- }
- .accessibilityHidden(true)
- }
- }
- .animation(.none, value: viewModel.isDragging)
- .delayTouches() // <-- stops the ScrollView stealing the gesture
- .frame(height: max(1, reader.size.height * 0.35))
- .padding(.horizontal, 16)
- // mainGraph: draws the line + the hover overlay (dots/lines under each finger)
- private func mainGraph(halfBiggestChartValueHeight: CGFloat) -> some View {
- NewChartView(
- prices: viewModel.getPrices(),
- color: viewModel.graphColor,
- lineWidth: 2,
- dragPositions: viewModel.dragPositions, // <-- one point per finger
- size: viewModel.chartSize,
- isDragging: viewModel.isDragging
- )
- .padding(.leading, (viewModel.timeStamps.normalized.first ?? 0) * viewModel.chartSize.width)
- .readSize {
- viewModel.chartSize = $0
- }
- .padding(
- EdgeInsets(
- top: halfBiggestChartValueHeight,
- leading: 0,
- bottom: halfBiggestChartValueHeight,
- trailing: biggestChartValue.width
- )
- )
- ChartHoverView(
- positions: viewModel.dragPositions,
- size: viewModel.chartSize,
- color: viewModel.graphColor,
- topPadding: halfBiggestChartValueHeight
- )
- .opacity(viewModel.isDragging ? 1 : 0)
- .frame(height: viewModel.chartSize.height + halfBiggestChartValueHeight * 2)
- }
- ================================================================================
- 4. View model glue (from CryptoDetailViewModel.swift)
- Turns the raw touch points into: isDragging state, clamped offsets,
- drag positions (dots on the line), the line color (green/red based on the
- range) and kicks off the price/time lookup in HoverHelper.
- ================================================================================
- // Called on every touch update from MultiTouchView
- func updateHoverStats(touches: [CGPoint]) {
- guard !mainReloading else { return }
- guard !timeStamps.isEmpty && !touches.isEmpty else {
- isDragging = false
- timer?.invalidate()
- return
- }
- // Small delay before we consider it a real "drag" (avoids accidental taps)
- if !isDragging {
- timer?.invalidate()
- timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in
- Task { @MainActor in
- guard let self = self else { return }
- self.isDragging = !touches.isEmpty
- if self.isDragging {
- UIImpactFeedbackGenerator(style: .light).impactOccurred()
- }
- }
- }
- }
- hoverHelper.offset = touches.map {
- CGPoint(x: max(0, min($0.x, chartSize.width.rounded(.down) - 0.00001)), y: $0.y)
- }
- hoverHelper.getHoverPrice(
- readerSize: chartSize,
- offset: hoverHelper.offset,
- prices: getPrices(),
- dates: getTimestamps(),
- timeChart: selectedTimeFrame,
- isDragging: isDragging
- )
- }
- // Color of the line: green/red depending on the change *between the 2 fingers*
- var graphColor: Color {
- guard hoverHelper.hoverPrices.count == 2, isDragging else {
- return changePercent?.changeColor ?? .onSurfaceDim
- }
- let first = hoverHelper.hoverPrices[safely: 0] ?? 0
- let second = hoverHelper.hoverPrices[safely: 1] ?? 0
- let change = (second - first).roundTo(places: 7)
- if change == 0 {
- let percent = Double.getDifferenceInPercent(between: second, and: first)?.roundTo(places: 7)
- return percent?.changeColor ?? .onSurfaceDim
- }
- return change.changeColor
- }
- // One chart index per finger (used to draw the dots on the line)
- var mainDragIndex: [Int] {
- let timestamps = getTimestamps()
- let pricesCount = CGFloat(getPrices().count)
- let firstTimestamp = timeStamps.normalized.first ?? 0
- let timestampCount = Double(timestamps.count)
- return hoverHelper.offset.map { offsetPoint in
- let offsetRatio = offsetPoint.x / chartSize.width
- guard offsetRatio >= firstTimestamp else { return -1 }
- let indexOffset = timestampCount * firstTimestamp
- let maxIndex = max(0, Int(pricesCount))
- let spatialIndex = Double(maxIndex) * Double(offsetPoint.x / chartSize.width)
- let index = spatialIndex - indexOffset
- if index.isNaN || index.isInfinite {
- return -1
- }
- return Int(index)
- }
- }
- // Screen position for each finger's point on the line
- var dragPositions: [CGPoint] {
- return mainDragIndex.map({
- point(at: $0, rect: CGSize(width: chartSize.width, height: chartSize.height))
- })
- }
- func point(at index: Int, rect: CGSize) -> CGPoint {
- return calculatePoint(
- at: index,
- rect: rect,
- timestamps: getTimestamps(),
- prices: getPrices()
- )
- }
- private func calculatePoint(
- at index: Int,
- rect: CGSize,
- timestamps: [Double],
- prices: [Double],
- adjustXByHalfPixel: Bool = true
- ) -> CGPoint {
- let normalizedPrices = prices.normalized
- let pointValue = normalizedPrices[safely: index] ?? 0
- let denominator = Double(normalizedPrices.count - 1)
- let x = rect.width * Double(index) / denominator
- let y = (1 - pointValue) * rect.height
- return CGPoint(
- x: adjustXByHalfPixel ? x - 0.5 : x,
- y: y.isNaN ? 0.5 * rect.height : y
- )
- }
- ================================================================================
- 5. HoverHelper.swift
- The @Observable state object that maps each touch X position to a chart
- index, then to a price + formatted time. Fires a haptic every time the
- hovered time bucket changes so scrubbing "ticks". hoverPrices ends up with
- up to 2 values -> the two ends of the range.
- ================================================================================
- import Foundation
- import SwiftUI
- @Observable @MainActor
- class HoverHelper {
- let impactMed = UIImpactFeedbackGenerator(style: .light)
- var hoverPrices = [Double]()
- var SMAHoverPrices = [Double]()
- var hoverTimes = [String]()
- var offset = [CGPoint]()
- var dateFormatter: DateFormatter = DateFormatter()
- var tempHoverTimes = [String]()
- var compareHoverPrice = 0.0
- var latestPositions = [Int]()
- var latestComparePosition = 0
- init() {
- dateFormatter.locale = Locale(identifier: "en_US_POSIX")
- dateFormatter.amSymbol = "AM"
- dateFormatter.pmSymbol = "PM"
- }
- func resetValues() {
- offset = []
- hoverPrices = []
- hoverTimes = []
- }
- // The main one for line/area charts: takes the (up to 2) touch offsets and
- // fills hoverPrices + hoverTimes for each finger.
- func getHoverPrice(readerSize: CGSize, offset: [CGPoint], prices: [Double], dates: [Double], timeChart: TimeCharts?, compareDates: [Double]? = nil, comparePrices: [Double]? = nil, inFearGreedView: Bool = false, isDragging: Bool) {
- // Pre-compute loop-invariant values
- let mainMinTime = dates.lazy.filter { $0 != -1 }.min() ?? .infinity
- let compareMinTime = compareDates?.lazy.filter { $0 != -1 }.min() ?? .infinity
- let lowestTimeStamp = mainMinTime < compareMinTime ? dates : (compareDates ?? [])
- let offsetCount = offset.count
- var indices = [Int]()
- var localPricesArray = [Double]()
- var localTimes = [String]()
- var temporaryTimes = [String]()
- indices.reserveCapacity(offsetCount)
- localPricesArray.reserveCapacity(offsetCount)
- localTimes.reserveCapacity(offsetCount)
- temporaryTimes.reserveCapacity(offsetCount)
- // First pass: compute indices and prices (x = width * i / (n - 1))
- for point in offset {
- let index = chartLinearSeriesIndex(x: point.x, width: readerSize.width, pointCount: prices.count)
- indices.append(index)
- localPricesArray.append(prices[safely: index] ?? 0.0)
- }
- // Second pass: format hover times (one dateFormat set)
- dateFormatter.dateFormat = getHoverDateFormat(timeChart: timeChart, dontShowTime: inFearGreedView)
- for index in indices {
- let date = Date(timeIntervalSince1970: (lowestTimeStamp[safely: index] ?? 0.0) / 1000)
- localTimes.append(dateFormatter.string(from: date))
- }
- // Third pass: format temporary times (used to detect bucket changes)
- let timeSuffix = Date.uses24HourClock ? "HH:mm" : "hh:mm a"
- dateFormatter.dateFormat = "dd MMM 'at' \(timeSuffix)"
- for index in indices {
- let date = Date(timeIntervalSince1970: (lowestTimeStamp[safely: index] ?? 0.0) / 1000)
- temporaryTimes.append(dateFormatter.string(from: date))
- }
- // Haptic "tick" whenever the hovered time bucket changes while dragging
- if tempHoverTimes != temporaryTimes && isDragging {
- hapticFeedback()
- }
- tempHoverTimes = temporaryTimes
- hoverPrices = localPricesArray
- hoverTimes = localTimes
- }
- func getHoverDateFormat(timeChart: TimeCharts?, dontShowTime: Bool = false) -> String {
- let timeSuffix = Date.uses24HourClock ? "HH:mm" : "hh:mm a"
- switch timeChart {
- case .aWeek:
- return dontShowTime ? "MMM dd, YYYY" : "MMM dd 'at' \(timeSuffix)"
- case .aMonth, .threeMonths, .aYear:
- return "MMM dd, YYYY"
- case .max:
- return "MMM YYYY"
- default:
- return "MMM dd 'at' \(timeSuffix)"
- }
- }
- private func hapticFeedback() {
- impactMed.impactOccurred()
- }
- }
- // MARK: - Chart index <-> touch helpers
- /// Index for line/area charts where point `i` is at `x = width * i / (pointCount - 1)`.
- func chartLinearSeriesIndex(x: CGFloat, width: CGFloat, pointCount: Int) -> Int {
- guard pointCount > 0, width > 0 else { return 0 }
- if pointCount == 1 { return 0 }
- let clampedX = min(max(x, 0), width)
- let maxIndex = pointCount
- return min(maxIndex, Int(Double(maxIndex) * Double(clampedX / width)))
- }
- ================================================================================
- 6. NewChartView.swift
- Draws the price line + filled area. The important part for the range touch:
- it builds gradients so the segment *between the two fingers* is drawn in the
- full color while everything outside it is dimmed. Uses dragPositions to know
- where the two ends are (as a fraction of the width).
- ================================================================================
- import SwiftUI
- struct NewChartView: View {
- let prices: [Double]
- let color: Color?
- var isGraphFilled = true
- var lineWidth: CGFloat = 1
- var dragPositions = [CGPoint]()
- var size: CGSize = .zero
- var isDragging: Bool = false
- var dropCount: Int = 0
- var isEmptyAtBottom = false
- // Normalize prices into 0...1 (with -1 kept as a "gap" sentinel)
- private var realPrices: [Double] {
- guard !prices.isEmpty, let firstPrice = prices.first else { return [] }
- var allSame = true
- var minValue = Double.infinity
- var maxValue = -Double.infinity
- for price in prices {
- if price != -1 {
- if price < minValue { minValue = price }
- if price > maxValue { maxValue = price }
- if price != firstPrice {
- allSame = false
- }
- }
- }
- if allSame && minValue != Double.infinity {
- return isEmptyAtBottom ? [0, 0] : [0.5, 0.5]
- }
- guard minValue != Double.infinity && maxValue != -Double.infinity else {
- return isEmptyAtBottom ? [0, 0] : [0.5, 0.5]
- }
- let range = maxValue - minValue
- if range == 0 {
- return isEmptyAtBottom ? [0, 0] : [0.5, 0.5]
- }
- let normalized = prices.map { price in
- price == -1 ? -1 : (price - minValue) / range
- }
- return Array(normalized.dropLast(dropCount))
- }
- var body: some View {
- let color = color ?? Color.onSurfaceDim
- let sizeWidth = max(1, size.width)
- let firstPosition = (dragPositions.first?.x ?? 0) / sizeWidth
- let lastPosition = (dragPositions.last?.x ?? 0) / sizeWidth
- let singleLineGradient = Gradient(stops: [
- Gradient.Stop(color: color, location: 0),
- Gradient.Stop(color: color, location: 1)
- ])
- let singleHoverLineGradient = Gradient(stops: [
- Gradient.Stop(color: color, location: -1),
- Gradient.Stop(color: color, location: min(max(firstPosition, 0), 1)),
- Gradient.Stop(color: color.opacity(0.15), location: min(max(lastPosition, 0), 1)),
- Gradient.Stop(color: color.opacity(0.15), location: 1.1)
- ])
- // The two-finger case: dim outside [firstPosition, lastPosition], full color inside
- let multiLineGradient = Gradient(stops: [
- Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: -1),
- Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: min(max(firstPosition, 0), 1)),
- Gradient.Stop(color: color, location: min(max(firstPosition, 0), 1)),
- Gradient.Stop(color: color, location: min(max(lastPosition, 0), 1)),
- Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: min(max(lastPosition, 0), 1)),
- Gradient.Stop(color: .onSurfaceDim.opacity(0.2), location: 1)
- ])
- let singleFillGradient = Gradient(stops: [
- Gradient.Stop(color: color, location: 0),
- Gradient.Stop(color: color, location: 1)
- ])
- let multiFillGradient = Gradient(stops: [
- Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: -1),
- Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: min(max(firstPosition, 0), 1)),
- Gradient.Stop(color: color, location: min(max(firstPosition, 0), 1)),
- Gradient.Stop(color: color, location: min(max(lastPosition, 0), 1)),
- Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: min(max(lastPosition, 0), 1)),
- Gradient.Stop(color: .onSurfaceDim.opacity(0.25), location: 1)
- ])
- let lineGradient: Gradient = {
- if isGraphFilled {
- if dragPositions.count == 2 && isDragging {
- return multiLineGradient
- } else if dragPositions.count == 1 && isDragging {
- return singleHoverLineGradient
- } else {
- return singleLineGradient
- }
- } else {
- return Gradient(colors: [color])
- }
- }()
- let fillGradient = dragPositions.count == 2 && isDragging ? multiFillGradient : singleFillGradient
- let mask = LinearGradient(colors: [.gray.opacity(0.3), .gray.opacity(0.05)], startPoint: .top, endPoint: .bottom)
- let rp = realPrices
- ZStack {
- ChartLineShape(prices: rp)
- .stroke(
- LinearGradient(gradient: lineGradient, startPoint: .leading, endPoint: .trailing),
- style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
- )
- .accessibilityHidden(true)
- if isGraphFilled {
- ChartAreaShape(prices: rp)
- .fill(LinearGradient(gradient: fillGradient, startPoint: .leading, endPoint: .trailing))
- .mask { mask }
- .accessibilityHidden(true)
- }
- }
- .clipped()
- }
- }
- private struct ChartLineShape: Shape {
- let prices: [Double]
- func path(in rect: CGRect) -> Path {
- var path = Path()
- guard prices.count > 1 else { return path }
- let step = rect.width / CGFloat(prices.count - 1)
- var needsMove = true
- for (index, value) in prices.enumerated() {
- guard value != -1 else { needsMove = true; continue }
- let point = CGPoint(
- x: rect.minX + CGFloat(index) * step,
- y: rect.minY + (1 - value) * rect.height
- )
- if needsMove {
- path.move(to: point)
- needsMove = false
- } else {
- path.addLine(to: point)
- }
- }
- return path
- }
- }
- private struct ChartAreaShape: Shape {
- let prices: [Double]
- func path(in rect: CGRect) -> Path {
- var path = Path()
- guard prices.count > 1 else { return path }
- let step = rect.width / CGFloat(prices.count - 1)
- var needsMove = true
- var firstValidX: CGFloat?
- var lastValidX: CGFloat?
- for (index, value) in prices.enumerated() {
- guard value != -1 else { needsMove = true; continue }
- let x = rect.minX + CGFloat(index) * step
- let y = rect.minY + (1 - value) * rect.height
- if needsMove {
- path.move(to: CGPoint(x: x, y: y))
- needsMove = false
- if firstValidX == nil { firstValidX = x }
- } else {
- path.addLine(to: CGPoint(x: x, y: y))
- }
- lastValidX = x
- }
- if let lastX = lastValidX, let firstX = firstValidX {
- path.addLine(to: CGPoint(x: lastX, y: rect.maxY))
- path.addLine(to: CGPoint(x: firstX, y: rect.maxY))
- path.closeSubpath()
- }
- return path
- }
- }
- ================================================================================
- 7. ChartHoverView.swift
- The overlay drawn on top of the line: one dashed vertical line + a dot for
- each finger (positions == dragPositions from the view model).
- ================================================================================
- import SwiftUI
- struct VerticalHoverLine: Shape {
- func path(in rect: CGRect) -> Path {
- var path = Path()
- path.move(to: CGPoint(x: 0, y: 0))
- path.addLine(to: CGPoint(x: 0, y: rect.height))
- return path
- }
- }
- struct HorizontalHoverLine: Shape {
- func path(in rect: CGRect) -> Path {
- var path = Path()
- path.move(to: CGPoint(x: 0, y: 0))
- path.addLine(to: CGPoint(x: rect.width, y: 0))
- return path
- }
- }
- struct ChartHoverView: View {
- let positions: [CGPoint]
- let size: CGSize
- let color: Color?
- var topPadding: CGFloat = 0
- var extraSize: Bool = false
- var body: some View {
- let color = color ?? Color.onSurfaceDim
- ForEach(0..<positions.count, id: \.self) { index in
- let position = positions[index]
- ZStack {
- VerticalHoverLine()
- .stroke(style: StrokeStyle(lineWidth: 2, dash: [5]))
- .foregroundStyle(Color.outline.opacity(0.5))
- .frame(width: 2)
- .position(x: position.x + 1.25, y: (size.height / 2))
- Circle()
- .fill(color.opacity(0.25))
- .frame(width: 24, height: 24, alignment: .center)
- .overlay(
- Circle()
- .fill(color)
- .frame(width: 6, height: 6, alignment: .center)
- .zIndex(2)
- )
- .position(x: position.x + 0.25, y: position.y)
- .padding(.vertical, topPadding)
- }
- }
- .animation(.none, value: positions)
- .accessibilityHidden(true)
- }
- }
- ================================================================================
- TL;DR / flow
- ================================================================================
- finger(s) on chart
- -> MultiTouchGestureRecognizer.touchesBegan/Moved/Ended
- -> tappedCallback([CGPoint]) (up to 2 points, sorted left->right)
- -> viewModel.updateHoverStats(touches:)
- -> sets isDragging (after 0.2s), clamps offsets
- -> HoverHelper.getHoverPrice(...) -> hoverPrices / hoverTimes (one per finger)
- -> UI reads dragPositions (dots), graphColor (green/red for the range),
- hoverPrices/hoverTimes (labels)
- Key gotcha: .delayTouches() is what makes the multi-touch gesture reliably win
- against the enclosing ScrollView. Without it the whole thing feels broken.
Advertisement
Add Comment
Please, Sign In to add comment