Skip to content

Instantly share code, notes, and snippets.

@uvolchyk
Last active November 14, 2024 01:30
Show Gist options
  • Save uvolchyk/d7c29dc3eb47f5e109ccb6cf102a7421 to your computer and use it in GitHub Desktop.
Save uvolchyk/d7c29dc3eb47f5e109ccb6cf102a7421 to your computer and use it in GitHub Desktop.
import SwiftUI
extension View where Self: Shape {
func glow(
fill: some ShapeStyle,
lineWidth: Double,
blurRadius: Double = 8.0,
lineCap: CGLineCap = .round
) -> some View {
self
.stroke(style: StrokeStyle(lineWidth: lineWidth / 2, lineCap: lineCap))
.fill(fill)
.overlay {
self
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: lineCap))
.fill(fill)
.blur(radius: blurRadius)
}
.overlay {
self
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: lineCap))
.fill(fill)
.blur(radius: blurRadius / 2)
}
}
}
extension ShapeStyle where Self == AngularGradient {
static var palette: some ShapeStyle {
.angularGradient(
stops: [
.init(color: .blue, location: 0.0),
.init(color: .purple, location: 0.2),
.init(color: .red, location: 0.4),
.init(color: .mint, location: 0.5),
.init(color: .indigo, location: 0.7),
.init(color: .pink, location: 0.9),
.init(color: .blue, location: 1.0),
],
center: .center,
startAngle: Angle(radians: .zero),
endAngle: Angle(radians: .pi * 2)
)
}
}
import SwiftUI
import MetalKit
struct Particle {
let color: SIMD4<Float>
let radius: Float
let lifespan: Float
let position: SIMD2<Float>
let velocity: SIMD2<Float>
}
struct ParticleCloudInfo {
let center: SIMD2<Float>
let progress: Float
}
struct ParticleCloud: UIViewRepresentable {
let center: CGPoint?
let progress: Float
private let metalView = MTKView()
func makeUIView(
context: Context
) -> MTKView {
context.coordinator.progress = progress
return metalView
}
func updateUIView(
_ view: MTKView,
context: Context
) {
context.coordinator.progress = progress
guard let center else { return }
let bounds = view.bounds
context.coordinator.center = CGPoint(
x: center.x / bounds.width,
y: center.y / bounds.height
)
}
func makeCoordinator() -> Renderer {
Renderer(metalView: metalView)
}
}
final class Renderer: NSObject {
var center = CGPoint(x: 0.5, y: 0.5)
var progress: Float = 0.0 {
didSet {
metalView?.isPaused = progress == .zero
}
}
private weak var metalView: MTKView?
private let commandQueue: MTLCommandQueue
private let cleanState: MTLComputePipelineState
private let drawState: MTLComputePipelineState
private var particleBuffer: MTLBuffer!
var particleCount: Int = 32
var colors: [SIMD4<Float>] = Array(
repeating: .init(
Float.random(in: 0.0..<0.3),
Float.random(in: 0.3..<0.7),
Float.random(in: 0.7..<1.0),
1.0
),
count: 3
)
init(metalView: MTKView) {
self.metalView = metalView
guard
let device = MTLCreateSystemDefaultDevice(),
let commandQueue = device.makeCommandQueue()
else {
fatalError("GPU not available")
}
self.commandQueue = commandQueue
do {
let library = try device.makeDefaultLibrary(bundle: .main)
let clearFunc = library.makeFunction(
name: "cleanScreen"
)!
let drawFunc = library.makeFunction(
name: "drawParticles"
)!
cleanState = try device.makeComputePipelineState(
function: clearFunc
)
drawState = try device.makeComputePipelineState(
function: drawFunc
)
} catch {
fatalError("Library not available: \(error)")
}
super.init()
let particles: [Particle] = (0..<particleCount).map { i in
let vx = Float(5.0)
let vy = Float(5.0)
return Particle(
color: colors[i % colors.count],
radius: Float.random(in: 4..<30),
lifespan: .zero,
position: SIMD2<Float>(.zero, .zero),
velocity: SIMD2<Float>(vx, vy)
)
}
particleBuffer = device.makeBuffer(
bytes: particles,
length: MemoryLayout<Particle>.stride * particleCount
)
metalView.device = device
metalView.delegate = self
metalView.framebufferOnly = false
metalView.backgroundColor = .clear
}
}
extension Renderer: MTKViewDelegate {
func draw(in view: MTKView) {
guard let drawable = view.currentDrawable else { return }
let texture = drawable.texture
let commandBuffer = commandQueue.makeCommandBuffer()
let commandEncoder = commandBuffer?.makeComputeCommandEncoder()
commandEncoder?.setTexture(texture, index: 0)
commandEncoder?.setComputePipelineState(cleanState)
let w = cleanState.threadExecutionWidth
let h = cleanState.maxTotalThreadsPerThreadgroup / w
commandEncoder?.dispatchThreads(
MTLSize(
width: texture.width,
height: texture.height,
depth: 1
),
threadsPerThreadgroup: MTLSize(
width: w,
height: h,
depth: 1
)
)
commandEncoder?.setComputePipelineState(drawState)
commandEncoder?.setBuffer(
particleBuffer,
offset: 0,
index: 0
)
var info = ParticleCloudInfo(
center: SIMD2<Float>(Float(center.x), Float(center.y)),
progress: progress
)
commandEncoder?.setBytes(
&info,
length: MemoryLayout<ParticleCloudInfo>.stride,
index: 1
)
commandEncoder?.dispatchThreads(
MTLSize(
width: particleCount,
height: 1,
depth: 1
),
threadsPerThreadgroup: MTLSize(
width: drawState.threadExecutionWidth,
height: 1,
depth: 1
)
)
commandEncoder?.endEncoding()
commandBuffer?.present(drawable)
commandBuffer?.commit()
}
func mtkView(
_ view: MTKView,
drawableSizeWillChange size: CGSize
) {}
}
import SwiftUI
struct ProgressiveGlow: ViewModifier {
let origin: CGPoint
let progress: Double
func body(content: Content) -> some View {
content.visualEffect { view, proxy in
view.colorEffect(
ShaderLibrary.default.glow(
.float2(origin),
.float2(proxy.size),
.float(3.0),
.float(progress)
)
)
}
}
}
#include <SwiftUI/SwiftUI.h>
#include <metal_stdlib>
using namespace metal;
[[ stitchable ]]
half4 glow(
float2 position,
half4 color,
float2 origin,
float2 size,
float amplitude,
float progress
) {
float2 uv_position = position / size;
float2 uv_origin = origin / size;
float distance = length(uv_position - uv_origin);
float glowIntensity = smoothstep(0.0, 1.0, progress) * exp(-distance * distance) * amplitude;
glowIntensity *= smoothstep(0.0, 1.0, (1.0 - distance / progress));
return color * glowIntensity;
}
[[ stitchable ]]
half4 ripple(
float2 position,
SwiftUI::Layer layer,
float2 origin,
float time,
float amplitude,
float frequency,
float decay,
float speed
) {
// The distance of the current pixel position from `origin`.
float distance = length(position - origin);
// The amount of time it takes for the ripple to arrive at the current pixel position.
float delay = distance / speed;
// Adjust for delay, clamp to 0.
time = max(0.0, time - delay);
// The ripple is a sine wave that Metal scales by an exponential decay function.
float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time);
// A vector of length `amplitude` that points away from position.
float2 n = normalize(position - origin);
// Scale `n` by the ripple amount at the current pixel position and add it
// to the current pixel position.
//
// This new position moves toward or away from `origin` based on the
// sign and magnitude of `rippleAmount`.
float2 newPosition = position + rippleAmount * n;
// Sample the layer at the new position.
half4 color = layer.sample(newPosition);
// Lighten or darken the color based on the ripple amount and its alpha component.
color.rgb += (rippleAmount / amplitude) * color.a;
return color;
}
struct Particle {
float4 color;
float radius;
float lifespan;
float2 position;
float2 velocity;
};
struct ParticleCloudInfo {
float2 center;
float progress;
};
kernel void cleanScreen (
texture2d<half, access::write> output [[ texture(0) ]],
uint2 id [[ thread_position_in_grid ]]
) {
output.write(half4(0), id);
}
float rand(int passSeed)
{
int seed = 57 + passSeed * 241;
seed = (seed << 13) ^ seed;
seed = (seed * (seed * seed * 15731 + 789221) + 1376312589) & 2147483647;
seed = (seed * (seed * seed * 48271 + 39916801) + 2147483647) & 2147483647;
return ((1.f - (seed / 1073741824.0f)) + 1.0f) / 2.0f;
}
kernel void drawParticles (
texture2d<half, access::write> output [[ texture(0) ]],
device Particle *particles [[ buffer(0) ]],
constant ParticleCloudInfo &info [[ buffer(1) ]],
uint id [[ thread_position_in_grid ]]
) {
float2 uv_center = info.center;
float width = output.get_width();
float height = output.get_height();
float2 center = float2(width * uv_center.x, height * uv_center.y);
Particle particle = particles[id];
float lifespan = particle.lifespan;
float2 position = particle.position;
float2 velocity = particle.velocity;
if (
length(center - position) < 20.0 ||
position.x == 0.0 && position.y == 0.0 ||
lifespan > 100
) {
position = float2(rand(id) * width, rand(id + 1) * height);
lifespan = 0;
} else {
float2 direction = normalize(center - position);
position += direction * length(velocity);
lifespan += 1;
}
particle.lifespan = lifespan;
particle.position = position;
particles[id] = particle;
half4 color = half4(particle.color) * (lifespan / 100) * info.progress;
uint2 pos = uint2(position.x, position.y);
for (int y = -100; y < 100; y++) {
for (int x = -100; x < 100; x++) {
float s_radius = x * x + y * y;
if (sqrt(s_radius) <= particle.radius * info.progress) {
output.write(color, pos + uint2(x, y));
}
}
}
}
import SwiftUI
struct ReactiveControl: View {
private enum DragState {
case inactive
case dragging
}
@State private var glowAnimationID: UUID?
@State private var rippleAnimationID: UUID?
@State private var rippleLocation: CGPoint?
@GestureState private var dragState: DragState = .inactive
@State private var dragLocation: CGPoint?
var body: some View {
GeometryReader { proxy in
ZStack {
let rippleLocation = rippleLocation ?? .zero
let dragLocation = dragLocation ?? .zero
Capsule()
.fill(.black)
.keyframeAnimator(
initialValue: 0,
trigger: rippleAnimationID,
content: { view, elapsedTime in
view.modifier(
RippleModifier(
origin: rippleLocation,
elapsedTime: elapsedTime,
duration: 1.0,
amplitude: 2.0,
frequency: 4.0,
decay: 10.0,
speed: 800.0
)
)
},
keyframes: { _ in
MoveKeyframe(.zero)
LinearKeyframe(
1.0,
duration: 2.0
)
}
)
.sensoryFeedback(
.impact,
trigger: rippleAnimationID
)
KeyframeAnimator(
initialValue: 0.0,
trigger: glowAnimationID
) { value in
ParticleCloud(
center: dragLocation,
progress: Float(value)
)
.clipShape(Capsule())
} keyframes: { _ in
if glowAnimationID != nil {
MoveKeyframe(.zero)
LinearKeyframe(
1.0,
duration: 0.4
)
} else {
MoveKeyframe(1.0)
LinearKeyframe(
.zero,
duration: 0.4
)
}
}
Capsule()
.strokeBorder(
Color.white,
style: .init(lineWidth: 1.0)
)
Capsule()
.glow(fill: .palette, lineWidth: 4.0)
.keyframeAnimator(
initialValue: .zero,
trigger: glowAnimationID,
content: { view, elapsedTime in
view.modifier(
ProgressiveGlow(
origin: dragLocation,
progress: elapsedTime
)
)
},
keyframes: { _ in
if glowAnimationID != nil {
MoveKeyframe(.zero)
LinearKeyframe(
1.0,
duration: 0.4
)
} else {
MoveKeyframe(1.0)
LinearKeyframe(
.zero,
duration: 0.4
)
}
}
)
}
.gesture(
DragGesture(
minimumDistance: .zero
)
.updating(
$dragState,
body: { gesture, state, _ in
switch state {
case .inactive:
dragLocation = gesture.location
glowAnimationID = UUID()
rippleAnimationID = UUID()
rippleLocation = gesture.location
state = .dragging
case .dragging:
let location = gesture.location
let size = proxy.size
dragLocation = CGPoint(
x: location.x.clamp(
min: .zero,
max: size.width
),
y: location.y.clamp(
min: .zero,
max: size.height
)
)
}
}
)
.onEnded { _ in
glowAnimationID = nil
}
)
}
}
}
extension Comparable where Self: AdditiveArithmetic {
func clamp(min: Self, max: Self) -> Self {
if self < min { return min }
if self > max { return max }
return self
}
}
#Preview {
ZStack {
Color.black.ignoresSafeArea()
ReactiveControl()
.frame(
width: 240.0,
height: 100.0
)
}
}
import SwiftUI
struct RippleModifier: ViewModifier {
let origin: CGPoint
let elapsedTime: TimeInterval
let duration: TimeInterval
let amplitude: Double
let frequency: Double
let decay: Double
let speed: Double
func body(content: Content) -> some View {
let shader = ShaderLibrary.default.ripple(
.float2(origin),
.float(elapsedTime),
.float(amplitude),
.float(frequency),
.float(decay),
.float(speed)
)
let maxSampleOffset = CGSize(
width: amplitude,
height: amplitude
)
let elapsedTime = elapsedTime
let duration = duration
content.visualEffect { view, _ in
view.layerEffect(
shader,
maxSampleOffset: maxSampleOffset,
isEnabled: 0...duration ~= elapsedTime
)
}
}
}
@uvolchyk
Copy link
Author

Result

animation-4.mov

@mirkokg
Copy link

mirkokg commented Oct 23, 2024

Type 'Bundle' has no member 'module'

@concentrat1on
Copy link

concentrat1on commented Oct 23, 2024

Thanks for the code but could you please make it work? For now it throws an error in console and doesn't response to touches
Modifying state during view update, this will cause undefined behavior.

@uvolchyk
Copy link
Author

@concentrat1on could you please share more details?
I run my code on iOS 18 / Xcode 16 and have not encountered this issue.

@concentrat1on
Copy link

concentrat1on commented Oct 24, 2024

@uvolchyk just tested on Xcode 16, works fire, it seems the problem with Xcode 15.2 which is super weird. In both cases used the same phone with iOS 18.0.1

@uvolchyk
Copy link
Author

This SwiftUI 🗿
Thank you for pointing out, I’ll check it then

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment