From 727ef6e58f251fb0e85b9dbdcd30814c114372a5 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 11:57:32 +0100 Subject: [PATCH] banner: explicit opacity fade (transitions were unreliable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI's removal transition never actually played despite .id keying — the tip banner vanished instantly on dismiss. Replace it with explicit opacity/ offset driven by withAnimation in reflectBanner (gated by a .task(id: activeBanner) so presentation is reliable and prompt): a dismissal now fades out + slides up over 0.35s, and a tip→network swap crossfades. Uses the same state-driven pattern that fixed the HermesCall ticker fades. --- FuelBoard/ContentView.swift | 83 +++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 558fd04..a353e15 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -84,6 +84,16 @@ struct ContentView: View { /// network/offline strip (ContentView renders it above the tabs). @StateObject private var tipStore = TipStore() + // --- Explicit banner animation state (robust fade-out) --- + // SwiftUI's removal transition for the banner proved unreliable here + // (the tip faded to nothing only in the cross-nil case, and even then + // inconsistently). Drive opacity/offset explicitly instead so a + // dismissal ALWAYS fades + slides up on ANY banner change. + @State private var currentBanner: AppBanner? + @State private var bannerOpacity: Double = 0 + @State private var bannerOffset: CGFloat = 0 + @State private var bannerClearTask: Task? + /// The pool the list draws from. In Cheapest mode the chosen miles radius /// bounds it ("best price within X miles"); in Closest mode the radius is /// redundant — the whole country sorted nearest-first, because "nearest" @@ -485,26 +495,75 @@ struct ContentView: View { GeometryReader { geo in ZStack(alignment: .top) { rootTabView - if let banner = activeBanner { + if let banner = currentBanner { // Floating near the top of the screen, over the nav area — // overlays content (never pushes it) and sits above the // main content so it doesn't cover or block the list/pill - // beneath it. + // beneath it. Opacity/offset are driven explicitly by + // reflectBanner so the fade-out reliably animates. floatingBanner(banner) - // Key by value so ANY change (tip→network, tip→nil) - // runs the removal transition (fade/slide-out) instead - // of silently swapping content in place. - .id(banner) .padding(.top, geo.safeAreaInsets.top + 10) - .transition(.asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - // Slide up + fade on dismiss/timeout (one style for all). - removal: .move(edge: .top).combined(with: .opacity) - )) + .opacity(bannerOpacity) + .offset(y: bannerOffset) } } } - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: activeBanner) + .task(id: activeBanner) { + // Fires on launch with the current banner AND whenever it changes — + // unlike onChange(computed) which can miss the first non-nil value + // when dataStatus is set slightly after the view appears. + reflectBanner(activeBanner) + } + } + + /// Explicitly animates the banner in/out — a robust replacement for the + /// SwiftUI removal transition (which was silently not firing). Handles: + /// - first appearance → fade + settle down from slightly above + /// - any content change (tip→network, new tip) → fade the old out, then + /// fade the new in — so a tip ALWAYS visibly fades away + /// - dismissal → fade out + slide up, then clear after the fade + private func reflectBanner(_ newBanner: AppBanner?) { + guard newBanner != currentBanner else { return } + bannerClearTask?.cancel() + if let newBanner { + if currentBanner == nil { + present(newBanner) + } else { + // Crossfade: slide+fade the current out, then present the new. + withAnimation(.easeOut(duration: 0.2)) { + bannerOpacity = 0 + bannerOffset = -28 + } + bannerClearTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + present(newBanner) + } + } + } else { + withAnimation(.easeOut(duration: 0.35)) { + bannerOpacity = 0 + bannerOffset = -28 + } + bannerClearTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 350_000_000) + guard !Task.isCancelled else { return } + currentBanner = nil + bannerOpacity = 0 + bannerOffset = 0 + } + } + } + + /// Fade a banner in from slightly above and settle it into place. + private func present(_ banner: AppBanner) { + currentBanner = banner + bannerOpacity = 0 + bannerOffset = -16 + withAnimation(.easeOut(duration: 0.3)) { + bannerOpacity = 1 + bannerOffset = 0 + } } private var rootTabView: some View {