Author SHA1 Message Date
FuelBoard Contributor 727ef6e58f banner: explicit opacity fade (transitions were unreliable)
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.
2026-08-19 11:57:32 +01:00
FuelBoard Contributor 4c222cdf55 banner: key by value so tip fades out on dismiss
The banner sits in a single if-let slot; without an identity key, clearing a
tip that was covering a network/offline banner swapped content in place with
no transition (instantly vanishing). Keying the banner by its value (.id)
makes every change — tip→network or tip→nil — run the shared removal
transition, so the tip slides up and fades out when it auto-dismisses or is
tapped.
2026-08-19 11:10:29 +01:00
2 changed files with 73 additions and 10 deletions
+72 -9
View File
@@ -5,7 +5,7 @@ import WidgetKit
/// The single floating notification banner. Network/offline state and tip
/// outcomes all funnel through ONE style and render via the same chrome in
/// ContentView never pushing layout, always overlaid.
enum AppBanner: Equatable {
enum AppBanner: Hashable {
case offlineDump(date: String)
case connectionProblem
case tip(TipStore.TipOutcome)
@@ -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<Void, Never>?
/// 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,22 +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)
.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 (tipnetwork, 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 {
+1 -1
View File
@@ -604,7 +604,7 @@ final class TipStore: ObservableObject {
]
/// A banner outcome: the exact copy to show plus its icon + tint.
struct TipOutcome: Equatable {
struct TipOutcome: Hashable {
let message: String
let icon: String
let tint: Color