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>
This commit is contained in:
195
SportsTime/Core/Theme/AnimatedBackground.swift
Normal file
195
SportsTime/Core/Theme/AnimatedBackground.swift
Normal file
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,15 @@ struct ThemedBackground: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(Theme.backgroundGradient(colorScheme))
|
||||
.background {
|
||||
if DesignStyleManager.shared.animationsEnabled {
|
||||
AnimatedSportsBackground()
|
||||
.ignoresSafeArea()
|
||||
} else {
|
||||
Theme.backgroundGradient(colorScheme)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,37 +21,30 @@ struct HomeContent_ClassicAnimated: View {
|
||||
let displayedTips: [PlanningTip]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Animated background layer
|
||||
AnimatedSportsBackground()
|
||||
.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Hero Card
|
||||
heroCard
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.top, Theme.Spacing.sm)
|
||||
|
||||
// Content layer
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Hero Card
|
||||
heroCard
|
||||
// Suggested Trips
|
||||
suggestedTripsSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Saved Trips
|
||||
if !savedTrips.isEmpty {
|
||||
savedTripsSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.top, Theme.Spacing.sm)
|
||||
|
||||
// Suggested Trips
|
||||
suggestedTripsSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Saved Trips
|
||||
if !savedTrips.isEmpty {
|
||||
savedTripsSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
}
|
||||
|
||||
// Planning Tips
|
||||
if !displayedTips.isEmpty {
|
||||
tipsSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
}
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
|
||||
// Planning Tips
|
||||
if !displayedTips.isEmpty {
|
||||
tipsSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
}
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
@@ -325,185 +318,3 @@ struct HomeContent_ClassicAnimated: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animated Sports Background
|
||||
|
||||
/// Floating sports icons with route lines and subtle glow effects
|
||||
private 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background route lines connecting city dots (very subtle)
|
||||
private 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual floating sports icon with subtle glow animation
|
||||
private 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user