Last active
November 14, 2024 01:30
-
-
Save uvolchyk/d7c29dc3eb47f5e109ccb6cf102a7421 to your computer and use it in GitHub Desktop.
Source code for the article: https://uvolchyk.medium.com/sparkling-shiny-things-with-metal-and-swiftui-cba69c730a24
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
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) | |
) | |
} | |
} |
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
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 | |
) {} | |
} |
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
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) | |
) | |
) | |
} | |
} | |
} |
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
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 | |
) | |
} | |
} |
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
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 | |
) | |
} | |
} | |
} |
Type 'Bundle' has no member 'module'
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.
@concentrat1on could you please share more details?
I run my code on iOS 18 / Xcode 16 and have not encountered this issue.
@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
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
Result
animation-4.mov