diff --git a/Sandbox/Sandbox/PreviewViews/ConfettiView.swift b/Sandbox/Sandbox/PreviewViews/ConfettiView.swift index 71f0fa2..a251cbd 100644 --- a/Sandbox/Sandbox/PreviewViews/ConfettiView.swift +++ b/Sandbox/Sandbox/PreviewViews/ConfettiView.swift @@ -15,11 +15,11 @@ struct ConfettiView: View { ZStack { Text("Tap anywhere to create confetti.") - VortexView(.confetti.makeUniqueCopy()) { + VortexView(.confetti) { Rectangle() .fill(.white) .frame(width: 16, height: 16) - .tag("square") + .tag("triangle") Circle() .fill(.white) diff --git a/Sandbox/Sandbox/PreviewViews/FirefliesView.swift b/Sandbox/Sandbox/PreviewViews/FirefliesView.swift index 4de2926..4845015 100644 --- a/Sandbox/Sandbox/PreviewViews/FirefliesView.swift +++ b/Sandbox/Sandbox/PreviewViews/FirefliesView.swift @@ -22,15 +22,8 @@ struct FirefliesView: View { Text("Drag anywhere to repel the fireflies.") .padding(.bottom, 20) } - - VortexView(.fireflies.makeUniqueCopy()) { - Circle() - .fill(.white) - .frame(width: 32) - .blur(radius: 3) - .blendMode(.plusLighter) - .tag("circle") - } + // Show the fireflies preset using default symbols + VortexView(.fireflies) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in diff --git a/Sources/Vortex/Helpers/Angle+Codable.swift b/Sources/Vortex/Helpers/Angle+Codable.swift new file mode 100644 index 0000000..019c75f --- /dev/null +++ b/Sources/Vortex/Helpers/Angle+Codable.swift @@ -0,0 +1,20 @@ +// +// Angle+Codable.swift +// Vortex +// https://www.github.com/twostraws/Vortex +// See LICENSE for license information. +// + +import SwiftUI + +extension Angle: Codable { + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self = try Angle(radians: container.decode(Double.self)) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.radians) + } +} diff --git a/Sources/Vortex/Helpers/Equatable+Identifiable.swift b/Sources/Vortex/Helpers/Equatable+Identifiable.swift new file mode 100644 index 0000000..272b211 --- /dev/null +++ b/Sources/Vortex/Helpers/Equatable+Identifiable.swift @@ -0,0 +1,14 @@ +// +// Equatable+Identifiable.swift +// Vortex +// https://www.github.com/twostraws/Vortex +// See LICENSE for license information. +// + + +// Automatic conformance to Equatable for any Identifiable Type. +extension Equatable where Self: Identifiable{ +public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/Vortex/Helpers/Hashable+Identifiable.swift b/Sources/Vortex/Helpers/Hashable+Identifiable.swift new file mode 100644 index 0000000..00c6a53 --- /dev/null +++ b/Sources/Vortex/Helpers/Hashable+Identifiable.swift @@ -0,0 +1,15 @@ +// +// Hashable+Identifiable.swift +// Vortex +// https://www.github.com/twostraws/Vortex +// See LICENSE for license information. +// + +import SwiftUI +// Automatic conformance to Hashable for any Identifiable Type. +extension Hashable where Self: Identifiable { + /// The default hash value for an Identifiable type is simply its identifier. + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Sandbox/Sandbox/PlatformShims.swift b/Sources/Vortex/Helpers/PlatformShims.swift similarity index 99% rename from Sandbox/Sandbox/PlatformShims.swift rename to Sources/Vortex/Helpers/PlatformShims.swift index d82e79b..b884015 100644 --- a/Sandbox/Sandbox/PlatformShims.swift +++ b/Sources/Vortex/Helpers/PlatformShims.swift @@ -7,6 +7,7 @@ import SwiftUI + #if !os(macOS) extension View { /// A tiny shim to make navigationSubtitle() do nothing everywhere @@ -16,3 +17,4 @@ extension View { } } #endif + diff --git a/Sources/Vortex/Presets/Confetti.swift b/Sources/Vortex/Presets/Confetti.swift index 2078c0e..8b74524 100644 --- a/Sources/Vortex/Presets/Confetti.swift +++ b/Sources/Vortex/Presets/Confetti.swift @@ -7,23 +7,49 @@ import SwiftUI -extension VortexSystem { - /// A built-in effect that creates confetti only when a burst is triggered. +//extension VortexSystem { +// /// A built-in effect that creates confetti only when a burst is triggered. +// /// Relies on "triangle" and "circle" tags being present – using `Rectangle` +// /// and `Circle` with frames of 16x16 works well. +// public static let confetti: VortexSystem = VortexSystem(.confetti) +//} + +extension VortexSystem.Settings { + /// These settings create confetti (only) when a burst is triggered. /// Relies on "square" and "circle" tags being present – using `Rectangle` /// and `Circle` with frames of 16x16 works well. - public static let confetti: VortexSystem = { - VortexSystem( - tags: ["square", "circle"], - birthRate: 0, - lifespan: 4, - speed: 0.5, - speedVariation: 0.5, - angleRange: .degrees(90), - acceleration: [0, 1], - angularSpeedVariation: [4, 4, 4], - colors: .random(.white, .red, .green, .blue, .pink, .orange, .cyan), - size: 0.5, - sizeVariation: 0.5 - ) - }() + public static let confetti = VortexSystem.Settings {settings in + settings.tags = ["triangle", "circle"] + settings.birthRate = 0 + settings.lifespan = 4 + settings.speed = 0.5 + settings.speedVariation = 0.5 + settings.angleRange = .degrees(90) + settings.acceleration = [0, 1] + settings.angularSpeedVariation = [4, 4, 4] + settings.colors = .random(.white, .red, .green, .blue, .pink, .orange, .cyan) + settings.size = 0.5 + settings.sizeVariation = 0.5 + } +} + +#Preview("Confetti") { + VortexViewReader { proxy in + ZStack { + Text("Tap anywhere to create confetti.") + + VortexView(.confetti) + .gesture( + /// Drag gesture is available on all platforms vs onTap which requires macOS 14+ + DragGesture(minimumDistance: 0) + .onChanged { gesture in + proxy.move(to: gesture.location) + proxy.burst() + } + ) + } + } + .navigationSubtitle("Demonstrates on-demand particle bursting") + .ignoresSafeArea(edges: .top) } + diff --git a/Sources/Vortex/Presets/Fire.swift b/Sources/Vortex/Presets/Fire.swift index 877dd24..74055db 100644 --- a/Sources/Vortex/Presets/Fire.swift +++ b/Sources/Vortex/Presets/Fire.swift @@ -7,21 +7,31 @@ import SwiftUI -extension VortexSystem { +extension VortexSystem.Settings { /// A built-in fire effect. Relies on a "circle" tag being present, which should be set to use /// `.blendMode(.plusLighter)`. - public static let fire: VortexSystem = { - VortexSystem( - tags: ["circle"], - shape: .box(width: 0.1, height: 0), - birthRate: 300, - speed: 0.2, - speedVariation: 0.2, - angleRange: .degrees(10), - attractionStrength: 2, - colors: .ramp(.brown, .brown, .brown, .brown.opacity(0)), - sizeVariation: 0.5, - sizeMultiplierAtDeath: 0.1 - ) - }() + public static let fire = VortexSystem.Settings { settings in + settings.tags = ["circle"] + settings.shape = .box(width: 0.1, height: 0) + settings.birthRate = 300 + settings.speed = 0.2 + settings.speedVariation = 0.2 + settings.angleRange = .degrees(10) + settings.attractionStrength = 2 + settings.colors = .ramp(.brown, .brown, .brown, .brown.opacity(0)) + settings.sizeVariation = 0.5 + settings.sizeMultiplierAtDeath = 0.1 + } +} + +#Preview { + let fireSettings = VortexSystem.Settings(from: .fire ) { settings in + settings.position = [ 0.5, 1.03] + settings.shape = .box(width: 1.0, height: 0) + settings.birthRate = 600 + } + VortexView(fireSettings) { + Image.circle.blendMode(.plusLighter).frame(width: 16).tag("circle") + } + .frame(width: 500, height: 500) } diff --git a/Sources/Vortex/Presets/Fireflies.swift b/Sources/Vortex/Presets/Fireflies.swift index 256ef49..f9e6ccd 100644 --- a/Sources/Vortex/Presets/Fireflies.swift +++ b/Sources/Vortex/Presets/Fireflies.swift @@ -10,18 +10,66 @@ import SwiftUI extension VortexSystem { /// A built-in firefly effect. Relies on a "circle" tag being present, which should be set to use /// `.blendMode(.plusLighter)`. - public static let fireflies: VortexSystem = { - VortexSystem( - tags: ["circle"], - shape: .ellipse(radius: 0.5), - birthRate: 200, - lifespan: 2, - speed: 0, - speedVariation: 0.25, - angleRange: .degrees(360), - colors: .ramp(.yellow, .yellow, .yellow.opacity(0)), - size: 0.01, - sizeMultiplierAtDeath: 100 - ) - }() + public static let fireflies: VortexSystem = .init(.fireflies) +} +extension VortexSystem.Settings { + ///Built in settings for a firefly effect. + public static let fireflies = VortexSystem.Settings { settings in + settings.tags = ["circle"] + settings.shape = .ellipse(radius: 0.5) + settings.birthRate = 200 + settings.lifespan = 2 + settings.speed = 0 + settings.speedVariation = 0.25 + settings.angleRange = .degrees(360) + settings.colors = .ramp(.yellow, .yellow, .yellow.opacity(0)) + settings.size = 0.01 + settings.sizeMultiplierAtDeath = 100 + } +} + +@available(macOS 15.0, *) +#Preview("Fireflies") { + @Previewable @State var isDragging = false + /// A state value indicating whether the Option key is being held down + @Previewable @State var pressingOptionKey = false + VortexViewReader { proxy in + ZStack(alignment: .bottom) { + if isDragging { + Text("Release your drag to reset the fireflies.") + .padding(.bottom, 20) + } else { + let instructions = if !pressingOptionKey { + "Drag anywhere to repel the fireflies. Or hold the Option Key" + } else { + "Drag anywhere to attract the fireflies" + } + Text(instructions) + .padding(.bottom, 20) + } + + VortexView(.fireflies) + .onModifierKeysChanged(mask: .option) { _, new in + // set the view state based whether the + // `new` EventModifiers value contains the option key + pressingOptionKey = !new.isEmpty + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + proxy.attractTo(value.location) + proxy.particleSystem?.vortexSettings + .attractionStrength = pressingOptionKey ? 2.5 : -2 + isDragging = true + } + .onEnded { _ in + proxy.particleSystem?.vortexSettings + .attractionStrength = 0 + isDragging = false + } + ) + } + } + .navigationSubtitle("Demonstrates use of attraction and repulsion") + .ignoresSafeArea(edges: .top) } diff --git a/Sources/Vortex/Presets/Fireworks.swift b/Sources/Vortex/Presets/Fireworks.swift index ddabed0..fb25c09 100644 --- a/Sources/Vortex/Presets/Fireworks.swift +++ b/Sources/Vortex/Presets/Fireworks.swift @@ -11,54 +11,60 @@ extension VortexSystem { /// A built-in fireworks effect, using secondary systems that create sparkles and explosions. /// Relies on a "circle" tag being present, which should be set to use /// `.blendMode(.plusLighter)`. - public static let fireworks: VortexSystem = { - let sparkles = VortexSystem( - tags: ["circle"], - spawnOccasion: .onUpdate, - emissionLimit: 1, - lifespan: 0.5, - speed: 0.05, - angleRange: .degrees(90), - size: 0.05 - ) - - let explosion = VortexSystem( - tags: ["circle"], - spawnOccasion: .onDeath, - position: [0.5, 1], - birthRate: 100_000, - emissionLimit: 500, - speed: 0.5, - speedVariation: 1, - angleRange: .degrees(360), - acceleration: [0, 1.5], - dampingFactor: 4, - colors: .randomRamp( + public static let fireworks = VortexSystem(.fireworks) +} +extension VortexSystem.Settings { + public static let fireworks = VortexSystem.Settings { settings in + + var sparkles = VortexSystem.Settings { sparkle in + sparkle.tags = ["circle"] + sparkle.spawnOccasion = .onUpdate + sparkle.emissionLimit = 1 + sparkle.lifespan = 0.5 + sparkle.speed = 0.05 + sparkle.angleRange = .degrees(180) + sparkle.size = 0.05 + } + + var explosions = VortexSystem.Settings { explosion in + explosion.tags = ["circle"] + explosion.spawnOccasion = .onDeath + explosion.position = [0.5, 0.5] + explosion.birthRate = 100_000 + explosion.emissionLimit = 500 + explosion.speed = 0.5 + explosion.speedVariation = 1 + explosion.angleRange = .degrees(360) + explosion.acceleration = [0, 1.5] + explosion.dampingFactor = 4 + explosion.colors = .randomRamp( [.white, .pink, .pink], [.white, .blue, .blue], [.white, .green, .green], [.white, .orange, .orange], [.white, .cyan, .cyan] - ), - size: 0.15, - sizeVariation: 0.1, - sizeMultiplierAtDeath: 0 - ) - - let mainSystem = VortexSystem( - tags: ["circle"], - secondarySystems: [sparkles, explosion], - position: [0.5, 1], - birthRate: 2, - emissionLimit: 1000, - speed: 1.5, - speedVariation: 0.75, - angleRange: .degrees(60), - dampingFactor: 2, - size: 0.15, - stretchFactor: 4 - ) + ) + explosion.size = 0.15 + explosion.sizeVariation = 0.1 + explosion.sizeMultiplierAtDeath = 0 + } + + settings.tags = ["circle"] + settings.secondarySettings = [sparkles, explosions] + settings.position = [0.5, 1] + settings.birthRate = 2 + settings.emissionLimit = 1000 + settings.speed = 1.5 + settings.speedVariation = 0.75 + settings.angleRange = .degrees(60) + settings.dampingFactor = 2 + settings.size = 0.15 + settings.stretchFactor = 4 + } +} - return mainSystem - }() +#Preview("Fireworks") { + VortexView(.fireworks) + .navigationSubtitle("Demonstrates multi-stage effects") + .ignoresSafeArea(edges: .top) } diff --git a/Sources/Vortex/Presets/Magic.swift b/Sources/Vortex/Presets/Magic.swift index 394545a..58b51d3 100644 --- a/Sources/Vortex/Presets/Magic.swift +++ b/Sources/Vortex/Presets/Magic.swift @@ -10,19 +10,24 @@ import SwiftUI extension VortexSystem { /// A built-in magic effect. Relies on a "sparkle" tag being present, which should be set to use /// `.blendMode(.plusLighter)`. - public static let magic: VortexSystem = { - VortexSystem( - tags: ["sparkle"], - shape: .ring(radius: 0.5), - lifespan: 1.5, - speed: 0, - speedVariation: 0.2, - angleRange: .degrees(360), - angularSpeedVariation: [0, 0, 10], - colors: .random(.red, .pink, .orange, .blue, .green, .white), - size: 0.5, - sizeVariation: 0.5, - sizeMultiplierAtDeath: 0.01 - ) - }() + public static let magic: VortexSystem = VortexSystem(.magic) +} +extension VortexSystem.Settings { + static let magic = VortexSystem.Settings { settings in + settings.tags = ["sparkle"] + settings.shape = .ring(radius: 0.5) + settings.lifespan = 1.5 + settings.speed = 0 + settings.speedVariation = 0.2 + settings.angleRange = .degrees(360) + settings.angularSpeedVariation = [0, 0, 10] + settings.colors = .random(.red, .pink, .orange, .blue, .green, .white) + settings.size = 0.5 + settings.sizeVariation = 0.5 + settings.sizeMultiplierAtDeath = 0.01 + } +} + +#Preview { + VortexView(.magic) } diff --git a/Sources/Vortex/Presets/Rain.swift b/Sources/Vortex/Presets/Rain.swift index fcf1c1b..a587a67 100644 --- a/Sources/Vortex/Presets/Rain.swift +++ b/Sources/Vortex/Presets/Rain.swift @@ -9,24 +9,29 @@ import SwiftUI extension VortexSystem { /// A built-in rain effect. Relies on a "circle" tag being present. - public static let rain: VortexSystem = { - VortexSystem( - tags: ["circle"], - position: [0.5, 0 ], - shape: .box(width: 1.8, height: 0), - birthRate: 400, - lifespan: 0.5, - speed: 4.5, - speedVariation: 2, - angle: .degrees(190), - colors: .random( - Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.6), - Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.5), - Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.4) - ), - size: 0.09, - sizeVariation: 0.05, - stretchFactor: 12 + public static let rain: VortexSystem = VortexSystem(.rain) +} +extension VortexSystem.Settings { + static let rain = VortexSystem.Settings { settings in + settings.tags = ["circle"] + settings.position = [0.5, 0 ] + settings.shape = .box(width: 1.8, height: 0) + settings.birthRate = 400 + settings.lifespan = 0.5 + settings.speed = 4.5 + settings.speedVariation = 2 + settings.angle = .degrees(190) + settings.colors = .random( + VortexSystem.Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.6), + VortexSystem.Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.5), + VortexSystem.Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.4) ) - }() + settings.size = 0.09 + settings.sizeVariation = 0.05 + settings.stretchFactor = 12 + } +} + +#Preview { + VortexView(.rain) } diff --git a/Sources/Vortex/Presets/Smoke.swift b/Sources/Vortex/Presets/Smoke.swift index a678e63..95fc761 100644 --- a/Sources/Vortex/Presets/Smoke.swift +++ b/Sources/Vortex/Presets/Smoke.swift @@ -9,18 +9,28 @@ import SwiftUI extension VortexSystem { /// A built-in smoke effect. Relies on a "circle" tag being present. - public static let smoke: VortexSystem = { - VortexSystem( - tags: ["circle"], - shape: .box(width: 0.05, height: 0), - lifespan: 3, - speed: 0.1, - speedVariation: 0.1, - angleRange: .degrees(10), - colors: .ramp(.gray, .gray.opacity(0)), - size: 0.5, - sizeVariation: 0.5, - sizeMultiplierAtDeath: 2 - ) - }() + public static let smoke: VortexSystem = VortexSystem(.smoke) +} +extension VortexSystem.Settings { + static let smoke = VortexSystem.Settings { settings in + settings.tags = ["circle"] + settings.shape = .box(width: 0.05, height: 0) + settings.lifespan = 3 + settings.speed = 0.1 + settings.speedVariation = 0.1 + settings.angleRange = .degrees(10) + settings.colors = .ramp(.gray, .gray.opacity(0)) + settings.size = 0.5 + settings.sizeVariation = 0.5 + settings.sizeMultiplierAtDeath = 2 + } +} +#Preview { + VortexView(.smoke, symbols: { + Circle() + .fill(.white) + .frame(width: 64) + .blur(radius: 10) + .tag("circle") + }) } diff --git a/Sources/Vortex/Presets/Snow.swift b/Sources/Vortex/Presets/Snow.swift index 55fb07a..a496872 100644 --- a/Sources/Vortex/Presets/Snow.swift +++ b/Sources/Vortex/Presets/Snow.swift @@ -9,19 +9,25 @@ import SwiftUI extension VortexSystem { /// A built-in snow effect. Relies on a "circle" tag being present. - public static let snow: VortexSystem = { - VortexSystem( - tags: ["circle"], - position: [0.5, 0], - shape: .box(width: 1, height: 0), - birthRate: 50, - lifespan: 10, - speed: 0.2, - speedVariation: 0.2, - angle: .degrees(180), - angleRange: .degrees(20), - size: 0.25, - sizeVariation: 0.4 - ) - }() + public static let snow: VortexSystem = VortexSystem(.snow) +} +extension VortexSystem.Settings { + static let snow = VortexSystem.Settings { settings in + settings.tags = ["circle"] + settings.position = [0.5, 0] + settings.shape = .box(width: 1, height: 0) + settings.birthRate = 50 + settings.lifespan = 10 + settings.speed = 0.2 + settings.speedVariation = 0.2 + settings.angle = .degrees(180) + settings.angleRange = .degrees(20) + settings.size = 0.25 + settings.sizeVariation = 0.4 + } +} + +#Preview { + VortexView(.snow) + .frame(width: 500, height: 500) } diff --git a/Sources/Vortex/Presets/Spark.swift b/Sources/Vortex/Presets/Spark.swift index be18fe2..36cb065 100644 --- a/Sources/Vortex/Presets/Spark.swift +++ b/Sources/Vortex/Presets/Spark.swift @@ -10,23 +10,28 @@ import SwiftUI extension VortexSystem { /// A built-in spark effect. Relies on a "circle" tag being present, which should be set to use /// `.blendMode(.plusLighter)`. - public static let spark: VortexSystem = { - VortexSystem( - tags: ["circle"], - birthRate: 150, - emissionDuration: 0.2, - idleDuration: 0.5, - lifespan: 1.5, - speed: 1.25, - speedVariation: 0.2, - angle: .degrees(330), - angleRange: .degrees(20), - acceleration: [0, 3], - dampingFactor: 4, - colors: .ramp(.white, .yellow, .yellow.opacity(0)), - size: 0.1, - sizeVariation: 0.1, - stretchFactor: 8 - ) - }() + public static let spark: VortexSystem = VortexSystem(.spark) +} +extension VortexSystem.Settings { + static let spark = VortexSystem.Settings { settings in + settings.tags = ["circle"] + settings.birthRate = 150 + settings.emissionDuration = 0.2 + settings.idleDuration = 0.5 + settings.lifespan = 1.5 + settings.speed = 1.25 + settings.speedVariation = 0.2 + settings.angle = .degrees(330) + settings.angleRange = .degrees(20) + settings.acceleration = [0, 3] + settings.dampingFactor = 4 + settings.colors = .ramp(.white, .yellow, .yellow.opacity(0)) + settings.size = 0.1 + settings.sizeVariation = 0.1 + settings.stretchFactor = 8 + } +} + +#Preview { + VortexView(.spark) } diff --git a/Sources/Vortex/Presets/Splash.swift b/Sources/Vortex/Presets/Splash.swift index 65f018e..b8de0ec 100644 --- a/Sources/Vortex/Presets/Splash.swift +++ b/Sources/Vortex/Presets/Splash.swift @@ -7,39 +7,50 @@ import SwiftUI -extension VortexSystem { +extension VortexSystem.Settings { /// A built-in splash effect, designed to accompany the rain present. /// Relies on a "circle" tag being present, which should be set to use /// `.blendMode(.plusLighter)`. - public static let splash: VortexSystem = { - let drops = VortexSystem( - tags: ["circle"], - birthRate: 5, - emissionLimit: 10, - speed: 0.4, - speedVariation: 0.1, - angleRange: .degrees(90), - acceleration: [0, 1], - colors: .random( - Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.7), - Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.6), - Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.5) - ), - size: 0.2 - ) - - let mainSystem = VortexSystem( - tags: ["circle"], - secondarySystems: [drops], - position: [0.5, 1], - shape: .box(width: 1, height: 0), - birthRate: 5, - lifespan: 0.001, - speed: 0, - colors: .single(.clear), - size: 0 - ) + static let splash = VortexSystem.Settings { settings in + + var drops = VortexSystem.Settings { drops in + drops.tags = ["circle"] + drops.birthRate = 5 + drops.emissionLimit = 10 + drops.speed = 0.4 + drops.speedVariation = 0.1 + drops.angleRange = .degrees(90) + drops.acceleration = [0, 1] + drops.colors = .random( + VortexSystem.Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.7), + VortexSystem.Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.6), + VortexSystem.Color(red: 0.7, green: 0.7, blue: 1, opacity: 0.5) + ) + drops.size = 0.2 + drops.position = [0.5,1] + } + + settings.tags = ["circle"] + settings.secondarySettings = [drops] + settings.position = [0.5, 1] + settings.shape = .box(width: 1, height: 0) + settings.birthRate = 5 + settings.lifespan = 0.001 + settings.speed = 0 + settings.colors = .single(.clear) + settings.size = 0 + } +} - return mainSystem - }() +#Preview { + ZStack { + VortexView(.rain) + VortexView(.splash) { + // The default circle symbol has a blur, so dont use it + Circle() + .fill(.white) + .frame(width: 16) + .tag("circle") + } + } } diff --git a/Sources/Vortex/Resources/Media.xcassets/Contents.json b/Sources/Vortex/Resources/Media.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Vortex/Resources/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Vortex/Resources/Media.xcassets/circle.imageset/Contents.json b/Sources/Vortex/Resources/Media.xcassets/circle.imageset/Contents.json new file mode 100644 index 0000000..8fc741b --- /dev/null +++ b/Sources/Vortex/Resources/Media.xcassets/circle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "circle.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets/circle.png b/Sources/Vortex/Resources/Media.xcassets/circle.imageset/circle.png similarity index 100% rename from Assets/circle.png rename to Sources/Vortex/Resources/Media.xcassets/circle.imageset/circle.png diff --git a/Sources/Vortex/Resources/Media.xcassets/confetti.imageset/Contents.json b/Sources/Vortex/Resources/Media.xcassets/confetti.imageset/Contents.json new file mode 100644 index 0000000..6b4c7c1 --- /dev/null +++ b/Sources/Vortex/Resources/Media.xcassets/confetti.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "confetti.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets/confetti.png b/Sources/Vortex/Resources/Media.xcassets/confetti.imageset/confetti.png similarity index 100% rename from Assets/confetti.png rename to Sources/Vortex/Resources/Media.xcassets/confetti.imageset/confetti.png diff --git a/Sources/Vortex/Resources/Media.xcassets/sparkle.imageset/Contents.json b/Sources/Vortex/Resources/Media.xcassets/sparkle.imageset/Contents.json new file mode 100644 index 0000000..572581a --- /dev/null +++ b/Sources/Vortex/Resources/Media.xcassets/sparkle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "sparkle.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets/sparkle.png b/Sources/Vortex/Resources/Media.xcassets/sparkle.imageset/sparkle.png similarity index 100% rename from Assets/sparkle.png rename to Sources/Vortex/Resources/Media.xcassets/sparkle.imageset/sparkle.png diff --git a/Sources/Vortex/Settings/DefaultSymbols.swift b/Sources/Vortex/Settings/DefaultSymbols.swift new file mode 100644 index 0000000..e089e79 --- /dev/null +++ b/Sources/Vortex/Settings/DefaultSymbols.swift @@ -0,0 +1,14 @@ +// +// DefaultSymbols.swift +// Vortex +// https://www.github.com/twostraws/Vortex +// See LICENSE for license information. +// + +import SwiftUI +/// Load default images from the asset catalog in the Resources folder +extension Image { + public static let circle = Image("circle", bundle: Bundle.module) + public static let confetti = Image("confetti", bundle: Bundle.module) + public static let sparkle = Image("sparkle", bundle: Bundle.module) +} diff --git a/Sources/Vortex/Settings/Settings.swift b/Sources/Vortex/Settings/Settings.swift new file mode 100644 index 0000000..5b49ee5 --- /dev/null +++ b/Sources/Vortex/Settings/Settings.swift @@ -0,0 +1,248 @@ +// +// Settings.swift +// Vortex +// https://www.github.com/twostraws/Vortex +// See LICENSE for license information. +// + +import SwiftUI + +extension VortexSystem { + + /// Contains the variables used to configure a Vortex System for use with a `VortexView`. + /// Properties:- + /// - **tags**: The list of possible tags to use for this particle system. This might be the + /// full set of tags passed into a `VortexView`, but might also be a subset if + /// secondary systems use other tags. + /// - secondarySettings: The list of secondary settings for reac secondary system that can be created. + /// Defaults to an empty array. + /// - spawnOccasion: When this particle system should be spawned. + /// This is useful only for secondary systems. Defaults to `.onBirth`. + /// - position: The current position of this particle system, in unit space. + /// Defaults to [0.5, 0.5]. + /// - shape: The shape of this particle system, which controls where particles + /// are created relative to the system's position. Defaults to `.point`. + /// - birthRate: How many particles are created every second. You can use + /// values below 1 here, e.g a birth rate of 0.2 means one particle being created + /// every 5 seconds. Defaults to 100. + /// - emissionLimit: The total number of particles this system should create. + /// A value of `nil` means no limit. Defaults to `nil`. + /// - emissionDuration: How long this system should emit particles for before + /// pausing, measured in seconds. Defaults to 1. + /// - idleDuration: How long this system should wait between particle + /// emissions, measured in seconds. Defaults to 0. + /// - burstCount: How many particles should be emitted when a burst is requested. + /// Defaults to 100. + /// - burstCountVariation: How much variation should be allowed in bursts. + /// Defaults to 0. + /// - lifespan: How long particles should live for, measured in seconds. Defaults + /// to 1. + /// - lifespanVariation: How much variation to allow in particle lifespan. + /// Defaults to 0. + /// - speed: The base rate of movement for particles. A speed of 1 means the + /// system will move from one side to the other in one second. Defaults to 1. + /// - speedVariation: How much variation to allow in particle speed. Defaults + /// to 0. + /// - angle: The base direction to launch new particles, where 0 is directly up. Defaults + /// to 0. + /// - angleRange: How much variation to use in particle launch direction. Defaults to 0. + /// - acceleration: How much acceleration to apply for particle movement. + /// Defaults to 0, meaning that no acceleration is applied. + /// - attractionCenter: A specific point particles should move towards or away + /// from, based on `attractionStrength`. A `nil` value here means + /// no attraction. Defaults to `nil`. + /// - attractionStrength: How fast to move towards `attractionCenter`, + /// when it is not `nil`. Defaults to 0 + /// - dampingFactor: How fast movement speed should be slowed down. Defaults to 0. + /// - angularSpeed: How fast particles should spin. Defaults to `[0, 0, 0]`. + /// - angularSpeedVariation: How much variation to allow in particle spin speed. + /// Defaults to `[0, 0, 0]`. + /// - colors: What colors to use for particles made by this system. If `randomRamp` + /// is used then this system picks one possible color ramp to use. Defaults to + /// `.single(.white)`. + /// - size: How large particles should be drawn, where a value of 1 means 100% + /// the image size. Defaults to 1. + /// - sizeVariation: How much variation to use for particle size. Defaults to 0 + /// - sizeMultiplierAtDeath: How how much bigger or smaller this particle should + /// be by the time it is removed. This is used as a multiplier based on the particle's initial + /// size, so if it starts at size 0.5 and has a `sizeMultiplierAtDeath` of 0.5, the + /// particle will finish at size 0.25. Defaults to 1. + /// - stretchFactor: How much to stretch this particle's image based on its movement + /// speed. Larger values cause more stretching. Defaults to 1 (no stretch). + /// + public struct Settings: Sendable, Equatable, Hashable, Identifiable, Codable { + /// Unique id. Set as variable to allow decodable conformance without compiler quibbles. + public var id: UUID = UUID() + + // These properties control system-wide behavior. + /// The current position of this particle system, in unit space. + /// Defaults to the centre. + public var position: SIMD2 = [0.5, 0.5] + + /// The list of possible tags to use for this particle system. This might be the full set of + /// tags passed into a `VortexView`, but might also be a subset if secondary systems + /// use other tags. + /// Defaults to "circle" + public var tags: [String] = ["circle"] + + /// Whether this particle system should actively be emitting right now. + public var isEmitting = true + + /// The list of secondary systems that can be created by this system. + // public var secondarySystems = [VortexSystem]() + + /// The list of secondary settings associated with this setting. + public var secondarySettings = [Settings]() + + /// When this particle system should be spawned. This is useful only for secondary systems. + /// Defaults to `.onBirth` + public var spawnOccasion: SpawnOccasion = .onBirth + + // These properties control how particles are created. + /// The shape of this particle system, which controls where particles are created relative to + /// the system's position. + /// Defaults to `.point` + public var shape: Shape = .point + + /// How many particles are created every second. You can use values below 1 here, e.g + /// a birth rate of 0.2 means one particle being created every 5 seconds. + /// Defaults to 100 + public var birthRate: Double = 100 + + /// The total number of particles this system should create. + /// The default value of `nil` means no limit. + public var emissionLimit: Int? = nil + + /// How long this system should emit particles for before pausing, measured in seconds. + /// Defaults to 1 + public var emissionDuration: TimeInterval = 1 + + /// How long this system should wait between particle emissions, measured in seconds. + /// Defaults to 0 + public var idleDuration: TimeInterval = 0 + + /// How many particles should be emitted when a burst is requested. + /// Defaults to 100 + public var burstCount: Int = 100 + + /// How much variation should be allowed in bursts. + /// Defaults to 0 + public var burstCountVariation: Int = 0 + + /// How long particles should live for, measured in seconds. + /// Defaults to 1 + public var lifespan: TimeInterval = 1 + + /// How much variation to allow in particle lifespan. + /// Defaults to 0 + public var lifespanVariation: TimeInterval = 0 + + // These properties control how particles move. + /// The base rate of movement for particles. + /// The default speed of 1 means the system will move + /// from one side to the other in one second. + public var speed: Double = 1 + + /// How much variation to allow in particle speed. + /// Defaults to 0 + public var speedVariation: Double = .zero + + /// The base direction to launch new particles, where 0 is directly up. + /// Defaults to 0 + public var angle: Angle = .zero + + /// How much variation to use in particle launch direction. + /// Defaults to 0 + public var angleRange: Angle = .zero + + /// How much acceleration to apply for particle movement. Set to 0 by default, meaning + /// that no acceleration is applied. + public var acceleration: SIMD2 = [0,0] + + /// A specific point particles should move towards or away from, based + /// on `attractionStrength`. A `nil` value here means no attraction. + public var attractionCenter: SIMD2? = nil + + /// How fast to move towards `attractionCenter`, when it is not `nil`. + /// Defaults to 0 + public var attractionStrength: Double = .zero + + /// How fast movement speed should be slowed down. + /// Defaults to 0 + public var dampingFactor: Double = 0 + + /// How fast particles should spin. + /// Defaults to zero + public var angularSpeed: SIMD3 = [0,0,0] + + /// How much variation to allow in particle spin speed. + /// Defaults to zero + public var angularSpeedVariation: SIMD3 = [0,0,0] + + // These properties determine how particles are drawn. + + + /// How large particles should be drawn, where a value of 1, the default, means 100% of the image size. + public var size: Double = 1 + + /// How much variation to use for particle size. + /// Defaults to zero + public var sizeVariation: Double = .zero + + /// How how much bigger or smaller this particle should be by the time it is removed. + /// This is used as a multiplier based on the particle's initial size, so if it starts at size + /// 0.5 and has a `sizeMultiplierAtDeath` of 0.5, the particle will finish + /// at size 0.25. + /// Defaults to zero + public var sizeMultiplierAtDeath: Double = .zero + + /// How much to stretch this particle's image based on its movement speed. Larger values + /// cause more stretching. + /// Defaults to zero + public var stretchFactor: Double = .zero + + /// What colors to use for particles made by this system. If `randomRamp` is used + /// then this system picks one possible color ramp to use. + public var colors: VortexSystem.ColorMode = .single(.white) { + didSet { + if case let .randomRamp(allColors) = colors { + self.selectedColorRamp = Int.random(in: 0..Void` The closure which will modify the setting supplied in the first parameter + /// e.g. + /// ```swift + /// let fireSettings = VortexSettings(from: .fire ) { settings in + /// settings.position = [ 0.5, 1.03] + /// settings.shape = .box(width: 1.0, height: 0) + /// settings.birthRate = 600 + /// } + /// ``` + public init(from base: VortexSystem.Settings = VortexSystem.Settings(), _ modifiedBy: @escaping (_: inout VortexSystem.Settings) -> Void ) { + // Take a copy of the base struct, and generate new id + var newSettings = base + newSettings.id = UUID() + // Amend newSettings by calling the supplied closure + modifiedBy(&newSettings) + self = newSettings + } + + /// Formerly used to make deep copies of the VortexSystem class so that secondary systems functioned correctly. + /// No longer needed, but left here for backward compatibility. + public func makeUniqueCopy() -> VortexSystem.Settings { + return self + } + } +} diff --git a/Sources/Vortex/System/Color.swift b/Sources/Vortex/System/Color.swift index 7e2ed00..0b840e6 100644 --- a/Sources/Vortex/System/Color.swift +++ b/Sources/Vortex/System/Color.swift @@ -9,7 +9,7 @@ import SwiftUI extension VortexSystem { /// A Vortex color struct that gives easy access to its RGBA values, and is also `Codable`. - public struct Color: Codable, ExpressibleByArrayLiteral, Hashable { + public struct Color: Sendable, Equatable, Hashable, Codable, ExpressibleByArrayLiteral { public var red: Double public var green: Double public var blue: Double diff --git a/Sources/Vortex/System/ColorMode.swift b/Sources/Vortex/System/ColorMode.swift index 03287ab..5c9ab07 100644 --- a/Sources/Vortex/System/ColorMode.swift +++ b/Sources/Vortex/System/ColorMode.swift @@ -9,7 +9,7 @@ import Foundation extension VortexSystem { /// Controls how colors are applied to particles inside a Vortex system. - public enum ColorMode: Codable { + public enum ColorMode: Sendable, Equatable, Hashable, Codable { /// Particles should always be created with a single color. case single(_ color: Color) @@ -40,5 +40,6 @@ extension VortexSystem { public static func randomRamp(_ colors: [Color]...) -> ColorMode { .randomRamp(colors) } + } } diff --git a/Sources/Vortex/System/Particle.swift b/Sources/Vortex/System/Particle.swift index 89496e7..664c240 100644 --- a/Sources/Vortex/System/Particle.swift +++ b/Sources/Vortex/System/Particle.swift @@ -9,7 +9,7 @@ import SwiftUI extension VortexSystem { /// One particle in a Vortex system. - public struct Particle: Hashable { + public struct Particle: Sendable, Equatable, Hashable { /// The tag for this particle, which should match one of the tags attached to /// the SwiftUI views you're passing into a `VortexView`. var tag: String diff --git a/Sources/Vortex/System/Shape.swift b/Sources/Vortex/System/Shape.swift index 2c28cfc..da01f9e 100644 --- a/Sources/Vortex/System/Shape.swift +++ b/Sources/Vortex/System/Shape.swift @@ -9,7 +9,7 @@ import Foundation extension VortexSystem { /// Controls where particles are created inside the particle system. - public enum Shape: Codable { + public enum Shape: Sendable, Equatable, Hashable, Codable { /// All particles are created from the center of the particle system. case point diff --git a/Sources/Vortex/System/SpawnOccasion.swift b/Sources/Vortex/System/SpawnOccasion.swift index da48ff4..0f2276a 100644 --- a/Sources/Vortex/System/SpawnOccasion.swift +++ b/Sources/Vortex/System/SpawnOccasion.swift @@ -9,7 +9,7 @@ import Foundation extension VortexSystem { /// Controls when secondary systems are created. - public enum SpawnOccasion: Codable { + public enum SpawnOccasion: Sendable, Equatable, Hashable, Codable { /// Creates a new system at the same time as creating a new particle. case onBirth @@ -20,3 +20,4 @@ extension VortexSystem { case onUpdate } } + diff --git a/Sources/Vortex/System/VortexSystem-Behavior.swift b/Sources/Vortex/System/VortexSystem-Behavior.swift index 0fb4f64..0ea1fab 100644 --- a/Sources/Vortex/System/VortexSystem-Behavior.swift +++ b/Sources/Vortex/System/VortexSystem-Behavior.swift @@ -24,11 +24,11 @@ extension VortexSystem { let delta = currentTimeInterval - lastUpdate lastUpdate = currentTimeInterval - if isEmitting && lastUpdate - lastIdleTime > emissionDuration { - isEmitting = false + if vortexSettings.isEmitting && lastUpdate - lastIdleTime > vortexSettings.emissionDuration { + vortexSettings.isEmitting = false lastIdleTime = lastUpdate - } else if isEmitting == false && lastUpdate - lastIdleTime > idleDuration { - isEmitting = true + } else if vortexSettings.isEmitting == false && lastUpdate - lastIdleTime > vortexSettings.idleDuration { + vortexSettings.isEmitting = true lastIdleTime = lastUpdate } @@ -38,9 +38,9 @@ extension VortexSystem { // Push attraction strength down to a small number, otherwise // it's much too strong. - let adjustedAttractionStrength = attractionStrength / 1000 + let adjustedAttractionStrength = vortexSettings.attractionStrength / 1000 - if let attractionCenter { + if let attractionCenter = vortexSettings.attractionCenter { attractionUnitPoint = [attractionCenter.x / drawSize.width, attractionCenter.y / drawSize.height] } @@ -69,18 +69,18 @@ extension VortexSystem { particle.position.x += particle.speed.x * delta * drawDivisor particle.position.y += particle.speed.y * delta - if dampingFactor != 1 { - let dampingAmount = dampingFactor * delta / lifespan + if vortexSettings.dampingFactor != 1 { + let dampingAmount = vortexSettings.dampingFactor * delta / vortexSettings.lifespan particle.speed -= particle.speed * dampingAmount } - particle.speed += acceleration * delta + particle.speed += vortexSettings.acceleration * delta particle.angle += particle.angularSpeed * delta particle.currentColor = particle.colors.lerp(by: lifeProgress) particle.currentSize = particle.initialSize.lerp( - to: particle.initialSize * sizeMultiplierAtDeath, + to: particle.initialSize * vortexSettings.sizeMultiplierAtDeath, amount: lifeProgress ) @@ -94,8 +94,11 @@ extension VortexSystem { } } + /// Create particles that should have been created between now and the last update time. (delta) + /// Note that the birthrate can be fractional, e.g. 0.2, to generate a particle every 5 seconds, + /// so keeping track of fractional outstanding particles is necessary private func createParticles(delta: Double) { - outstandingParticles += birthRate * delta + outstandingParticles += vortexSettings.birthRate * delta if outstandingParticles >= 1 { let particlesToCreate = Int(outstandingParticles) @@ -108,10 +111,11 @@ extension VortexSystem { } } + /// Update each secondary system, and remove those that have expired. private func updateSecondarySystems(date: Date, drawSize: CGSize) { for activeSecondarySystem in activeSecondarySystems { activeSecondarySystem.update(date: date, drawSize: drawSize) - + // If a system has emitted particles, but currently has none, remove it. if activeSecondarySystem.particles.isEmpty && activeSecondarySystem.emissionCount > 0 { activeSecondarySystems.remove(activeSecondarySystem) } @@ -122,19 +126,19 @@ extension VortexSystem { /// - Parameter force: When true, this will create a particle even if /// this system has already reached its emission limit. func createParticle(force: Bool = false) { - guard isEmitting else { return } + guard vortexSettings.isEmitting else { return } - if let emissionLimit { + if let emissionLimit = vortexSettings.emissionLimit { if emissionCount >= emissionLimit && force == false { return } } // We subtract half of pi here to ensure that angle 0 is directly up. - let launchAngle = angle.radians + angleRange.radians.randomSpread() - .pi / 2 - let launchSpeed = speed + speedVariation.randomSpread() - let lifespan = lifespan + lifespanVariation.randomSpread() - let size = size + sizeVariation.randomSpread() + let launchAngle = vortexSettings.angle.radians + vortexSettings.angleRange.radians.randomSpread() - .pi / 2 + let launchSpeed = vortexSettings.speed + vortexSettings.speedVariation.randomSpread() + let lifespan = vortexSettings.lifespan + vortexSettings.lifespanVariation.randomSpread() + let size = vortexSettings.size + vortexSettings.sizeVariation.randomSpread() let particlePosition = getNewParticlePosition() let speed = SIMD2( @@ -142,11 +146,11 @@ extension VortexSystem { sin(launchAngle) * launchSpeed ) - let spinSpeed = angularSpeed + angularSpeedVariation.randomSpread() + let spinSpeed = vortexSettings.angularSpeed + vortexSettings.angularSpeedVariation.randomSpread() let colorRamp = getNewParticleColorRamp() let newParticle = Particle( - tag: tags.randomElement() ?? "", + tag: vortexSettings.tags.randomElement() ?? "", position: particlePosition, speed: speed, birthTime: lastUpdate, @@ -163,7 +167,7 @@ extension VortexSystem { /// Force a bunch of particles to be created immediately. func burst() { - let particlesToCreate = burstCount + burstCountVariation.randomSpread() + let particlesToCreate = vortexSettings.burstCount + vortexSettings.burstCountVariation.randomSpread() for _ in 0.. SIMD2 { - switch shape { + switch vortexSettings.shape { case .point: - return position + return vortexSettings.position case .box(let width, let height): return [ - position.x + width.randomSpread(), - position.y + height.randomSpread() + vortexSettings.position.x + width.randomSpread(), + vortexSettings.position.y + height.randomSpread() ] case .ellipse(let radius): @@ -197,22 +203,22 @@ extension VortexSystem { let placement = Double.random(in: 0...radius / 2) return [ - placement * cos(angle) + position.x, - placement * sin(angle) + position.y + placement * cos(angle) + vortexSettings.position.x, + placement * sin(angle) + vortexSettings.position.y ] case .ring(let radius): let angle = Double.random(in: 0...(2 * .pi)) - + let placement = radius / 2 return [ - radius / 2 * cos(angle) + position.x, - radius / 2 * sin(angle) + position.y + placement * cos(angle) + vortexSettings.position.x, + placement * sin(angle) + vortexSettings.position.y ] } } func getNewParticleColorRamp() -> [Color] { - switch colors { + switch vortexSettings.colors { case .single(let color): return [color] @@ -227,7 +233,7 @@ extension VortexSystem { return colors case .randomRamp(let colors): - return colors[selectedColorRamp] + return colors[vortexSettings.selectedColorRamp] } } } diff --git a/Sources/Vortex/System/VortexSystem.swift b/Sources/Vortex/System/VortexSystem.swift index 0ff791f..4a2ea91 100644 --- a/Sources/Vortex/System/VortexSystem.swift +++ b/Sources/Vortex/System/VortexSystem.swift @@ -8,20 +8,24 @@ import SwiftUI /// The main particle system generator class that powers Vortex. -public class VortexSystem: Codable, Equatable, Hashable { +@dynamicMemberLookup +public class VortexSystem: Codable, Identifiable, Equatable, Hashable { + /// The subset of properties we need to load and save to handle Codable correctly. - enum CodingKeys: CodingKey { - case tags, secondarySystems, spawnOccasion, position, shape, birthRate, emissionLimit, emissionDuration - case idleDuration, burstCount, burstCountVariation, lifespan, lifespanVariation, speed, speedVariation, angle - case angleRange, acceleration, attractionCenter, attractionStrength, dampingFactor, angularSpeed - case angularSpeedVariation, colors, size, sizeVariation, sizeMultiplierAtDeath, stretchFactor + enum CodingKeys: CodingKey { case vortexSettings } + + subscript(dynamicMember keyPath: KeyPath) -> T { + vortexSettings[keyPath: keyPath] } + /// A random identifier for Identifiable conformance, that gives us Equatable and Hashable conformances by default. + public let id = UUID() - /// A random identifier that lets us create Equatable and Hashable conformances easily. - let id = UUID() - + // These properties determine how particles are drawn. + var vortexSettings: VortexSystem.Settings + // These properties are used for managing a live system, rather // than for configuration purposes. + /// How many particles are waiting to be created. This is particularly useful when /// working with a birth rate below 1, where we want to create one particle every 5 /// seconds for example. @@ -31,9 +35,6 @@ public class VortexSystem: Codable, Equatable, Hashable { /// emission, where particles are fired, then paused, then fired again, etc. var lastIdleTime = Date.now.timeIntervalSince1970 - /// The color ramp being used by particles in this particle system. - var selectedColorRamp = 0 - /// The last time this particle system was updated. var lastUpdate = Date.now.timeIntervalSince1970 @@ -49,355 +50,26 @@ public class VortexSystem: Codable, Equatable, Hashable { /// The last size at which this particle system was drawn. Used to let us move /// to a particle screen location on demand. var lastDrawSize = CGSize.zero - - // These properties control system-wide behavior. - /// The current position of this particle system, in unit space. - public var position: SIMD2 - - /// The list of possible tags to use for this particle system. This might be the full set of - /// tags passed into a `VortexView`, but might also be a subset if secondary systems - /// use other tags. - public var tags = [String]() - - /// Whether this particle system should actively be emitting right now. - public var isEmitting = true - - /// The list of secondary systems that can be created by this system. - public var secondarySystems = [VortexSystem]() - - /// When this particle system should be spawned. This is useful only for secondary systems. - public var spawnOccasion: SpawnOccasion - - // These properties control how particles are created. - /// The shape of this particle system, which controls where particles are created relative to - /// the system's position. - public var shape: Shape - - /// How many particles are created every second. You can use values below 1 here, e.g - /// a birth rate of 0.2 means one particle being created every 5 seconds. - public var birthRate: Double - - /// The total number of particles this system should create. A value of `nil` means no limit. - public var emissionLimit: Int? - - /// How long this system should emit particles for before pausing, measured in seconds. - public var emissionDuration: TimeInterval - - /// How long this system should wait between particle emissions, measured in seconds. - public var idleDuration: TimeInterval - - /// How many particles should be emitted when a burst is requested. - public var burstCount: Int - - /// How much variation should be allowed in bursts. - public var burstCountVariation: Int - - /// How long particles should live for, measured in seconds. - public var lifespan: TimeInterval - - /// How much variation to allow in particle lifespan. - public var lifespanVariation: TimeInterval - - // These properties control how particles move. - /// The base rate of movement for particles. A speed of 1 means the system will move - /// from one side to the other in one second. - public var speed: Double - - /// How much variation to allow in particle speed. - public var speedVariation: Double - - /// The base direction to launch new particles, where 0 is directly up. - public var angle: Angle - - /// How much variation to use in particle launch direction. - public var angleRange: Angle - - /// How much acceleration to apply for particle movement. Set to 0 by default, meaning - /// that no acceleration is applied. - public var acceleration: SIMD2 - - /// A specific point particles should move towards or away from, based - /// on `attractionStrength`. A `nil` value here means no attraction. - public var attractionCenter: SIMD2? - - /// How fast to move towards `attractionCenter`, when it is not `nil`. - public var attractionStrength: Double - - /// How fast movement speed should be slowed down. - public var dampingFactor: Double - - /// How fast particles should spin. - public var angularSpeed: SIMD3 - - /// How much variation to allow in particle spin speed. - public var angularSpeedVariation: SIMD3 - - // These properties determine how particles are drawn. - /// What colors to use for particles made by this system. If `randomRamp` is used - /// then this system picks one possible color ramp to use. - public var colors: ColorMode { - didSet { - if case let .randomRamp(allColors) = colors { - self.selectedColorRamp = Int.random(in: 0.. = [0.5, 0.5], - shape: Shape = .point, - birthRate: Double = 100, - emissionLimit: Int? = nil, - emissionDuration: Double = 1, - idleDuration: Double = 0, - burstCount: Int = 100, - burstCountVariation: Int = 0, - lifespan: TimeInterval = 1, - lifespanVariation: TimeInterval = 0, - speed: Double = 1, - speedVariation: Double = 0, - angle: Angle = .zero, - angleRange: Angle = .zero, - acceleration: SIMD2 = [0, 0], - attractionCenter: SIMD2? = nil, - attractionStrength: Double = 0, - dampingFactor: Double = 0, - angularSpeed: SIMD3 = [0, 0, 0], - angularSpeedVariation: SIMD3 = [0, 0, 0], - colors: ColorMode = .single(.white), - size: Double = 1, - sizeVariation: Double = 0, - sizeMultiplierAtDeath: Double = 1, - stretchFactor: Double = 1 - ) { - self.tags = tags - self.secondarySystems = secondarySystems - self.spawnOccasion = spawnOccasion - self.position = position - self.shape = shape - self.birthRate = birthRate - self.emissionLimit = emissionLimit - self.emissionDuration = emissionDuration - self.idleDuration = idleDuration - self.burstCount = burstCount - self.burstCountVariation = burstCountVariation - self.lifespan = lifespan - self.lifespanVariation = lifespanVariation - self.speed = speed - self.speedVariation = speedVariation - self.angle = angle - self.acceleration = acceleration - self.angleRange = angleRange - self.attractionCenter = attractionCenter - self.attractionStrength = attractionStrength - self.dampingFactor = dampingFactor - self.angularSpeed = angularSpeed - self.angularSpeedVariation = angularSpeedVariation - self.colors = colors - self.size = size - self.sizeVariation = sizeVariation - self.sizeMultiplierAtDeath = sizeMultiplierAtDeath - self.stretchFactor = stretchFactor - - if case let .randomRamp(allColors) = colors { - selectedColorRamp = Int.random(in: 0...self, forKey: .position) - shape = try container.decode(Shape.self, forKey: .shape) - birthRate = try container.decode(Double.self, forKey: .birthRate) - emissionLimit = try container.decodeIfPresent(Int.self, forKey: .emissionLimit) - emissionDuration = try container.decode(Double.self, forKey: .emissionDuration) - idleDuration = try container.decode(Double.self, forKey: .idleDuration) - burstCount = try container.decode(Int.self, forKey: .burstCount) - burstCountVariation = try container.decode(Int.self, forKey: .burstCountVariation) - lifespan = try container.decode(Double.self, forKey: .lifespan) - lifespanVariation = try container.decode(Double.self, forKey: .lifespanVariation) - speed = try container.decode(Double.self, forKey: .speed) - speedVariation = try container.decode(Double.self, forKey: .speedVariation) - - let angleRadians = try container.decode(Double.self, forKey: .angle) - angle = Angle(radians: angleRadians) - - let angleRangeRadians = try container.decode(Double.self, forKey: .angleRange) - angleRange = Angle(radians: angleRangeRadians) - - acceleration = try container.decode(SIMD2.self, forKey: .acceleration) - attractionCenter = try container.decodeIfPresent(SIMD2.self, forKey: .attractionCenter) - attractionStrength = try container.decode(Double.self, forKey: .attractionStrength) - dampingFactor = try container.decode(Double.self, forKey: .dampingFactor) - angularSpeed = try container.decode(SIMD3.self, forKey: .angularSpeed) - angularSpeedVariation = try container.decode(SIMD3.self, forKey: .angularSpeedVariation) - colors = try container.decode(ColorMode.self, forKey: .colors) - size = try container.decode(Double.self, forKey: .size) - sizeVariation = try container.decode(Double.self, forKey: .sizeVariation) - sizeMultiplierAtDeath = try container.decode(Double.self, forKey: .sizeMultiplierAtDeath) - stretchFactor = try container.decode(Double.self, forKey: .stretchFactor) - } - - /// Support for `Codable` to make it easier to create an editor UI in the future. - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(tags, forKey: .tags) - try container.encode(secondarySystems, forKey: .secondarySystems) - try container.encode(spawnOccasion, forKey: .spawnOccasion) - try container.encode(position, forKey: .position) - try container.encode(shape, forKey: .shape) - try container.encode(birthRate, forKey: .birthRate) - try container.encode(emissionLimit, forKey: .emissionLimit) - try container.encode(emissionDuration, forKey: .emissionDuration) - try container.encode(idleDuration, forKey: .idleDuration) - try container.encode(burstCount, forKey: .burstCount) - try container.encode(burstCountVariation, forKey: .burstCountVariation) - try container.encode(lifespan, forKey: .lifespan) - try container.encode(lifespanVariation, forKey: .lifespanVariation) - try container.encode(speed, forKey: .speed) - try container.encode(speedVariation, forKey: .speedVariation) - try container.encode(angle.radians, forKey: .angle) - try container.encode(angleRange.radians, forKey: .angleRange) - try container.encode(acceleration, forKey: .acceleration) - try container.encode(attractionCenter, forKey: .attractionCenter) - try container.encode(attractionStrength, forKey: .attractionStrength) - try container.encode(dampingFactor, forKey: .dampingFactor) - try container.encode(angularSpeed, forKey: .angularSpeed) - try container.encode(angularSpeedVariation, forKey: .angularSpeedVariation) - try container.encode(colors, forKey: .colors) - try container.encode(size, forKey: .size) - try container.encode(sizeVariation, forKey: .sizeVariation) - try container.encode(sizeMultiplierAtDeath, forKey: .sizeMultiplierAtDeath) - try container.encode(stretchFactor, forKey: .stretchFactor) - } - - /// Two particle systems are the same if they they have same identifier. - public static func == (lhs: VortexSystem, rhs: VortexSystem) -> Bool { - lhs.id == rhs.id - } - - /// The hash value for this system is simply its identifier. - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - /// Because particle systems are classes rather than structs, we need to make deep - /// copies by hand. This is important when creating a secondary system. This method - /// copies all values across. + /// Formerly used to make deep copies of the VortexSystem class so that secondary systems functioned correctly. + /// No longer needed, but left here for backward compatibility. public func makeUniqueCopy() -> VortexSystem { - VortexSystem( - tags: tags, - secondarySystems: secondarySystems, - spawnOccasion: spawnOccasion, - position: position, - shape: shape, - birthRate: birthRate, - emissionLimit: emissionLimit, - emissionDuration: emissionDuration, - idleDuration: idleDuration, - burstCount: burstCount, - burstCountVariation: burstCountVariation, - lifespan: lifespan, - lifespanVariation: lifespanVariation, - speed: speed, - speedVariation: speedVariation, - angle: angle, - angleRange: angleRange, - acceleration: acceleration, - attractionCenter: attractionCenter, - attractionStrength: attractionStrength, - dampingFactor: dampingFactor, - angularSpeed: angularSpeed, - angularSpeedVariation: angularSpeedVariation, - colors: colors, - size: size, - sizeVariation: sizeVariation, - sizeMultiplierAtDeath: sizeMultiplierAtDeath, - stretchFactor: stretchFactor - ) - } + return self + } } diff --git a/Sources/Vortex/Views/VortexProxy.swift b/Sources/Vortex/Views/VortexProxy.swift index 3694548..d94482a 100644 --- a/Sources/Vortex/Views/VortexProxy.swift +++ b/Sources/Vortex/Views/VortexProxy.swift @@ -26,7 +26,7 @@ public struct VortexProxy { public func move(to newPosition: CGPoint) { guard let particleSystem else { return } - particleSystem.position = [ + particleSystem.vortexSettings.position = [ newPosition.x / particleSystem.lastDrawSize.width, newPosition.y / particleSystem.lastDrawSize.height ] diff --git a/Sources/Vortex/Views/VortexView.swift b/Sources/Vortex/Views/VortexView.swift index 6e53129..2ec6a95 100644 --- a/Sources/Vortex/Views/VortexView.swift +++ b/Sources/Vortex/Views/VortexView.swift @@ -6,18 +6,20 @@ // import SwiftUI +import OSLog /// A SwiftUI view that renders a Vortex particle system. public struct VortexView: View where Symbols: View { + /// The list of SwiftUI views that should be used to draw particles. @ViewBuilder var symbols: Symbols - + /// The primary system this is responsible for drawing. @State private var particleSystem: VortexSystem - + /// The ideal frame rate for updating particles. Using lower frame rates saves CPU time. public var targetFrameRate: Int - + public var body: some View { TimelineView(.animation(minimumInterval: 1 / Double(targetFrameRate))) { timeline in Canvas { context, size in @@ -29,18 +31,34 @@ public struct VortexView: View where Symbols: View { } .preference(key: VortexSystemPreferenceKey.self, value: particleSystem) } - + /// Creates a new VortexView from a pre-configured particle system, along with all the SwiftUI - /// views to render as particles. + /// views to render as particles. Sensible defaults will be used if no parameters are passed. /// - Parameters: - /// - system: The primary particle system you want to render. - /// - symbols: A list of SwiftUI views to use as particles. - public init(_ system: VortexSystem, targetFrameRate: Int = 60, @ViewBuilder symbols: () -> Symbols) { - _particleSystem = State(initialValue: system) + /// - settings: A vortexSettings struct that should be used to generate a particle system. + /// Typically this will be set using a preset static struct, e.g. `.fire`. Defaults to a simple system. + /// - targetFrameRate: The ideal frame rate for updating particles. Defaults to 60 if not specified. (use 120 on Pro model iPhones/iPads ) + /// - symbols: A closure that should return a tagged group of SwiftUI views to use as particles. + /// If not specified, a default group of three views tagged with `.circle`,`.triangle` and `.sparkle` will be used. + public init( + _ settings: VortexSystem.Settings = .init(), + targetFrameRate: Int = 60, + @ViewBuilder symbols: () -> Symbols = { + Group { + Image.circle + .frame(width: 16).blendMode(.plusLighter).tag("circle") + Image.confetti + .frame(width: 16, height: 16).blendMode(.plusLighter).tag("triangle") + Image.sparkle + .frame(width: 16, height: 16).blendMode(.plusLighter).tag("sparkle") + } + } + ) { + _particleSystem = State( initialValue: VortexSystem(settings) ) self.targetFrameRate = targetFrameRate self.symbols = symbols() } - + /// Draws one particle system inside the canvas. /// - Parameters: /// - particleSystem: The particle system to draw. @@ -49,8 +67,12 @@ public struct VortexView: View where Symbols: View { private func draw(_ particleSystem: VortexSystem, into context: GraphicsContext, at size: CGSize) { for particle in particleSystem.particles { // Find the appropriate tag for this particle. + // If it's not found, generate an error message in the console log guard let symbol = context.resolveSymbol(id: particle.tag) else { - print("VortexView: Unable to locate symbol named \(particle.tag).") + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Vortex", category: "SymbolError") + logger .error( "VortexView: Unable to locate symbol named \(particle.tag, privacy: .public)." ) + // Currently, a logger call within this scenario does not output to XCode Preview console, so... print :( + print("VortexView: Unable to locate symbol named \(particle.tag)" ) continue } diff --git a/Sources/Vortex/Views/VortexViewReader.swift b/Sources/Vortex/Views/VortexViewReader.swift index 41a11cc..8a1b193 100644 --- a/Sources/Vortex/Views/VortexViewReader.swift +++ b/Sources/Vortex/Views/VortexViewReader.swift @@ -26,9 +26,9 @@ public struct VortexViewReader: View { nearestVortexSystem?.burst() } attractTo: { point in if let point { - nearestVortexSystem?.attractionCenter = SIMD2(point.x, point.y) + nearestVortexSystem?.vortexSettings.attractionCenter = SIMD2(point.x, point.y) } else { - nearestVortexSystem?.attractionCenter = nil + nearestVortexSystem?.vortexSettings.attractionCenter = nil } }