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.
This commit is contained in:
+71
-12
@@ -84,6 +84,16 @@ struct ContentView: View {
|
|||||||
/// network/offline strip (ContentView renders it above the tabs).
|
/// network/offline strip (ContentView renders it above the tabs).
|
||||||
@StateObject private var tipStore = TipStore()
|
@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
|
/// 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
|
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||||
/// redundant — the whole country sorted nearest-first, because "nearest"
|
/// redundant — the whole country sorted nearest-first, because "nearest"
|
||||||
@@ -485,26 +495,75 @@ struct ContentView: View {
|
|||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
rootTabView
|
rootTabView
|
||||||
if let banner = activeBanner {
|
if let banner = currentBanner {
|
||||||
// Floating near the top of the screen, over the nav area —
|
// Floating near the top of the screen, over the nav area —
|
||||||
// overlays content (never pushes it) and sits above the
|
// overlays content (never pushes it) and sits above the
|
||||||
// main content so it doesn't cover or block the list/pill
|
// 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)
|
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)
|
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||||
.transition(.asymmetric(
|
.opacity(bannerOpacity)
|
||||||
insertion: .move(edge: .top).combined(with: .opacity),
|
.offset(y: bannerOffset)
|
||||||
// Slide up + fade on dismiss/timeout (one style for all).
|
|
||||||
removal: .move(edge: .top).combined(with: .opacity)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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 {
|
private var rootTabView: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user