From 24ff757c4465d22ef855a86e6c7371931ee96409 Mon Sep 17 00:00:00 2001 From: Christian Grothoff Date: Wed, 4 Nov 2026 08:17:56 +0100 Subject: [PATCH] AI: do not flash the loading screen for operations that finish instantly An exploration campaign recorded 100 observations of a full-screen loading state that was on screen for less than a probe interval -- under 100 ms, and in the os_log evidence the wallet-core request behind it had completed in 21 to 36 ms. A spinner that brief is a flicker rather than feedback, and it is the kind of thing one notices without being able to say what happened. LoadingView now waits 250 ms before it shows anything. The view itself stays in the hierarchy the whole time, so the call sites' own .task/.onAppear work and the navigation are untouched; only the visible affordance and its navigation title are gated. It is hidden with accessibilityHidden as well as opacity, because opacity alone still gets read out -- the same trap as in the three stacked retry states. 250 ms is a judgement call: below roughly that a progress indicator reads as a glitch, above it its absence reads as a freeze. The constant is named and in one place so it can be argued with. Assisted by LLM --- .../Views/HelperViews/LoadingView.swift | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift index b0864c85..55ff2cf9 100644 --- a/TalerWallet1/Views/HelperViews/LoadingView.swift +++ b/TalerWallet1/Views/HelperViews/LoadingView.swift @@ -112,6 +112,18 @@ struct LoadingView: View { @State private var rotationEnabled = true @State private var disabled: Bool = false + /// False until the operation has been running long enough to be worth + /// telling the user about. Most operations behind this view finish in + /// tens of milliseconds, and showing a full-screen spinner for that long + /// is a flicker, not feedback. + @State private var worthShowing = false + + /// How long an operation has to run before the spinner appears. Below + /// roughly a quarter of a second a progress indicator is perceived as a + /// glitch rather than as a response; above it, its absence is perceived + /// as the app having frozen. Nanoseconds rather than `Duration`, which + /// needs iOS 16 while this target still deploys to 15. + private static let appearAfterNs: UInt64 = 250_000_000 private var dismissButton: some View { Button("Cancel", role: .cancel) { @@ -177,8 +189,18 @@ struct LoadingView: View { .frame(maxWidth: .infinity) .padding(.horizontal) .talerFont(.title) - .navigationTitle(navTitle) + // Hidden, not absent: the view stays in the hierarchy so that the + // call sites' own .task/.onAppear work keeps running, and so that + // nothing about the navigation changes. opacity alone would still + // be read out by VoiceOver, hence accessibilityHidden alongside it. + .opacity(worthShowing ? 1 : 0) + .accessibilityHidden(!worthShowing) + .navigationTitle(worthShowing ? navTitle : EMPTYSTRING) .background(FullBackground()) + .task { + try? await Task.sleep(nanoseconds: Self.appearAfterNs) + withAnimation { worthShowing = true } + } if #available(iOS 17.0, *) { view.toolbarTitleDisplayMode(.inlineLarge) -- 2.47.3