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

@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