Files
Sportstime/SportsTime/Core/Theme/AnimatedBackground.swift
Trey t e1d84ac769 feat(ui): apply animated background to all screens via themedBackground modifier
Extract AnimatedSportsBackground components to shared file and update
ThemedBackground modifier to conditionally show animations when enabled
in settings. All views using .themedBackground() now get animated background.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:06:58 -06:00

196 lines
6.7 KiB
Swift

//
// AnimatedBackground.swift
// SportsTime
//
// Animated sports background with floating icons and route lines.
// Used by ThemedBackground modifier when animations are enabled.
//
import SwiftUI
// MARK: - Animated Sports Background
/// Floating sports icons with route lines and subtle glow effects
struct AnimatedSportsBackground: View {
@Environment(\.colorScheme) private var colorScheme
@State private var animate = false
var body: some View {
ZStack {
// Base gradient
Theme.backgroundGradient(colorScheme)
// Route lines with city dots (subtle background element)
RouteMapLayer(animate: animate)
// Floating sports icons with gentle glow
ForEach(0..<20, id: \.self) { index in
AnimatedSportsIcon(index: index, animate: animate)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 5.0).repeatForever(autoreverses: true)) {
animate = true
}
}
}
}
// MARK: - Route Map Layer
/// Background route lines connecting city dots (very subtle)
struct RouteMapLayer: View {
@Environment(\.colorScheme) private var colorScheme
let animate: Bool
var body: some View {
Canvas { context, size in
// City points scattered across the view
let points: [CGPoint] = [
CGPoint(x: size.width * 0.1, y: size.height * 0.15),
CGPoint(x: size.width * 0.3, y: size.height * 0.25),
CGPoint(x: size.width * 0.55, y: size.height * 0.1),
CGPoint(x: size.width * 0.75, y: size.height * 0.3),
CGPoint(x: size.width * 0.2, y: size.height * 0.45),
CGPoint(x: size.width * 0.6, y: size.height * 0.5),
CGPoint(x: size.width * 0.85, y: size.height * 0.2),
CGPoint(x: size.width * 0.4, y: size.height * 0.65),
CGPoint(x: size.width * 0.8, y: size.height * 0.6),
CGPoint(x: size.width * 0.15, y: size.height * 0.75),
CGPoint(x: size.width * 0.5, y: size.height * 0.8),
CGPoint(x: size.width * 0.9, y: size.height * 0.85),
]
// Draw dotted route lines connecting points
let routePairs: [(Int, Int)] = [
(0, 1), (1, 3), (3, 6), (2, 6),
(1, 4), (4, 5), (5, 8), (4, 9),
(5, 7), (7, 10), (9, 10), (10, 11),
(2, 3), (8, 11)
]
let lineColor = Theme.warmOrange.resolve(in: .init())
for (start, end) in routePairs {
var path = Path()
path.move(to: points[start])
path.addLine(to: points[end])
context.stroke(
path,
with: .color(Color(lineColor).opacity(0.05)),
style: StrokeStyle(lineWidth: 1, dash: [5, 5])
)
}
// Draw city dots (very subtle)
for (index, point) in points.enumerated() {
let isMainCity = index % 4 == 0
let dotSize: CGFloat = isMainCity ? 5 : 3
let dotPath = Path(ellipseIn: CGRect(
x: point.x - dotSize / 2,
y: point.y - dotSize / 2,
width: dotSize,
height: dotSize
))
context.fill(dotPath, with: .color(Color(lineColor).opacity(isMainCity ? 0.1 : 0.05)))
}
}
}
}
// MARK: - Animated Sports Icon
/// Individual floating sports icon with subtle glow animation
struct AnimatedSportsIcon: View {
let index: Int
let animate: Bool
@State private var glowOpacity: Double = 0
private let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
// Edge icons
(0.06, 0.08, "football.fill", -15, 0.85),
(0.94, 0.1, "basketball.fill", 12, 0.8),
(0.04, 0.28, "baseball.fill", 8, 0.75),
(0.96, 0.32, "hockey.puck.fill", -10, 0.7),
(0.08, 0.48, "soccerball", 6, 0.8),
(0.92, 0.45, "figure.run", -6, 0.85),
(0.05, 0.68, "sportscourt.fill", 4, 0.75),
(0.95, 0.65, "trophy.fill", -12, 0.8),
(0.1, 0.88, "ticket.fill", 10, 0.7),
(0.9, 0.85, "mappin.circle.fill", -8, 0.75),
(0.5, 0.03, "car.fill", 0, 0.7),
(0.5, 0.97, "map.fill", 3, 0.75),
(0.25, 0.93, "stadium.fill", -5, 0.7),
(0.75, 0.95, "flag.checkered", 7, 0.7),
// Middle area icons (will appear behind cards)
(0.35, 0.22, "tennisball.fill", -8, 0.65),
(0.65, 0.35, "volleyball.fill", 10, 0.6),
(0.3, 0.52, "figure.baseball", -5, 0.65),
(0.7, 0.58, "figure.basketball", 8, 0.6),
(0.4, 0.72, "figure.hockey", -10, 0.65),
(0.6, 0.82, "figure.soccer", 5, 0.6),
]
var body: some View {
let config = configs[index]
GeometryReader { geo in
ZStack {
// Subtle glow circle behind icon when active
Circle()
.fill(Theme.warmOrange)
.frame(width: 28 * config.scale, height: 28 * config.scale)
.blur(radius: 8)
.opacity(glowOpacity * 0.2)
Image(systemName: config.icon)
.font(.system(size: 20 * config.scale))
.foregroundStyle(Theme.warmOrange.opacity(0.08 + glowOpacity * 0.1))
.rotationEffect(.degrees(config.rotation))
}
.position(x: geo.size.width * config.x, y: geo.size.height * config.y)
.scaleEffect(animate ? 1.02 : 0.98)
.scaleEffect(1 + glowOpacity * 0.05)
.animation(
.easeInOut(duration: 4.0 + Double(index) * 0.15)
.repeatForever(autoreverses: true)
.delay(Double(index) * 0.2),
value: animate
)
}
.onAppear {
startRandomGlow()
}
}
private func startRandomGlow() {
let initialDelay = Double.random(in: 2.0...8.0)
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) {
triggerGlow()
}
}
private func triggerGlow() {
// Slow fade in
withAnimation(.easeIn(duration: 0.8)) {
glowOpacity = 1
}
// Hold briefly then slow fade out
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
withAnimation(.easeOut(duration: 1.0)) {
glowOpacity = 0
}
}
// Longer interval between glows
let nextGlow = Double.random(in: 6.0...12.0)
DispatchQueue.main.asyncAfter(deadline: .now() + nextGlow) {
triggerGlow()
}
}
}