1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【SwiftUI】滑らかにクルクル回るローディングアニメーション

Last updated at Posted at 2025-07-17

はじめに

個人でiOSアプリ開発を行なっている田中幹久です。

今回はロード画面に表示する円を作って見ました。
デフォルトのProgressViewがありますが、質素なので避けたい場面もあるかと思います。
そんな方向けにいい感じのアニメーションをつけたものを作ってみました。

👇デフォルトのProgressView
ProgressView.gif

完成形

こんな感じでクルクルと回転します。
(カクカクに見えますが、実機ではヌルヌルと動きます。)

LoadingCircle.gif

全体のコードはこちらのGitHubに載せてます!

こんな感じで図にしてみるとわかりやすい人もいるかもしれません。
僕はこの図を作り、その後コードに直す、という手順で作りました。

LoadingCircle_Graph.jpeg

コード

Viewの全体像はこんな感じです。

ContenView.swift
import SwiftUI


struct LoadingCircle : View {
    @StateObject var stopwatch : Stopwatch
    
    var blue1 = Color(red: 59/255, green: 150/255, blue: 215/255)
    var blue2 = Color(red: 66/255, green: 126/255, blue: 217/255)
    var blue3 = Color(red: 100/255, green: 120/255, blue: 215/255)
    
    var body: some View {
        Arc(startAngle: getStartAngle(), endAngle: getEndAngle())
            .stroke(getGradient(), lineWidth: 3) // ここで色と太さを指定
            .frame(width: 40, height: 40)
            .animation(.linear(duration: 0.01), value: stopwatch.time)
    }
    
    private func getGradient() -> AngularGradient{
        let gradation = AngularGradient(gradient: Gradient(colors: [blue1, blue2,blue3,blue1]), center: .center, angle: .degrees(-90 ) )
        return gradation
    }
    
    private func getStartAngle() -> Angle {
        // 1.2秒を1周期とする進捗率(0.0 〜 1.2)を計算
        let progress = stopwatch.time.truncatingRemainder(dividingBy: 1.2)
        
        switch progress {
        case 0.0..<0.15:
            return Angle(degrees: 90 + progress * 90 / 0.15)
        case 0.0..<0.3:
            return Angle(degrees: 180 + (progress - 0.15) * 180 / 0.15)
        case 0.0..<0.75:
            return Angle(degrees: (progress - 0.3) * 90 / 0.45 )
        case 0.0..<0.9:
            return Angle(degrees: (progress - 0.75) * 90 / 0.15 + 90)
        case 0.0..<1.05:
            return Angle(degrees: (progress - 0.9) * 180 / 0.15 + 180)
        case 0.0..<1.2:
            return Angle(degrees: (progress - 1.05) * 90 / 0.15)
        default:
            return Angle(degrees: 0)
        }
    }
    
    private func getEndAngle() -> Angle {
        // 1.2秒を1周期とする進捗率(0.0 〜 1.2)を計算
        let progress = stopwatch.time.truncatingRemainder(dividingBy: 1.2)
        switch progress {
        case 0.0..<0.75:
            return Angle(degrees: progress * 180 / 0.75)
        case 0.0..<0.9:
            return Angle(degrees: (progress - 0.75) * 180 / 0.15 + 180)
        case 0.0..<1.05:
            return Angle(degrees: (progress - 0.9) * 90 / 0.15)
        case 0.0..<1.2:
            return Angle(degrees: (progress - 1.05) * 270 / 0.15 + 90)
        default:
            return Angle(degrees: 0)
        }
    }
}

struct LoadingCircleTest : View {
    @StateObject var stopwatch = Stopwatch()
    var body: some View {
        
        //ProgressView()
        
        LoadingCircle(stopwatch: stopwatch)
        Button {
            
            stopwatch.isRunning.toggle()
            
            switch stopwatch.isRunning {
                
            case true: stopwatch.start()
                
            default:stopwatch.reset()
                
            }
        } label: {
            Text(stopwatch.isRunning ? "Stop" : "Start")
        }
        
        .onAppear {
            stopwatch.isRunning = true
            stopwatch.start()
        }
        
    }
}

#Preview {
    LoadingCircleTest()
}

円弧のViewです。

Arc.swift
import SwiftUI

struct Arc: Shape {
    /// 円弧の開始角度
    var startAngle: Angle
    
    /// 円弧の終了角度
    var endAngle: Angle
    
    /// 描画方向(trueで時計回り)
    var clockwise: Bool = true

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2
        
        // パスに円弧を追加する
        path.addArc(
            center: center,
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            // SwiftUIのaddArcは反時計回りが基準のため、直感的な時計回りの指定とは逆にする
            clockwise: !clockwise
        )
        
        return path
    }
}


ストップウォッチで時間変化を計測しています。

Stopwatch.swift
import Foundation
import Combine

class Stopwatch: ObservableObject {
    /// 経過時間(UIに変更を通知)
    @Published var time: Double = 0.0
    
    /// タイマーの実行状態(UIに変更を通知)
    @Published var isRunning: Bool = false
    
    private let timeInterval: Double = 0.01
    private var cancellable: AnyCancellable?


    /// タイマーを開始する
    func start() {
        print("start")
        // 実行中であれば何もしない
        guard isRunning else { return }
        
        isRunning = true
        
        cancellable = Timer.publish(every: timeInterval, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                // メモリリークを避けるために weak self を使用
                guard let self = self else { return }
                self.time += self.timeInterval
            }
    }

    /// タイマーを停止する
    func stop() {
        isRunning = false
        cancellable?.cancel()
    }

    /// タイマーをリセットする
    func reset() {
        print("reset")
        stop()
        time = 0.0
    }
}

全体のコードはこちらのGitHubに載せてます!

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?