SwiftUIでのカスタムローディングアニメーションサンプル

# 概要
以前作成したローディングアニメーションを整理したのでコピペして使えるように残しておきます。
当時はiOS15よりも前だったので`animation()`を`withAnimation`に書き直しました。

今回紹介するローディングアニメーションは以下の内容です。

主に以下の用途で使える内容となっています。
- 処理中であることがアプリユーザーにわかるような画面を作りたい
- 画面に動作中であると伝わるような動きを見せたい

この後の詳細セクションで以下のgif動画のアニメーションを表示しているViewと各カスタムローディングアニメーションのコードを紹介しています。

![Simulator Screen Recording - iPhone SE (3rd generation) - 2023-11-29 at 18.58.18.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3576326/22eea4b1-a72e-54d2-4041-0ddcf8091cd7.gif)


- - -
# 詳細


gif動画で表示している画面です。

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var rotation: Double = 0.0
    
    
    var body: some View {
        VStack {
            Spacer()
            CircleLoader()
                .frame(width: 50, height: 50)
            Spacer()
        }
        VStack {
            Spacer()
            ParticleLoader()
                .frame(width: 50, height: 50)
            Spacer()
        }
        VStack {
            Spacer()
            BouncingDotsLoader()
                .frame(width: 50, height: 50)
            Spacer()
        }
    }
}
```



上のアニメーション

```swift:CircleLoader.swift
import SwiftUI

struct CircleLoader: View {
    @State private var rotation: Double = 0.0

    var body: some View {
        Circle()
            .trim(from: 0.1, to: 1.0)
            .stroke(style: StrokeStyle(lineWidth: 8, lineCap: .round, lineJoin: .round))
            .foregroundColor(.blue)
            .rotationEffect(Angle(degrees: rotation))
            .onAppear {
                withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
                    rotation = 360
                }
            }
    }
}
```




真ん中のアニメーション

```swift:ParticleLoader
import SwiftUI

struct Particle: Identifiable {
    let id: UUID
    let angle: Double
}

struct ParticleLoader: View {
    let particleCount: Int = 8
    let radius: CGFloat = 20.0

    @State private var particles: [Particle] = []

    var body: some View {
        ZStack {
            ForEach(particles) { particle in
                Circle()
                    .frame(width: 10, height: 10)
                    .foregroundColor(.blue)
                    .offset(x: radius * CGFloat(cos(particle.angle * .pi / 180)),
                            y: radius * CGFloat(sin(particle.angle * .pi / 180)))
            }
        }
        .onAppear {
            particles = (0..<particleCount).map { index in
                Particle(id: UUID(), angle: Double(index) * (360.0 / Double(particleCount)))
            }

            withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
                particles = particles.map { particle in
                    Particle(id: particle.id, angle: particle.angle - 600.0)
                }
            }
        }
    }
}

```


下のアニメーション

```swift:DotLoader
struct BouncingDotsLoader: View {
    @State private var offsetY1: CGFloat = 0.0
    @State private var offsetY2: CGFloat = 0.0
    @State private var offsetY3: CGFloat = 0.0
    let dotSize: CGFloat = 20.0
    let bounceDistance: CGFloat = 20.0
    let animationDuration: Double = 0.5
    let delayBetweenDots: Double = 0.2

    var body: some View {
        HStack(spacing: 20) {
            DotView()
                .offset(y: offsetY1)
            DotView()
                .offset(y: offsetY2)
            DotView()
                .offset(y: offsetY3)
        }
        .onAppear {
            animateDots()
        }
    }

    private func animateDots() {
        withAnimation(Animation.easeInOut(duration: animationDuration).repeatForever(autoreverses: true)) {
            offsetY1 = bounceDistance
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + delayBetweenDots) {
            withAnimation(Animation.easeInOut(duration: animationDuration).repeatForever(autoreverses: true)) {
                offsetY2 = bounceDistance
            }
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 2 * delayBetweenDots) {
            withAnimation(Animation.easeInOut(duration: animationDuration).repeatForever(autoreverses: true)) {
                offsetY3 = bounceDistance
            }
        }
    }
}


struct DotView: View {
    let dotSize: CGFloat = 20.0
    
    var body: some View {
        Circle()
            .frame(width: dotSize, height: dotSize)
            .foregroundColor(.blue)
    }
}
```