// // FloatingCircle.swift // medmap // // Created by RSA on 15/09/2026. // import SwiftUI // MARK: - Model private struct FloatingCircle: Identifiable { let id = UUID() var position: CGPoint var velocity: CGVector let radius: CGFloat let color: Color } // MARK: - Background View /// A background of colored, blurred circles that bounce around the screen /// with simple physics (constant velocity + reflection off the edges). /// /// Usage: /// ```swift /// ZStack { /// BouncingCirclesBackground(numberOfCircles: 8, blurStrength: 60) /// YourContent() /// } /// ``` struct BouncingCirclesBackground: View { /// How many circles to animate. var numberOfCircles: Int = 6 /// Gaussian blur radius applied to the whole layer of circles. /// Higher = softer, more "ambient glow" look. var blurStrength: CGFloat = 40 /// Palette the circles are drawn from (cycled if there are more circles than colors). var colors: [Color] = [.pink, .purple, .blue, .orange, .mint, .yellow, .red, .teal] /// Range for circle radii (before scaling). var radiusRange: ClosedRange = 60...140 /// Range for movement speed, in points per second. var speedRange: ClosedRange = 25...65 @State private var circles: [FloatingCircle] = [] @State private var lastTimestamp: Date = Date() var body: some View { GeometryReader { geo in TimelineView(.animation) { timeline in Canvas { context, size in for circle in circles { let rect = CGRect( x: circle.position.x - circle.radius, y: circle.position.y - circle.radius, width: circle.radius * 2, height: circle.radius * 2 ) context.fill(Path(ellipseIn: rect), with: .color(circle.color)) } } .blur(radius: blurStrength) .onChange(of: timeline.date) { newDate in let dt = min(newDate.timeIntervalSince(lastTimestamp), 1 / 30) // clamp to avoid big jumps lastTimestamp = newDate step(dt: CGFloat(dt), in: geo.size) } } .onAppear { if circles.isEmpty { circles = makeInitialCircles(in: geo.size) } lastTimestamp = Date() } } .ignoresSafeArea() .drawingGroup() // renders via Metal, much smoother with blur + many shapes } // MARK: - Setup private func makeInitialCircles(in size: CGSize) -> [FloatingCircle] { (0.. 0, size.width > 0, size.height > 0 else { return } for i in circles.indices { var c = circles[i] var newX = c.position.x + c.velocity.dx * dt var newY = c.position.y + c.velocity.dy * dt // Bounce off left/right edges if newX - c.radius < 0 { newX = c.radius c.velocity.dx = abs(c.velocity.dx) } else if newX + c.radius > size.width { newX = size.width - c.radius c.velocity.dx = -abs(c.velocity.dx) } // Bounce off top/bottom edges if newY - c.radius < 0 { newY = c.radius c.velocity.dy = abs(c.velocity.dy) } else if newY + c.radius > size.height { newY = size.height - c.radius c.velocity.dy = -abs(c.velocity.dy) } c.position = CGPoint(x: newX, y: newY) circles[i] = c } } } // MARK: - Preview #Preview { ZStack { BouncingCirclesBackground(numberOfCircles: 8, blurStrength: 60) VStack { Text("Your content goes here") .font(.title.bold()) .foregroundStyle(.white) .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) } } }