Skip to content

Instantly share code, notes, and snippets.

@mykolaharmash
Created July 15, 2025 21:45
Show Gist options
  • Save mykolaharmash/44604ebd02d747a2c50dbc50808115d2 to your computer and use it in GitHub Desktop.
Save mykolaharmash/44604ebd02d747a2c50dbc50808115d2 to your computer and use it in GitHub Desktop.
//
// ParticlesView.swift
//
// Created by Mykola Harmash on 14.07.25.
//
import SwiftUI
fileprivate struct Particle {
let birthDate: Date = .now
let maxLifeDuration: TimeInterval
let size: Double
let blurRadius: Double
let maxOpacity: Double
var speed: CGPoint
var position: CGPoint
var opacity: Double = 0.0
func lifeDuration(to toDate: Date) -> TimeInterval {
return toDate.timeIntervalSince(birthDate)
}
mutating func calculateOpacity(transitionToLifeRatio: TimeInterval, currentDate: Date) {
let currentLifeDuration = lifeDuration(to: currentDate)
let transitionDuration = maxLifeDuration * transitionToLifeRatio
if currentLifeDuration <= transitionDuration {
opacity = currentLifeDuration / transitionDuration * maxOpacity
} else {
let risidualCurrentLifeDuration = maxLifeDuration - currentLifeDuration
opacity = min(risidualCurrentLifeDuration / transitionDuration, 1.0) * maxOpacity
}
}
mutating func move() {
position.x += speed.x
position.y += speed.y
speed.x += Double.random(in: -0.00001...0.00001)
speed.y += Double.random(in: -0.00001...0.00001)
}
}
fileprivate class ParticleSystem {
var particleList: [Particle]
let birthInterval: TimeInterval
private let TRANSITION_TO_LIFE_RATIO: TimeInterval = 0.3
init() {
self.particleList = []
self.birthInterval = 0.05
}
func calculateNextIteration(currentDate: Date) {
var updatedPrticleList: [Particle] = []
spawn(currentDate: currentDate)
for var particle in particleList {
guard particle.lifeDuration(to: currentDate) <= particle.maxLifeDuration else {
continue
}
particle.calculateOpacity(transitionToLifeRatio: TRANSITION_TO_LIFE_RATIO, currentDate: currentDate)
particle.move()
updatedPrticleList.append(particle)
}
particleList = updatedPrticleList
}
private func spawn(currentDate: Date) {
guard !particleList.isEmpty else {
particleList.append(createParticle())
return
}
let latestBirthDate: Date = particleList.map(\.birthDate).sorted(by: <).last!
let timeIntervalSinceLastBirth = currentDate.timeIntervalSince(latestBirthDate)
let spawnCount = Int(timeIntervalSinceLastBirth / birthInterval)
guard spawnCount > 0 else {
return
}
particleList.append(
contentsOf: Array(
repeating: createParticle(),
count: spawnCount
)
)
}
private func createParticle() -> Particle {
let depth = Double.random(in: 0...1.0)
return .init(
maxLifeDuration: 10.0,
size: Double.random(in: 0.01...0.03),
blurRadius: 20 * depth,
maxOpacity: 1.0 - depth,
speed: .init(
x: Double.random(in: -0.0003...0.0003),
y: -0.0005 - 0.001 * (1.0 - depth)
),
position: CGPoint(
x: Double.random(in: 0.0...1.0),
y: Double.random(in: 0.9...1.0)
)
)
}
}
struct ParticlesView: View {
@State private var particleSystem = ParticleSystem()
var body: some View {
ZStack {
TimelineView(.animation) { timelineContext in
Canvas(opaque: true, colorMode: .linear, rendersAsynchronously: false) {
drawContext,
size in
drawContext.fill(
Path(CGRect(origin: .zero, size: size)),
with: .linearGradient(
.init(colors: [.black, .black.mix(with: .white, by: 0.2)]),
startPoint: .init(x: size.width / 2, y: size.height),
endPoint: .init(x: size.width / 2, y: 0.0)
)
)
particleSystem.calculateNextIteration(currentDate: timelineContext.date)
for particle in particleSystem.particleList {
var particleDrawContext = drawContext
particleDrawContext.opacity = particle.opacity
particleDrawContext.addFilter(.blur(radius: particle.blurRadius))
particleDrawContext.fill(
Path(
ellipseIn: CGRect(
origin: .init(
x: size.width * particle.position.x,
y: size.height * particle.position.y
),
size: .init(
width: size.width * particle.size,
height: size.width * particle.size
)
)
),
with: .color(.white)
)
}
}
}
}
.ignoresSafeArea()
}
}
#Preview {
ZStack {
Text("Hello Canvas")
ParticlesView()
.frame(width: 300, height: 200)
.border(.gray)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment