はじめに
以前にUIViewを燃やすextensionを作ったのですがこれをSwiftUIのViewからもコールできるようにしました![]()
こんな感じです。
実装
まずは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
}
}
リソースを追加したくなかったのでmakeBlurredCircleImageとmakeSmokeImageで画像を生成しています。ここを改良すればもう少し炎ぽくなるはずです。
次に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)
}
}
これをコールすれば完成![]()
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
}
}
}
}
こんな感じ![]()
おわりに
SwiftUIのViewも燃やせるようになったのですがいくつか改善点もあります。。。
ZStack {
Color.green
.frame(width: size.width, height: size.height)
Text("燃えます\nここが燃えます\nほんとに燃えます")
}.burn($burn, size: size)
burnでViewのサイズを渡しているのですがここはburn側でどうにか取得するようにしたい![]()

