// SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// An animated sky for a kind of weather: sun, stars, clouds, rain, storm, snow or fog. /// /// Changing ``weather`` fades the sky and its effects into the new ones. Lightning follows a /// fixed schedule of at most two flashes every few seconds, never more than three a second. /// With Reduce Motion on, everything holds still and lightning never flashes. The sun sits in /// the top trailing corner, so it moves to the left in a right-to-left layout. /// /// It draws behind content, ignores the safe area and takes no touches, and VoiceOver skips it. struct WeatherEffectsBackground: View { /// The weather to draw. var weather: WeatherEffectsBackgroundWeather @Environment(\.accessibilityReduceMotion) private var reduceMotion /// The weather fading out after a change, drawn under the new one until it is gone. @State private var leaving: WeatherEffectsBackgroundWeather? /// How far the change has gone, from 0 when it starts to 1 when only the new weather shows. @State private var arrival: Double = 1 /// Counts the changes, so the end of an earlier fade does not clear a later one. @State private var change = 0 private let fade: Animation = .easeInOut(duration: 1) var body: some View { ZStack { LinearGradient(colors: weather.skyColors, startPoint: .top, endPoint: .bottom) .animation(fade, value: weather) // Each weather keeps its own clock, so the effects that leave go on moving while they fade. if let leaving { effects(of: leaving) .opacity(1 - arrival) } effects(of: weather) .opacity(arrival) } .onChange(of: weather) { previous, _ in change += 1 let current = change leaving = previous arrival = 0 withAnimation(fade) { arrival = 1 } completion: { if change == current { leaving = nil } } } .ignoresSafeArea() .allowsHitTesting(false) .accessibilityHidden(true) } } /// A kind of weather the background can draw. nonisolated enum WeatherEffectsBackgroundWeather: String, CaseIterable, Sendable { case clearDay, clearNight, cloudy, rain, thunderstorm, snow, fog /// The name of the weather, for a label next to it. var title: LocalizedStringResource { switch self { case .clearDay: "Clear" case .clearNight: "Clear Night" case .cloudy: "Cloudy" case .rain: "Rain" case .thunderstorm: "Thunderstorm" case .snow: "Snow" case .fog: "Fog" } } /// Falling rain and snow need every frame; slow clouds, fog, the sun and the stars do with half. fileprivate var frameInterval: Double { switch self { case .rain, .thunderstorm, .snow: 1.0 / 60.0 case .clearDay, .clearNight, .cloudy, .fog: 1.0 / 30.0 } } fileprivate var skyColors: [Color] { switch self { case .clearDay: [Color(red: 0.11, green: 0.38, blue: 0.80), Color(red: 0.46, green: 0.72, blue: 0.96)] case .clearNight: [Color(red: 0.02, green: 0.03, blue: 0.13), Color(red: 0.10, green: 0.13, blue: 0.34)] case .cloudy: [Color(red: 0.33, green: 0.40, blue: 0.52), Color(red: 0.62, green: 0.68, blue: 0.76)] case .rain: [Color(red: 0.14, green: 0.19, blue: 0.27), Color(red: 0.33, green: 0.40, blue: 0.49)] case .thunderstorm: [Color(red: 0.06, green: 0.07, blue: 0.13), Color(red: 0.20, green: 0.22, blue: 0.34)] case .snow: [Color(red: 0.36, green: 0.44, blue: 0.58), Color(red: 0.70, green: 0.76, blue: 0.85)] case .fog: [Color(red: 0.46, green: 0.49, blue: 0.56), Color(red: 0.70, green: 0.72, blue: 0.75)] } } } // MARK: - Math /// The timing of the effects: where a moving thing wraps around, and when lightning flashes. nonisolated enum WeatherEffectsBackgroundMath { /// The seconds after which the lightning schedule repeats. static let lightningCycle: Double = 6 /// A position moved back inside `0.. Double { guard travel > 0 else { return 0 } let remainder = position.truncatingRemainder(dividingBy: travel) guard remainder < 0 else { return remainder } // A tiny negative remainder rounds up to `travel` itself, which belongs at the start. let wrapped = remainder + travel return wrapped < travel ? wrapped : 0 } /// The brightness of the lightning at a moment, from 0 when dark to 1 at the peak of a flash. static func flash(at time: Double) -> Double { let moment = wrap(time, over: lightningCycle) for strike in strikes where moment >= strike.start && moment < strike.start + strike.duration { return sin(.pi * (moment - strike.start) / strike.duration) } return 0 } /// Two strikes close together, as lightning often comes, early in each cycle. private static let strikes: [(start: Double, duration: Double)] = [(0.4, 0.18), (0.75, 0.22)] } extension WeatherEffectsBackground { private func effects(of weather: WeatherEffectsBackgroundWeather) -> some View { TimelineView(.animation(minimumInterval: weather.frameInterval, paused: reduceMotion)) { timeline in WeatherEffectsBackgroundLayers( weather: weather, time: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate, flashes: !reduceMotion ) } .id(weather) } } // MARK: - Layers /// The effects of one weather at one moment. private struct WeatherEffectsBackgroundLayers: View { let weather: WeatherEffectsBackgroundWeather let time: Double let flashes: Bool var body: some View { ZStack { switch weather { case .clearDay: WeatherEffectsBackgroundSun(time: time) case .clearNight: canvas { WeatherEffectsBackgroundStars.draw(in: &$0, size: $1, time: time) } case .cloudy: canvas { WeatherEffectsBackgroundClouds.draw(in: &$0, size: $1, time: time, color: .white, opacity: 0.5) } case .rain: canvas { WeatherEffectsBackgroundClouds.draw(in: &$0, size: $1, time: time, color: .white, opacity: 0.25) } canvas { WeatherEffectsBackgroundRain.draw(in: &$0, size: $1, time: time, drops: 110) } case .thunderstorm: canvas { WeatherEffectsBackgroundClouds.draw(in: &$0, size: $1, time: time, color: .black, opacity: 0.5) } if flashes { canvas { WeatherEffectsBackgroundLightning.draw(in: &$0, size: $1, time: time) } } canvas { WeatherEffectsBackgroundRain.draw(in: &$0, size: $1, time: time, drops: 170) } case .snow: canvas { WeatherEffectsBackgroundSnow.draw(in: &$0, size: $1, time: time) } case .fog: canvas { WeatherEffectsBackgroundFog.draw(in: &$0, size: $1, time: time) } } } } private func canvas(_ draw: @escaping (inout GraphicsContext, CGSize) -> Void) -> some View { Canvas { context, size in draw(&context, size) } } } /// The property of a particle a pseudo-random value decides. nonisolated private enum WeatherEffectsBackgroundTrait: Double { case x = 1, y, size, speed, phase, offset, extra } /// A repeatable pseudo-random value in 0..<1 for one particle and one of its properties. nonisolated private func weatherEffectsBackgroundNoise(_ index: Int, _ trait: WeatherEffectsBackgroundTrait) -> Double { let value = sin(Double(index) * 127.1 + trait.rawValue * 311.7) * 43_758.5453 return value - floor(value) } // MARK: - Sun /// A glow in the top trailing corner with slow rays turning around it. private struct WeatherEffectsBackgroundSun: View { let time: Double private let diameter: CGFloat = 520 var body: some View { ZStack(alignment: .topTrailing) { Color.clear Canvas { context, size in let center = CGPoint(x: size.width / 2, y: size.height / 2) let radius = size.width / 2 let rays = 14 context.drawLayer { rayContext in rayContext.addFilter(.blur(radius: 12)) rayContext.translateBy(x: center.x, y: center.y) rayContext.rotate(by: .radians(time * 0.05)) for ray in 0.. 0 else { return } context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.white.opacity(flash * 0.45))) guard flash > 0.3 else { return } let cycle = Int(floor(time / WeatherEffectsBackgroundMath.lightningCycle)) var x = size.width * (0.2 + weatherEffectsBackgroundNoise(cycle, .x) * 0.6) var y: Double = 0 var bolt = Path() bolt.move(to: CGPoint(x: x, y: y)) for step in 0..<9 { x += (weatherEffectsBackgroundNoise(cycle * 10 + step, .offset) - 0.5) * 60 y += size.height * 0.065 bolt.addLine(to: CGPoint(x: x, y: y)) } context.drawLayer { glow in glow.addFilter(.blur(radius: glowWidth)) glow.stroke(bolt, with: .color(Color(red: 0.75, green: 0.8, blue: 1).opacity(flash)), lineWidth: glowWidth) } context.stroke(bolt, with: .color(.white.opacity(flash)), lineWidth: 2) } } // MARK: - Snow nonisolated private enum WeatherEffectsBackgroundSnow { /// Flakes in two depths that fall slowly and sway from side to side. static func draw(in context: inout GraphicsContext, size: CGSize, time: Double) { var near = Path() var far = Path() let travel = size.height + 20 for index in 0..<130 { let isNear = index % 3 == 0 let depth: Double = isNear ? 1 : 0.5 let y = WeatherEffectsBackgroundMath.wrap( weatherEffectsBackgroundNoise(index, .y) * travel + time * 50 * depth, over: travel ) - 10 let sway = sin(time * (0.5 + weatherEffectsBackgroundNoise(index, .speed)) + weatherEffectsBackgroundNoise(index, .phase) * 2 * .pi) * 14 * depth let x = weatherEffectsBackgroundNoise(index, .x) * size.width + sway let radius = (1.3 + weatherEffectsBackgroundNoise(index, .size) * 2) * depth let flake = Path(ellipseIn: CGRect(x: x - radius, y: y - radius, width: radius * 2, height: radius * 2)) if isNear { near.addPath(flake) } else { far.addPath(flake) } } context.fill(far, with: .color(.white.opacity(0.55))) context.fill(near, with: .color(.white.opacity(0.9))) } } // MARK: - Fog nonisolated private enum WeatherEffectsBackgroundFog { /// Wide, heavily blurred bands that drift slowly across the lower sky. static func draw(in context: inout GraphicsContext, size: CGSize, time: Double) { let bandWidth = size.width * 1.6 context.drawLayer { layer in layer.addFilter(.blur(radius: 30)) for index in 0..<8 { let travel = size.width + bandWidth let direction: Double = index.isMultiple(of: 2) ? 1 : -1 let x = WeatherEffectsBackgroundMath.wrap( weatherEffectsBackgroundNoise(index, .x) * travel + time * (5 + Double(index) * 2) * direction, over: travel ) - bandWidth let y = size.height * (0.18 + Double(index) * 0.1) let band = Path(ellipseIn: CGRect(x: x, y: y, width: bandWidth, height: size.height * 0.14)) layer.fill(band, with: .color(.white.opacity(0.5))) } } } } // MARK: - Demo struct WeatherEffectsBackgroundDemo: View { /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true @State private var weather = WeatherEffectsBackgroundWeather.clearDay /// A choice from the menu also stops the demo, which would otherwise move on from it. private var chosenWeather: Binding { Binding { weather } set: { choice in isAutoPlaying = false weather = choice } } var body: some View { ZStack { WeatherEffectsBackground(weather: weather) // The background takes no touches, so this layer catches the one that stops the demo. Color.clear .contentShape(.rect) .ignoresSafeArea() } .overlay(alignment: .bottom) { Picker("Weather", selection: chosenWeather) { ForEach(WeatherEffectsBackgroundWeather.allCases, id: \.self) { weather in Text(weather.title).tag(weather) } } .pickerStyle(.menu) .tint(.white) .padding(.horizontal, 6) .background(.ultraThinMaterial, in: .capsule) .environment(\.colorScheme, .dark) .padding(.bottom, 40) } .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task { while isAutoPlaying { try? await Task.sleep(for: .seconds(1.8)) guard !Task.isCancelled, isAutoPlaying else { return } let all = WeatherEffectsBackgroundWeather.allCases let next = ((all.firstIndex(of: weather) ?? 0) + 1) % all.count weather = all[next] } } } } #Preview { WeatherEffectsBackgroundDemo() }