Created
July 15, 2025 21:45
-
-
Save mykolaharmash/44604ebd02d747a2c50dbc50808115d2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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