medmap-ios/medmap/Views/FloatingCircle.swift
2026-09-15 23:23:01 +01:00

154 lines
4.8 KiB
Swift

//
// 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<CGFloat> = 60...140
/// Range for movement speed, in points per second.
var speedRange: ClosedRange<CGFloat> = 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..<numberOfCircles).map { i in
let radius = CGFloat.random(in: radiusRange)
let x = CGFloat.random(in: radius...max(radius, size.width - radius))
let y = CGFloat.random(in: radius...max(radius, size.height - radius))
let speed = CGFloat.random(in: speedRange)
let angle = CGFloat.random(in: 0..<(2 * .pi))
return FloatingCircle(
position: CGPoint(x: x, y: y),
velocity: CGVector(dx: cos(angle) * speed, dy: sin(angle) * speed),
radius: radius,
color: colors[i % colors.count]
)
}
}
// MARK: - Physics step
private func step(dt: CGFloat, in size: CGSize) {
guard dt > 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))
}
}
}