LoginSignup
33
16

onAppearとtaskの違いと使い分け(SwiftUI)

Last updated at Posted at 2023-12-02

はじめに

本記事は SwiftWednesday Advent Calendar 2023 の1日目の記事です。

SwiftUIの onAppear(perform:)task(priority:_:) モディファイアの違いと使い分けを紹介します。

環境

  • OS:macOS Sonoma 14.0(23A344)
  • Swift:5.9

結論

先に使い分けの結論を述べます。

  • iOS 15未満 → onAppear
  • iOS 15以上
    • 同期処理を実行する → onAppear
    • 非同期処理を実行する
      • 必ず処理を最後まで実行する → onAppear
      • Viewのライフサイクルによって処理を自動でキャンセルする → task

onAppearとtaskの違い

onAppeartask の違いを紹介します。

taskはiOS 15.0+でしか使えない

Viewの表示時に処理を実行する点は、 onAppeartask も同様です。

しかし onAppear はiOS 13.0+で使えるのに対し、 task はiOS 15.0+でしか使えません。
iOS 15未満をサポートする場合は onAppear を使うしかありません。

taskは非同期処理をそのまま実行できる

onAppearTask { ... } で括らないと非同期処理を実行できませんが、 task はそのままで非同期処理を実行できます。

以下は Task.sleep() で3秒待ったあとにprintする例です。

Text("Foo")
    // `onAppear` は `Task { ... }` で括らないと非同期処理を実行できない
    .onAppear {
        Task {
            try await Task.sleep(for: .seconds(3))
            print("Foo")
        }
    }
    // `task` はそのまま非同期処理を実行できる
    // `Task` と異なり、エラーを握り潰さないのに注意
    .task {
        try? await Task.sleep(for: .seconds(3))
        print("Foo")
    }

taskはViewのライフサイクルによって処理をキャンセルする

こちらが task を使う一番の理由です。

同期処理や投げっぱなしの非同期処理の実行は、 onAppear を使うほうが名前からViewの表示時に実行することがわかりやすいです。

例として、「トーストを出す」ボタンをタップすると下からニュッと表示するトーストを考えてみます。
こちらのトーストは3秒後に自動で消えますし、「トーストを消す」ボタンをタップしても消えます。ボタンのタップ時にはシュッと消えてほしいのでアニメーションを付けていません。

image.png

「3秒後に自動で消える」処理を、トーストの表示時に Task.sleep() を実行することで実現します。

まずは onAppear で実装してみます。

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var isPresented = false
    
    var body: some View {
        VStack {
            Button {
                withAnimation {
                    isPresented = true
                }
            } label: {
                Text("トーストを出す")
            }
            Button {
                isPresented = false
            } label: {
                Text("トーストを消す")
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(alignment: .bottom) {
            if isPresented {
                Text("トーストだよ")
                    .padding()
                    .background(.blue.opacity(0.2))
                    .clipShape(RoundedRectangle(cornerRadius: 16))
                    .padding(.bottom)
                    .transition(.move(edge: .bottom))
                    .onAppear {
                        Task {
                            do {
                                print("sleep start")
                                try await Task.sleep(for: .seconds(3))
                                print("sleep end")
                                withAnimation {
                                    isPresented = false
                                }
                            } catch {
                                print("error")
                            }
                        }
                    }
            }
        }
    }
}

on_appear.gif

実行するとわかりますが、こちらの実装は以下の問題があります。

  1. 「トーストを出す」ボタンをタップし、トーストを表示する
  2. 3秒経つ前に「トーストを消す」ボタンをタップし、トーストを消す
  3. 「トーストを出す」ボタンをタップし、トーストを再表示する
  4. 1.でトーストを出したときの「3秒後に自動で消える」処理が残っているため、すぐにトーストが消えてしまう

onAppear 内の処理は自動でキャンセルされないため、トーストを連続で表示すると、その分「3秒後に自動で消える」処理が実行されてしまいます。
catch { ... } 内の print("error") が実行されることはありません。

そこで task の出番です。

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var isPresented = false
    
    var body: some View {
        VStack {
            Button {
                withAnimation {
                    isPresented = true
                }
            } label: {
                Text("トーストを出す")
            }
            Button {
                isPresented = false
            } label: {
                Text("トーストを消す")
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(alignment: .bottom) {
            if isPresented {
                Text("トーストだよ")
                    .padding()
                    .background(.blue.opacity(0.2))
                    .clipShape(RoundedRectangle(cornerRadius: 16))
                    .padding(.bottom)
                    .transition(.move(edge: .bottom))
-                   .onAppear {
-                       Task {
-                           do {
-                               print("sleep start")
-                               try await Task.sleep(for: .seconds(3))
-                               print("sleep end")
-                               withAnimation {
-                                   isPresented = false
-                               }
-                           } catch {
-                               print("error")
-                           }
-                       }
-                   }
+                   .task {
+                       do {
+                           print("sleep start")
+                           try await Task.sleep(for: .seconds(3))
+                           print("sleep end")
+                           withAnimation {
+                               isPresented = false
+                           }
+                       } catch is CancellationError {
+                            print("task cancel")
+                       } catch {
+                           print("error")
+                       }
+                   }
            }
        }
    }
}

task.gif

これで上記の問題が解決します。

  1. 「トーストを出す」ボタンをタップし、トーストを表示する
  2. 3秒経つ前に「トーストを消す」ボタンをタップし、トーストを消す
  3. 「3秒後に自動で消える」処理がキャンセルされる
  4. 「トーストを出す」ボタンをタップし、トーストを再表示する
  5. 4.から3秒後にトーストが消える

task はViewのライフサイクルに寄り添ってくれます。
Viewが消える、またはIDが変わると CancellationError をスローするので、適切にエラーハンドリングすることでタスクのキャンセルを実現できます。

try? await Task.sleep() でタスクキャンセルのエラーを握り潰すと、後続の処理が実行されるので注意です。

おまけ: onAppearでtaskを実現する

onAppeartask のような自動キャンセルを実現するにはどうすればいいでしょうか?

正解は「 Task を保持し、 onDisappear でキャンセルする」です。
task と完全に同じ動作になるかはわかりませんが、少なくとも今回のユースケースでは問題なさそうです。

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var isPresented = false
+   @State private var task: Task<(), Never>?
    
    var body: some View {
        VStack {
            Button {
                withAnimation {
                    isPresented = true
                }
            } label: {
                Text("トーストを出す")
            }
            Button {
                isPresented = false
            } label: {
                Text("トーストを消す")
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(alignment: .bottom) {
            if isPresented {
                Text("トーストだよ")
                    .padding()
                    .background(.blue.opacity(0.2))
                    .clipShape(RoundedRectangle(cornerRadius: 16))
                    .padding(.bottom)
                    .transition(.move(edge: .bottom))
                    .onAppear {
-                       Task {
+                       task = Task {
                            do {
                                print("sleep start")
                                try await Task.sleep(for: .seconds(3))
                                print("sleep end")
                                withAnimation {
                                    isPresented = false
                                }
                            } catch is CancellationError {
                                print("task cancel")
                            } catch {
                                print("error")
                            }
                        }
                    }
+                   .onDisappear {
+                       task?.cancel()
+                   }
            }
        }
    }
}

iOS 15未満でタスクの自動キャンセルを実現したい場合は参考にしてください。

おわりに

task はただの「非同期処理をそのまま実行できる onAppear 」ではないことがわかりました。
task の特性を理解し、適切なSwift Concurrencyライフを送りましょう :relaxed:

以上 SwiftWednesday Advent Calendar 2023 の1日目の記事でした。
明日は @ojun_9 さんで Xcode 15のPreview Macroにおける依存注入について です。

33
16
1

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
33
16