From c964110f8a36e878479db24711b414f5b7b4ec82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Jul 2026 18:12:14 +0200 Subject: [PATCH] Fix main graph locking up after system-cancelled gestures When the system cancels a touch (permission alert, incoming call, app switch) the chart gestures never get their onEnded call, so pinch or inspect state stays latched and blocks all panning and zooming until the app is restarted. Clear leaked gesture state when a new touch begins, reset it when the chart disappears, and rebuild the gesture attachments on foregrounding while keeping the current viewport. --- LoopFollow/Charts/BGChartModel.swift | 4 ++ LoopFollow/Charts/BGChartView.swift | 77 +++++++++++++++++++++++----- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index f4f697a2f..9b451ac6a 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -17,6 +17,10 @@ final class BGChartInteraction: ObservableObject { /// when the user pans back into history, re-armed when they return to the edge. @Published var followLatest: Bool = true + /// Set once the main chart has done its initial scroll-to-now; kept out of + /// view @State so a chart remount (see BGChartView) keeps the user's place. + var hasInitializedViewport = false + init() { let seconds = Self.visibleSeconds(forScale: Storage.shared.chartScaleX.value) visibleSeconds = seconds diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index e7aa43b3c..9098bd123 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -74,11 +74,22 @@ struct BGChartView: View { let model: BGChartModel let config: Config + /// Remount key. A system-cancelled touch can wedge SwiftUI's gesture graph + /// for this subtree; bumping this on foregrounding rebuilds the gesture + /// attachments while BGChartInteraction preserves the viewport. + @State private var gestureMountEpoch = 0 + var body: some View { - if config == .small { - SmallBGChart(model: model, interaction: model.interaction) - } else { - MainBGChart(model: model, interaction: model.interaction) + Group { + if config == .small { + SmallBGChart(model: model, interaction: model.interaction) + } else { + MainBGChart(model: model, interaction: model.interaction) + } + } + .id(gestureMountEpoch) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + gestureMountEpoch &+= 1 } } } @@ -102,13 +113,21 @@ private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction - @State private var didInitialize = false - /// Rendered slice of the domain. The canvas covers only this window /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width /// and per-layout cost no matter how long the data domain grows. - @State private var renderWindowStart = Date() - @State private var renderWindowEnd = Date().addingTimeInterval(3600) + @State private var renderWindowStart: Date + @State private var renderWindowEnd: Date + + init(model: BGChartModel, interaction: BGChartInteraction) { + _model = ObservedObject(wrappedValue: model) + _interaction = ObservedObject(wrappedValue: interaction) + // Seed the render window around the current viewport so a remount's + // first frame draws in place. + let pad = BGChartConfig.renderWindowPadFactor * interaction.visibleSeconds + _renderWindowStart = State(initialValue: interaction.scrollPosition.addingTimeInterval(-pad)) + _renderWindowEnd = State(initialValue: interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds + pad)) + } /// Plot area of the static axis overlay, in shell coordinates. The /// selection overlay uses it for its value-to-pixel maps. @@ -265,15 +284,19 @@ private struct MainBGChart: View { updateRenderWindow() } .onAppear { - if !didInitialize { - didInitialize = true + if !interaction.hasInitializedViewport { + interaction.hasInitializedViewport = true scrollToNow(animated: false) - updateRenderWindow(force: true) + } else if !interaction.followLatest { + // Remounted while in history: re-arm the auto-return pause. + autoFollowPausedUntil = Date().addingTimeInterval(BGChartConfig.autoFollowPause) } + updateRenderWindow(force: true) } .onDisappear { momentumTask?.cancel() - inspectHoldTask?.cancel() + momentumTask = nil + resetGestureState() } } @@ -394,6 +417,13 @@ private struct MainBGChart: View { .onChanged { value in momentumTask?.cancel() momentumTask = nil + // A touch's first event has zero translation and precedes any + // pinch, so gesture state still set here was leaked by a + // system-cancelled touch (its onEnded never fired) and would + // swallow this and every later touch. + if value.translation == .zero, hasLeakedGestureState { + resetGestureState() + } guard !isPinching else { inspectHoldTask?.cancel() if selection != nil { selection = nil } @@ -454,6 +484,24 @@ private struct MainBGChart: View { } } + private var hasLeakedGestureState: Bool { + pinchAnchor != nil || pinchScale != 1 || isInspectLatched + || touchDownTime != nil || panBaseline != nil || selection != nil + } + + private func resetGestureState() { + inspectHoldTask?.cancel() + inspectHoldTask = nil + pinchAnchor = nil + pinchScale = 1 + isInspectLatched = false + touchDownTime = nil + panBaseline = nil + selection = nil + lastTouchLocation = nil + lastHapticAnchorDate = nil + } + /// Arms the inspect hold: after `inspectHoldDelay`, if the touch is still /// down and has neither become a pan nor a pinch, latch into inspect mode /// at the finger's last known position — with a haptic so the mode change is felt. @@ -936,6 +984,11 @@ private struct SmallBGChart: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in + // Zero translation = fresh touch; clear a scrub flag + // leaked by a system-cancelled touch. + if value.translation == .zero { + isScrubbing = false + } let distance = hypot(value.translation.width, value.translation.height) if isScrubbing || distance >= BGChartConfig.inspectMovementTolerance { isScrubbing = true