1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

XmasなのでSwiftUIで紙吹雪を飛ばそう!(未解決部分あり)

Posted at

はじめに

以前から自分のアプリにも何かを達成した時に紙吹雪エフェクトを実装しようとしていました。

Webサイトのの紙吹雪の実装の記事を参照したり、Cursor先生に相談して試行錯誤していました。

Cursorが出力してくる実装は最初全く実用的ではなく、紙吹雪なのに1枚しか飛ばなかったり、重力の影響を受けず無限に拡散していったりしたので、相談しながら少しずつ調整して紙吹雪っぽくしていきました。

紙吹雪アニメーション

紙吹雪エフェクトの使用技術について

紙吹雪エフェクトにはCanvasとTimelineViewという機能で作成しています。

Canvas

CanvasはSwiftUIで低レベルな2D描画を行うためのViewです。iOS 15から使用可能になりました。

Canvasを使う理由

通常のSwiftUIでは、各オブジェクトをForEachを使って個別のViewとして扱います。しかし、紙吹雪のように100個以上のオブジェクトを同時に描画する場合、ForEachで個別のViewを作成するとパフォーマンスが低下します。

Canvasを使うと、1つ1つのオブジェクトにボタンなどの機能を持たせられない代わりに多数のオブジェクトをグラフィックとして描画できます。

Canvasの基本的な使い方

import SwiftUI

struct ContentView: View {
    var body: some View {
        Canvas { content, size in
            let lineWidth: CGFloat = 4 //楕円部分の太さ
            let inset = lineWidth / 2 //2にすると四角の枠から見切れない
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset) //長方形の枠を定義する
            let path = Path(ellipseIn: rect) //Ellipseは楕円
            content.stroke(path, with: .color(.green), lineWidth: lineWidth)
        }
        .frame(width: 300, height: 200)
        .border(Color.blue)
    }
}

#Preview {
    ContentView()
}

TimelineView とは

TimelineViewは時間経過に応じてViewを再描画するSwiftUIコンポーネントです。こちらもiOS 15から使用可能になりました。Canvasと組み合わせることで、アニメーションを作成できます。

TimelineViewの基本的な使い方

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            TimelineView(.periodic(from: .now, by: 0.1)) { context in
                AnimatedEllipseView(date: context.date)
            }
            .frame(width: 300, height: 200)
            .border(Color.blue)
        }
        .padding()
    }
}

struct AnimatedEllipseView: View {
    let date: Date
    
    // 最大の線の太さ(枠内に収めるために使用)
    private let maxLineWidth: CGFloat = 6
    
    // 時間に基づいて変化する値を計算
    private var timeValue: Double {
        let seconds = date.timeIntervalSince1970
        return sin(seconds * 0.5) // 0.5秒ごとに1周期
    }
    
    // 楕円の幅の倍率(0.3倍から1.0倍の間で変化、枠内に収まるように)
    private var widthMultiplier: CGFloat {
        (timeValue + 1.0) * 0.35 + 0.3 // 0.3 ~ 1.0の範囲
    }
    
    // 楕円の高さの倍率(0.3倍から1.0倍の間で変化、位相をずらす)
    private var heightMultiplier: CGFloat {
        let offsetValue = sin(date.timeIntervalSince1970 * 0.5 + .pi / 2)
        return (offsetValue + 1.0) * 0.35 + 0.3 // 0.3 ~ 1.0の範囲
    }
    
    // 色の変化(緑から青へ)
    private var ellipseColor: Color {
        let hue = (timeValue + 1.0) * 0.5 * 0.3 // 0.0 ~ 0.3(緑から青)
        return Color(hue: hue, saturation: 0.8, brightness: 0.9)
    }
    
    // 線の太さの変化
    private var lineWidth: CGFloat {
        let baseWidth: CGFloat = 4
        return baseWidth + CGFloat(abs(timeValue)) * 2 // 2 ~ 6の範囲
    }
    
    var body: some View {
        Canvas { content, size in
            // 最大の線の太さを考慮して、常に枠内に収まるようにinsetを計算
            let inset = maxLineWidth / 2
            let baseRect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset)
            
            // 時間に応じてサイズを変更(baseRect内に完全に収まるように)
            let rectWidth = baseRect.width * widthMultiplier
            let rectHeight = baseRect.height * heightMultiplier
            
            // 中心に配置
            let rectX = (baseRect.width - rectWidth) / 2 + baseRect.origin.x
            let rectY = (baseRect.height - rectHeight) / 2 + baseRect.origin.y
            
            let rect = CGRect(
                x: rectX,
                y: rectY,
                width: rectWidth,
                height: rectHeight
            )
            
            let path = Path(ellipseIn: rect)
            content.stroke(path, with: .color(ellipseColor), lineWidth: lineWidth)
        }
        .animation(.easeInOut(duration: 0.1), value: timeValue)
    }
}

#Preview {
    ContentView()
}

紙吹雪実装の詳細

Model(ConfettiParticle)

各紙吹雪パーティクルの状態を保持する構造体です。

struct ConfettiParticle: Identifiable {
    let id = UUID()
    var position: CGPoint      // 現在位置
    var velocity: CGVector     // 速度ベクトル(dx, dy)
    var color: Color           // 色
    var rotationX: Double      // X軸回転(前後に傾く)
    var rotationY: Double      // Y軸回転(左右に傾く)
    var rotationXSpeed: Double // X軸回転速度
    var rotationYSpeed: Double // Y軸回転速度
    var width: CGFloat         // 幅
    var height: CGFloat        // 高さ
    var opacity: Double = 1.0  // 透明度(フェードアウト用)
    var windForce: Double      // 風の影響度
}

3D回転の概念

紙吹雪が「ひらひら舞う」効果を出すために、2つの軸で回転させる。

  • X軸回転: 紙吹雪が前後に傾く動き
  • Y軸回転: 紙吹雪が左右に傾く動き

これらを組み合わせることで、立体的な動きを表現しています。


ViewModel(ConfettiViewModel)

パーティクルの生成と物理シミュレーションを担当します。

@MainActor
class ConfettiViewModel: ObservableObject {
    @Published var particles: [ConfettiParticle] = []
    var animationStartTime: Date?
    
    let particleCount = 100
    let animationDuration: TimeInterval = 4.0
    
    // 7色のカラーパレット
    let colors: [Color] = [
        Color(red: 1.0, green: 0.42, blue: 0.42), // 赤
        Color(red: 1.0, green: 0.82, blue: 0.4),  // オレンジ
        Color(red: 1.0, green: 0.96, blue: 0.4),  // 黄
        Color(red: 0.4, green: 0.96, blue: 0.4),  // 緑
        Color(red: 0.4, green: 0.82, blue: 1.0),  // 青
        Color(red: 0.82, green: 0.4, blue: 1.0),  // 紫
        Color(red: 1.0, green: 0.4, blue: 0.82)   // ピンク
    ]
    // ...
}

パーティクル生成のポイント

// 上方向に飛ばす角度分布
let baseAngle = -Double.pi / 2  // -90度(上方向)
let spread = Double.pi / 2       // ±90度の範囲
let angle = baseAngle + Double.random(in: -spread..<spread)

// 速度ベクトル
let speed = Double.random(in: 20...40)
let vx = cos(angle) * speed
let vy = sin(angle) * speed

これにより、パーティクルは上方向を中心に扇状に広がります:

物理シミュレーション

毎フレーム以下の処理を行う。

func updateParticles(canvasSize: CGSize, now: Date) {
    let gravity: CGFloat = 0.5   // 重力
    let drag: CGFloat = 0.998    // 空気抵抗
    
    for i in particles.indices {
        var particle = particles[i]
        
        // 1. 重力を適用
        particle.velocity.dy += gravity * deltaTime
        
        // 2. 風の影響(sin波で揺れる)
        let windVariation = sin(elapsed * 2.0 + Double(i) * 0.1) * particle.windForce
        particle.velocity.dx += windVariation * deltaTime
        
        // 3. 空気抵抗を適用
        particle.velocity.dx *= drag
        particle.velocity.dy *= drag
        
        // 4. 位置を更新
        particle.position.x += particle.velocity.dx * deltaTime
        particle.position.y += particle.velocity.dy * deltaTime
        
        // 5. 3D回転を更新
        particle.rotationX += particle.rotationXSpeed * deltaTime
        particle.rotationY += particle.rotationYSpeed * deltaTime
        
        // 6. フェードアウト(最後の1秒で)
        if elapsed > animationDuration - 1.0 {
            let fadeProgress = (elapsed - (animationDuration - 1.0)) / 1.0
            particle.opacity = max(0, 1.0 - fadeProgress)
        }
        
        particles[i] = particle
    }
}

View(ConfettiView)

Canvasでの描画とTimelineViewによるアニメーション駆動を担当します。

struct ConfettiView: View {
    @StateObject private var viewModel = ConfettiViewModel()
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // Canvasでパーティクルを描画
                Canvas { context, size in
                    for particle in viewModel.particles {
                        // 3D効果:Y軸回転に応じてサイズを調整
                        let depthScale = 0.5 + 0.5 * abs(cos(particle.rotationY))
                        let scaledWidth = particle.width * depthScale
                        let scaledHeight = particle.height * depthScale
                        
                        // 長方形を描画
                        let rect = CGRect(
                            x: particle.position.x - scaledWidth / 2,
                            y: particle.position.y - scaledHeight / 2,
                            width: scaledWidth,
                            height: scaledHeight
                        )
                        
                        var path = Path(rect)
                        
                        // Z軸回転(ひらひら効果)
                        let zRotation = sin(particle.rotationX) * 0.3
                        let center = CGPoint(x: rect.midX, y: rect.midY)
                        let transform = CGAffineTransform(translationX: center.x, y: center.y)
                            .rotated(by: zRotation)
                            .translatedBy(x: -center.x, y: -center.y)
                        path = path.applying(transform)
                        
                        // 透明度を設定
                        context.opacity = particle.opacity
                        
                        // 描画
                        context.fill(path, with: .color(particle.color))
                    }
                }
                
                // TimelineViewでアニメーション駆動
                TimelineView(.animation) { timeline in
                    let now = timeline.date
                    
                    Task { @MainActor in
                        viewModel.updateParticles(canvasSize: geometry.size, now: now)
                    }
                    
                    return Color.clear
                }
                
                // トリガーボタン
                // ...
            }
        }
    }
}

未解決の問題

@Observableの速度低下

実装後に一つ問題が出ました。

ObservableObject@Observableに変更したところ、紙吹雪が超スローになります

海外コミュニティでのアドバイス

外国のコミュニティで聞いた意見では:

@Observableはデフォルトですべてのプロパティを追跡し、Canvas がフレームごとに再描画するため、速度低下が発生すると思います。

@ObservationIgnoredを追記するというアドバイスをいただきました:

しかし、@ObservationIgnored入れても遅いままでした。

結論

一旦ObservableObjectで問題なく動いているので、このまま使っていこうと思います。

もし解決方法をご存知の方がいらっしゃいましたら、コメントで教えていただけると嬉しいです!

紙吹雪を飛ばそうぜ!

この紙吹雪エフェクトのコードは、個人開発・商用開発を問わず、ご自由に使用・改変してお使いいただけます。
利用にあたっては、以下のどちらかのご対応をお願いいたします。

一言連絡をする: [@Perk_sh]
(https://x.com/Perk_sh) に「使わせてもらうぜ!」と一言連絡。

クレジット表記: 作成するアプリ内の「使用技術」「ライセンス」「スペシャルサンクス」などの項目に、本記事やGitHubへのリンク、または[@Perk_sh]
(https://x.com/Perk_sh) の記載をお願いします。

1
3
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
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?