154 lines
4.8 KiB
Swift
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))
|
|
}
|
|
}
|
|
} |