1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

以前にUIViewを燃やすextensionを作ったのですがこれをSwiftUIのViewからもコールできるようにしました:confetti_ball:

こんな感じです。

burn.gif

実装

まずはUIViewのextensionを作ります。

public extension UIView {

    func burnAnimation(duration: TimeInterval,
                       completion: (() -> Void)? = nil) {
        let baseView = makeBaseView()
        let imageView = makeImageview(baseView: baseView)
        baseView.addSubview(imageView)
        
        let gradientLayer = makeGradientLayer(baseView: baseView)
        baseView.layer.addSublayer(gradientLayer)
        
        let emitterLayer = makeEmitterLayer(baseView: baseView)
        baseView.layer.addSublayer(emitterLayer)
        
        let startPoint = CGPoint(x: baseView.frame.size.width/2,
                                 y: baseView.frame.size.height)
        let endPoint = CGPoint(x: baseView.frame.size.width/2,
                               y: 0)

        emitterLayer.emitterPosition = startPoint
        gradientLayer.position = CGPoint(x: startPoint.x, y: startPoint.y - 5)

        superview?.addSubview(baseView)
        
        let animationE = makeEmitterLayerAnimation(duration: duration,
                                                   startPoint: startPoint,
                                                   endPoint: endPoint)
        let animationG = makeGradientLayerAnimation(duration: duration,
                                                    startPoint: startPoint,
                                                    endPoint: endPoint)
        emitterLayer.add(animationE, forKey: nil)
        gradientLayer.add(animationG, forKey: nil)
        
        var frame = baseView.frame
        var imageViewFrame = imageView.frame
        frame.size.height = 0
        imageViewFrame.size.height = 0
        self.isHidden = true
        UIView.animate(withDuration: duration,
                       delay: 0.0,
                       options: .curveEaseOut,
                       animations:{
            imageView.frame = imageViewFrame
        },
                       completion:nil)
        
        UIView.animate(withDuration: duration,
                       delay: 0.0,
                       options: .curveEaseOut,
                       animations:{
            baseView.frame = frame
        },
                       completion: { _ in
            baseView.removeFromSuperview()
            completion?()
        })
    }
    
    // MARK: - Animation
    private func makeGradientLayerAnimation(duration: TimeInterval,
                                            startPoint: CGPoint,
                                            endPoint: CGPoint) -> CABasicAnimation {
        let sPoint = CGPoint(x: startPoint.x, y: startPoint.y - 5)
        let ePoint = CGPoint(x: endPoint.x, y: endPoint.y - 5)
        
        let animation = CABasicAnimation(keyPath: "position")
        animation.duration = duration
        animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
        animation.fromValue = NSValue(cgPoint: sPoint)
        animation.toValue = NSValue(cgPoint: ePoint)
        return animation
    }
    
    private func makeEmitterLayerAnimation(duration: TimeInterval,
                                           startPoint: CGPoint,
                                           endPoint: CGPoint) -> CABasicAnimation {
        let animation = CABasicAnimation(keyPath: "emitterPosition")
        animation.duration = duration
        animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
        animation.fromValue = NSValue(cgPoint: startPoint)
        animation.toValue = NSValue(cgPoint: endPoint)
        return animation
    }
    
    // MARK: - EmitterLayer
    private func makeEmitterLayer(baseView: UIView) -> CAEmitterLayer {
        let emitterLayer = CAEmitterLayer()
        let size = baseView.bounds.size
        emitterLayer.emitterPosition = CGPoint(x: size.width/2, y: size.height/2)
        emitterLayer.renderMode = CAEmitterLayerRenderMode.additive
        emitterLayer.emitterShape = CAEmitterLayerEmitterShape.line
        emitterLayer.emitterSize = CGSize(width: baseView.frame.size.width + 10, height: 10)
        
        let fireColor = UIColor(red: 0.89, green: 0.56, blue: 0.36, alpha: 0.5)
        let smokeColor = UIColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.1)
        emitterLayer.emitterCells = [
            makeEmitterCell(color: fireColor),
            makeSmokeEmitterCell(color: smokeColor)
        ]
        return emitterLayer
    }
    
    private func makeEmitterCell(color: UIColor) -> CAEmitterCell {
        let emitterCell = CAEmitterCell()
        let image = makeBlurredCircleImage()
        emitterCell.contents = image.cgImage
        emitterCell.emissionLongitude = CGFloat(Double.pi*2)
        emitterCell.emissionRange = CGFloat(Double.pi)
        emitterCell.birthRate = 500
        emitterCell.lifetimeRange = 1.2
        emitterCell.velocity = 230
        emitterCell.color = color.cgColor
        return emitterCell
    }
    
    private func makeSmokeEmitterCell(color: UIColor) -> CAEmitterCell {
        let emitterCell = CAEmitterCell()
        let image = makeSmokeImage()
        emitterCell.contents = image.cgImage
        emitterCell.emissionLongitude = CGFloat(Double.pi*2)
        emitterCell.emissionRange = 0
        emitterCell.birthRate = 50
        emitterCell.lifetimeRange = 1.0
        emitterCell.velocity = 200
        emitterCell.color = color.cgColor
        return emitterCell
    }
    
    // MARK: - GradientLayer
    private func makeGradientLayer(baseView: UIView) -> CAGradientLayer {
        let gradientLayer = CAGradientLayer()
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 1.0)
        gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
        gradientLayer.colors = [UIColor.black.cgColor, UIColor.clear.cgColor]
        gradientLayer.opacity = 0.5
        var layerFrame = baseView.frame
        layerFrame.size.height = 10
        layerFrame.origin.y = layerFrame.origin.y + baseView.frame.size.height - layerFrame.size.height
        gradientLayer.frame = layerFrame
        return gradientLayer
    }
    
    // MARK: - View
    private func makeBaseView() -> UIView {
        let baseView = UIView(frame: self.frame)
        return baseView
    }
    
    private func makeImageview(baseView: UIView) -> UIImageView {
        let imageView = UIImageView(frame: baseView.bounds)
        imageView.image = screenCapture()
        imageView.contentMode = .top
        imageView.layer.masksToBounds = true
        return imageView
    }
    
    private func screenCapture() -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, 0)
        defer {
            UIGraphicsEndImageContext()
        }
        guard let context = UIGraphicsGetCurrentContext() else {
            return nil
        }
        
        layer.render(in: context)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        return image
    }

    func makeBlurredCircleImage(
        diameter: CGFloat = 24,
        blur: CGFloat = 4
    ) -> UIImage {
        let size = CGSize(width: diameter, height: diameter)
        let format = UIGraphicsImageRendererFormat()
        format.scale = UIScreen.main.scale
        format.opaque = false

        let renderer = UIGraphicsImageRenderer(size: size, format: format)
        let image = renderer.image { context in
            let cg = context.cgContext
            cg.clear(CGRect(origin: .zero, size: size))
            cg.setFillColor(UIColor.white.cgColor)
            cg.setShadow(offset: .zero,
                         blur: blur,
                         color: UIColor.white.cgColor)

            let inset: CGFloat = blur
            let rect = CGRect(x: inset,
                              y: inset,
                              width: diameter - inset * 2,
                              height: diameter - inset * 2)

            cg.fillEllipse(in: rect)
        }

        return image
    }

    func makeSmokeImage(
        diameter: CGFloat = 16,
        noiseLevel: CGFloat = 6,
        blur: CGFloat = 4
    ) -> UIImage {
        let size = CGSize(width: diameter, height: diameter)
        let format = UIGraphicsImageRendererFormat()
        format.opaque = false

        let renderer = UIGraphicsImageRenderer(size: size, format: format)
        let image = renderer.image { ctx in
            let cg = ctx.cgContext
            cg.clear(.init(origin: .zero, size: size))
            let center = CGPoint(x: size.width / 2, y: size.height / 2)
            let baseRadius = diameter / 2 - blur
            let steps = 48
            let path = UIBezierPath()
            for i in 0...steps {
                let t = CGFloat(i) / CGFloat(steps) * .pi * 2
                let noise = CGFloat.random(in: -noiseLevel...noiseLevel)
                let r = baseRadius + noise

                let pt = CGPoint(
                    x: center.x + cos(t) * r,
                    y: center.y + sin(t) * r
                )

                if i == 0 { path.move(to: pt) }
                else { path.addLine(to: pt) }
            }
            path.close()
            cg.setShadow(offset: .zero,
                         blur: blur,
                         color: UIColor.white.cgColor)
            cg.setFillColor(UIColor.white.cgColor)
            cg.addPath(path.cgPath)
            cg.fillPath()
        }

        return image
    }
}

リソースを追加したくなかったのでmakeBlurredCircleImagemakeSmokeImageで画像を生成しています。ここを改良すればもう少し炎ぽくなるはずです。

次にSwiftUIでも使えるようにUIViewRepresentableを作ります。

struct BurnView<Content: View>: UIViewRepresentable {

    let content: Content
    @Binding var trigger: Bool
    let duration: TimeInterval
    let completion: (() -> Void)?

    init(trigger: Binding<Bool>, duration: TimeInterval, @ViewBuilder content: () -> Content, completion: (() -> Void)? = nil) {
        self._trigger = trigger
        self.content = content()
        self.duration = duration
        self.completion = completion
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> UIView {
        let host = UIHostingController(rootView: content)
        host.view.backgroundColor = .clear
        return host.view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        if trigger && !context.coordinator.wasTriggered {
            context.coordinator.wasTriggered = true
            uiView.burnAnimation(duration: duration) {
                completion?()
            }
        } else if !trigger {
            context.coordinator.wasTriggered = false
        }
    }

    class Coordinator {
        var wasTriggered = false
    }
}

updateUIViewで燃やしたかどうかを管理するためにCoordinatorを追加しています。

さらにこれをSwiftUIのViewからコールできるようにします。

extension View {

    func burn(_ trigger: Binding<Bool>,
              duration: TimeInterval = 2.0,
              size: CGSize,
              completion: (() -> Void)? = nil) -> some View {
        BurnView(trigger: trigger, duration: duration, content: {self}, completion: completion).frame(width: size.width, height: size.height)
    }
}

これをコールすれば完成:tada:

struct ContentView: View {
    
    @State private var burn = false
    let size = CGSize(width: 300, height: 300)

    var body: some View {
        VStack {
            ZStack {
                Color.green
                    .frame(width: size.width, height: size.height)
                Text("燃えます\nここが燃えます\nほんとに燃えます")
            }.burn($burn, size: size)
            
            Button("燃やす") {
                burn = true
            }
        }
    }
}

こんな感じ:fire:

burn.gif

おわりに

SwiftUIのViewも燃やせるようになったのですがいくつか改善点もあります。。。

ZStack {
    Color.green
        .frame(width: size.width, height: size.height)
    Text("燃えます\nここが燃えます\nほんとに燃えます")
}.burn($burn, size: size)

burnでViewのサイズを渡しているのですがここはburn側でどうにか取得するようにしたい:thinking:

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?