diff --git a/TalerCommon/HelperViews/LaunchAnimationView.swift b/TalerCommon/HelperViews/LaunchAnimationView.swift index cd79b87d..2b757afa 100644 --- a/TalerCommon/HelperViews/LaunchAnimationView.swift +++ b/TalerCommon/HelperViews/LaunchAnimationView.swift @@ -30,6 +30,23 @@ struct RotatingTaler: View { @Binding var rotationEnabled: Bool @State private var rotationDirection = false + /// False until the operation this indicator stands for has been running + /// long enough to be worth reporting. Most of the operations the wallet + /// puts a spinner on finish in tens of milliseconds, and a spinner that + /// brief is a flicker rather than feedback. + @State private var worthShowing = false + + /// How long an operation has to run before its indicator appears. Below + /// roughly a quarter of a second a progress indicator reads as a glitch; + /// above it, its absence reads 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 + + /// Only an indicator that reports an *operation* is delayed. `once` is + /// the launch animation, which reports nothing and must not be held back, + /// and `!progress` is the decorative logo. + private var reportsAnOperation: Bool { progress && !once } private let animationTimer = Timer .publish(every: 1.5, on: .current, in: .common) @@ -65,11 +82,25 @@ struct RotatingTaler: View { // image // .glassEffect(.clear) // } else { - image - .background { - Capsule() - .fill((background ?? Color(.systemGray6)).opacity(0.3)) - } + if reportsAnOperation && !worthShowing { + // Absent, not merely hidden: an opacity-0 or + // accessibility-hidden view is still in the accessibility + // snapshot, so hiding it would leave the indicator reported + // to anything reading the hierarchy. Keep its space so that + // nothing jumps when it does appear. + Color.clear + .frame(width: size + 20, height: size + 20) + .task { + try? await Task.sleep(nanoseconds: Self.appearAfterNs) + withAnimation { worthShowing = true } + } + } else { + image + .background { + Capsule() + .fill((background ?? Color(.systemGray6)).opacity(0.3)) + } + } // } } } 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)