はじめに
個人でiOSアプリ開発を行なっている田中幹久です。
今回はロード画面に表示する円を作って見ました。
デフォルトのProgressView
がありますが、質素なので避けたい場面もあるかと思います。
そんな方向けにいい感じのアニメーションをつけたものを作ってみました。
完成形
こんな感じでクルクルと回転します。
(カクカクに見えますが、実機ではヌルヌルと動きます。)
全体のコードはこちらのGitHubに載せてます!
図
こんな感じで図にしてみるとわかりやすい人もいるかもしれません。
僕はこの図を作り、その後コードに直す、という手順で作りました。
コード
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に載せてます!