フラグのディレイ
SwiftUIで「重い処理が始まったらすぐスピナーを出す」のではなく、「少し待って、まだ処理中ならスピナーを出す」というパターンはよくあります。短時間で終わる処理でもスピナーが一瞬表示されるのを防げるためです。
最近のSwift Concurrencyを使うなら、ローディング表示用のTaskを別で起動し、処理完了時にキャンセルする方法がシンプルです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@State private var isLoading = false func execute() async { let spinnerTask = Task { try? await Task.sleep(for: .milliseconds(300)) if !Task.isCancelled { await MainActor.run { isLoading = true } } } defer { spinnerTask.cancel() Task { @MainActor in isLoading = false } } await heavyWork() } |
処理の流れ:
- 重い処理開始
- 300ms待つTaskを起動
- 300ms以内に処理が終わればspinnerTaskをキャンセル
- 300msを超えたらスピナー表示
- 処理完了時にスピナー非表示
View側は普通に:
ZStack {
ContentView()
if isLoading {
ProgressView()
}
}
ViewModelに寄せる場合
実際は @Observable や ObservableObject のViewModelにまとめることが多いです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@MainActor @Observable final class MyViewModel { var isLoading = false func load() async { let spinnerTask = Task { try? await Task.sleep(for: .milliseconds(300)) if !Task.isCancelled { await MainActor.run { self.isLoading = true } } } defer { spinnerTask.cancel() isLoading = false } await heavyWork() } } |
さらに良くするなら「最小表示時間」も入れる
UX的には
- 300ms以上かかったら表示
- 表示したら最低500msは表示する
という実装もよくあります。そうしないと表示直後に消えてチラつくためです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let loadingShownAt = Date() // スピナー表示 let elapsed = Date().timeIntervalSince(loadingShownAt) if elapsed < 0.5 { try? await Task.sleep( for: .milliseconds(Int((0.5 - elapsed) * 1000)) ) } isLoading = false |
実務では
- 0〜200ms: 何も表示しない
- 200〜500ms: スピナー表示
- 数秒以上: スピナー+メッセージ
くらいが一般的です。SwiftUI + async/await なら「遅延表示用Taskを作ってキャンセルする」が最も自然で保守しやすい実装だと思います。
ローディングスピナー本体
ProgressView() のデフォルトのスピナーは、iOSの標準スタイルに従うので、多くの場合はライト/ダークモードに自動対応します。
ProgressView()
まずはこれが推奨です。
ただし、背景の上にオーバーレイ表示する場合は、背景とのコントラスト次第で見づらくなることがあります。
例えばこんなケースです。
ZStack {
content
if isLoading {
ProgressView()
}
}
背景が白っぽいとライトモードで見えにくかったり、背景が黒っぽいとダークモードで埋もれたりします。
その場合は、スピナーの背後に半透明のマテリアルや背景を置くのが一般的です。
|
1 2 3 4 |
ProgressView() .padding(20) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) |
または
|
1 2 3 4 5 6 7 |
ProgressView() .padding(20) .background { RoundedRectangle(cornerRadius: 12) .fill(.background) .shadow(radius: 4) } |
tintを固定しない方がよい
よくあるのが
ProgressView()
.tint(.white)
ですが、これだとライトモードで見えなくなる可能性があります。
逆に
ProgressView()
.tint(.black)
だとダークモードで見えなくなります。
どうしても色を指定するならシステムカラーを使います。
ProgressView()
.tint(.primary)
ただし、標準のままが最もテーマ適応性が高いです。
全画面ローディングなら
実務ではこんな感じが多いです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
ZStack { content if isLoading { Color.black.opacity(0.2) .ignoresSafeArea() ProgressView() .padding(24) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } } |
これならライトモード・ダークモードの両方で十分なコントラストが確保でき、アクセシビリティ面でも比較的安定します。