SwiftUI Spoiler View with particles


Greetings, traveler!

CAEmitterLayer is a valuable tool for creating particle effects like smoke, fire, snow, etc. Today, we will use it to make a spoiler view with particles in SwiftUI.

First, let’s create a UIView subclass to use CAEmitterLayer. We will override the layerClass property with CAEmitterLayer. Then, we must override the layer property to use it in the layoutSubviews function to specify the emitter position and size.

final class EmitterView: UIView {

    override class var layerClass: AnyClass { CAEmitterLayer.self }

    override var layer: CAEmitterLayer {
        super.layer as! CAEmitterLayer
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        
        layer.emitterPosition = .init(
            x: bounds.size.width / 2,
            y: bounds.size.height / 2
        )
        layer.emitterSize = bounds.size
    }
    
}

Now, we can create a SwiftUI View with the UIViewRepresentable protocol.

import SwiftUI

struct SpoilerView: UIViewRepresentable {

    var isEnabled: Bool
    var numberOfParticles: Float = 8000

    func makeUIView(context: Context) -> EmitterView {
        let emitterView = EmitterView()

        let emitterCell = CAEmitterCell()
        emitterCell.contents = UIImage(named: "white-particle")?.cgImage
        emitterCell.contentsScale = 2
        emitterCell.emissionRange = .pi * 2
        emitterCell.velocityRange = 20
        emitterCell.lifetime = 1
        emitterCell.alphaRange = 1
        emitterCell.birthRate = numberOfParticles
        emitterCell.scale = 0.02
        emitterCell.alphaRange = 1
        
        emitterView.layer.emitterShape = .rectangle
        emitterView.layer.emitterCells = [emitterCell]

        return emitterView
    }
    
}

Here, we will create and configure our EmitterView. We will use a white circle image as a particle and specify the number of particles.

We can use a bool variable to manage spoiler status. We will handle its visibility in the updateView function.

import SwiftUI

struct SpoilerView: UIViewRepresentable {

    var isEnabled: Bool
    var numberOfParticles: Float = 8000

    func makeUIView(context: Context) -> EmitterView {
        ...
    }

    func updateUIView(_ uiView: EmitterView, context: Context) {
        defer {
            uiView.layer.birthRate = isEnabled ? 1 : 0
        }
        
        guard isEnabled else { return }
        uiView.layer.beginTime = CACurrentMediaTime()
    }
    
}

ViewModifier

To apply this spoiler to any view, we can create a ViewModifier.

struct SpoilerModifier: ViewModifier {

    let isEnabled: Bool
    let numberOfParticles: Float

    func body(content: Content) -> some View {
        content.overlay {
            SpoilerView(
                isEnabled: isEnabled,
                numberOfParticles: numberOfParticles
            )
        }
    }
}

And then, we can create an extension to activate it with just one line of code.

extension View {
    func spoiler(
        numberOfParticles: Float,
        isEnabled: Binding<Bool>
    ) -> some View {
        self
            .overlay {
                Rectangle()
                    .fill(.ultraThinMaterial)
                    .opacity(isEnabled.wrappedValue ? 1 : 0)
                    .blur(radius: 2)
                    .padding(-3)
            }
            .modifier(
                SpoilerModifier(
                    isEnabled: isEnabled.wrappedValue,
                    numberOfParticles: numberOfParticles
                )
            )
            .animation(.easeIn, value: isEnabled.wrappedValue)
            .onTapGesture {
                isEnabled.wrappedValue.toggle()
            }
    }
}

Here, we will use a Rectangle with ultra-thin material to create a blurred overlay and add a tap gesture to change the spoiler status with animation.

Now, let’s use it.

struct ContentView: View {
    
    @State private var isSpoilerEnabled = true
    
    private let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
    
    var body: some View {
        Text(text)
            .padding()
            .spoiler(
                numberOfParticles: Float(text.count * 20),
                isEnabled: $isSpoilerEnabled
            )
    }
}

Nice!

Conclusion

This approach can be modified differently to create a flexible solution. The source code is available here.