Created
September 9, 2024 01:24
-
-
Save jtvargas/9d046ab3e267d2d55fbb235a7fcb7c2b to your computer and use it in GitHub Desktop.
Stress Fiddle App in SwiftUI
This file contains 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
// | |
// MatrixEffect.swift | |
// | |
// Created by J.T on 9/8/24. | |
// | |
import SwiftUI | |
import Combine | |
class MatrixEffectModel: ObservableObject { | |
@Published var dots: [Dot] | |
@Published var touchLocation: CGPoint = .zero | |
@Published var dotSize: CGFloat = 3 | |
@Published var dotSpacing: CGFloat = 20 | |
@Published var touchBoundingSize: CGFloat = 100 | |
@Published var dotInertia: CGFloat = 0.40 | |
private var cancellables: Set<AnyCancellable> = [] | |
private let updateSubject = PassthroughSubject<Void, Never>() | |
init() { | |
dots = [] | |
setupUpdateTimer() | |
} | |
private func setupUpdateTimer() { | |
Timer.publish(every: 1/120, on: .main, in: .common) | |
.autoconnect() | |
.sink { [weak self] _ in | |
self?.updateSubject.send() | |
} | |
.store(in: &cancellables) | |
updateSubject | |
.collect(.byTime(DispatchQueue.main, .milliseconds(16))) | |
.sink { [weak self] _ in | |
self?.updateDots() | |
} | |
.store(in: &cancellables) | |
} | |
func initializeDots(in size: CGSize) { | |
let rows = Int(size.height / dotSpacing) | |
let columns = Int(size.width / dotSpacing) | |
dots = (0..<rows).flatMap { row in | |
(0..<columns).map { column in | |
let x = CGFloat(column) * dotSpacing | |
let y = CGFloat(row) * dotSpacing | |
return Dot(x: x, y: y, originX: x, originY: y) | |
} | |
} | |
} | |
func updateDots() { | |
let touchBoundingSizeSquared = touchBoundingSize * touchBoundingSize | |
dots = dots.map { dot in | |
var updatedDot = dot | |
let dx = touchLocation.x - updatedDot.x | |
let dy = touchLocation.y - updatedDot.y | |
let distanceSquared = dx*dx + dy*dy | |
if distanceSquared < touchBoundingSizeSquared { | |
let distance = sqrt(distanceSquared) | |
let force = (touchBoundingSize - distance) / touchBoundingSize | |
let angle = atan2(dy, dx) | |
let targetX = updatedDot.x - cos(angle) * force * 20 | |
let targetY = updatedDot.y - sin(angle) * force * 20 | |
updatedDot.vx += (targetX - updatedDot.x) * dotInertia | |
updatedDot.vy += (targetY - updatedDot.y) * dotInertia | |
} | |
updatedDot.vx *= 0.9 | |
updatedDot.vy *= 0.9 | |
updatedDot.x += updatedDot.vx | |
updatedDot.y += updatedDot.vy | |
let dx2 = updatedDot.originX - updatedDot.x | |
let dy2 = updatedDot.originY - updatedDot.y | |
let distance2Squared = dx2*dx2 + dy2*dy2 | |
if distance2Squared > 1 { | |
updatedDot.x += dx2 * 0.03 | |
updatedDot.y += dy2 * 0.03 | |
} | |
return updatedDot | |
} | |
} | |
} | |
struct MatrixEffect: View { | |
@StateObject private var model = MatrixEffectModel() | |
var body: some View { | |
ZStack { | |
GeometryReader { geometry in | |
DotCanvas(dots: model.dots, dotSize: model.dotSize) | |
.gesture( | |
DragGesture(minimumDistance: 0) | |
.onChanged { value in | |
model.touchLocation = value.location | |
} | |
.onEnded { _ in | |
model.touchLocation = CGPoint(x: -1000, y: -1000) | |
} | |
) | |
.onAppear { | |
model.initializeDots(in: geometry.size) | |
} | |
.onChange(of: geometry.size) { newSize in | |
model.initializeDots(in: newSize) | |
} | |
} | |
.background(Color.black) | |
.edgesIgnoringSafeArea(.all) | |
VStack { | |
Spacer() | |
controlPanel | |
} | |
} | |
} | |
private var controlPanel: some View { | |
VStack(spacing: 10) { | |
SliderView(value: $model.dotSize, range: 1...10, title: "Dot Size") | |
SliderView(value: $model.touchBoundingSize, range: 50...200, title: "Touch Bounding") | |
SliderView(value: $model.dotInertia, range: 0.01...0.5, title: "Dot Inertia") | |
} | |
.padding() | |
.background(Color.black.opacity(0.5)) | |
.cornerRadius(10) | |
.padding() | |
} | |
} | |
struct DotCanvas: View { | |
let dots: [Dot] | |
let dotSize: CGFloat | |
var body: some View { | |
Canvas { context, size in | |
for dot in dots { | |
context.fill( | |
Path(ellipseIn: CGRect( | |
x: dot.x - dotSize/2, | |
y: dot.y - dotSize/2, | |
width: dotSize, | |
height: dotSize | |
)), | |
with: .color(.white) | |
) | |
} | |
} | |
} | |
} | |
struct Dot: Equatable { | |
var x: CGFloat | |
var y: CGFloat | |
let originX: CGFloat | |
let originY: CGFloat | |
var vx: CGFloat = 0 | |
var vy: CGFloat = 0 | |
} | |
struct SliderView: View { | |
@Binding var value: CGFloat | |
let range: ClosedRange<CGFloat> | |
let title: String | |
var body: some View { | |
VStack(alignment: .leading) { | |
Text("\(title): \(value, specifier: "%.2f")") | |
.foregroundColor(.white) | |
Slider(value: $value, in: range) | |
} | |
} | |
} | |
#Preview { | |
MatrixEffect() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment