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..adeb7cfb 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) { @@ -152,33 +164,61 @@ struct LoadingView: View { rotationEnabled: $rotationEnabled) .id("rotatingTaler") let view = VStack(alignment: .center) { - Spacer() - rotatingTaler - Spacer() - if controller.stalledConnection { - StalledView() - } else { - if let scopeInfo { - if let urlStr = scopeInfo.url { - Text(urlStr.trimURL) - .talerFont(.title2) - } else { - Text(scopeInfo.currency) - .talerFont(.title2) + // Gate the contents, not the root. Swapping what this body + // returns at the gate boundary tears down the in-flight task the + // call sites attach to the LoadingView value: measured with the + // gate lowered to 50 ms so it always expires mid-operation, the + // root-swapping version cancelled prepareWithdrawExchange with + // 7036 after 55-63 ms in 5 of 5 runs and never reached the terms + // of service, while this version cancelled nothing in 5 of 5 and + // reached it every time. The root has to keep its identity. + if worthShowing { + Spacer() + rotatingTaler + Spacer() + if controller.stalledConnection { + StalledView() + } else { + if let scopeInfo { + if let urlStr = scopeInfo.url { + Text(urlStr.trimURL) + .talerFont(.title2) + } else { + Text(scopeInfo.currency) + .talerFont(.title2) + } + Spacer() } + Text(message ?? EMPTYSTRING) + .talerFont(.title2) Spacer() } - Text(message ?? EMPTYSTRING) - .talerFont(.title2) - Spacer() + dismissButton + } else { + // Same footprint, and nothing in the accessibility tree: + // rotatingTaler is never built, so there is no "In progress" + // label for anything reading the hierarchy to find. + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) } - dismissButton } .frame(maxWidth: .infinity) .padding(.horizontal) .talerFont(.title) - .navigationTitle(navTitle) + // The root stays in the hierarchy -- see the comment on the gate + // above -- so the call sites' own .task/.onAppear work keeps running + // and nothing about the navigation changes. The opacity and + // accessibilityHidden are belt and braces: neither actually removes a + // view from the accessibility tree, which is why the contents are + // gated structurally rather than merely hidden. + .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)