SwiftUIでEnvironmentValuesを使ってカスタムActionをDIする

SwiftUIで、自分で作ったカスタムActionを使ってDIを行います。

# EnvironmentValuesのActionsについて

`@Environment` で使えるValueにはいくつかカテゴリがあって、その中の一つにActionsというものがあります。

https://developer.apple.com/documentation/swiftui/environmentvalues/#actions

これは、システム側で提供されるcallAsFunctionで定義されたアクションで、一つのタスクを行うことに特化したものになっています。
例えば `openURL` を使えば URL遷移を行うことができます。

```swift
struct OpenURLExample: View {
    @Environment(\.openURL) private var openURL

    var body: some View {
        Button {
            if let url = URL(string: "https://www.example.com") {
                openURL(url)
            }
        } label: {
            Label("Get Help", systemImage: "person.fill.questionmark")
        }
    }
}
```

iOS 17で追加された`purchase` は、製品の購入を行い、購入結果を取得する、というアクションになっています。

https://developer.apple.com/documentation/swiftui/environmentvalues/purchase

```swift
struct PurchaseExample: View {
    @Environment(\.purchase) private var purchase
    let product: Product
    let purchaseOptions: [Product.PurchaseOption]

    var body: some View {
        Button {
            Task {
                let purchaseResult = try? await purchase(product, options: purchaseOptions)
                // Process purchase result.
            }
        } label: {
            Text(product.displayName)
        }
    }
}
```

システムで提供されているActionsは、purchaseであれば `PurchaseAction` といったようなstructと結びついています。

# カスタムActionを作成する
自作したカスタムActionを、protocolで定義して差し替え可能にしてみようと思います。
Qiita APIからタグ一覧を取得するようなActionを考えます。

```swift
struct Tag: Decodable {
    let followersCount: Int
    let iconUrl: String?
    let id: String
    let itemsCount: Int
}
```

## EnvironmentValue用のprotocolの定義

まずはprotocolを定義します。

```swift
protocol GetQiitaTagsAction {
    func callAsFunction(page: Int, perPage: Int, sort: String) async throws -> [Tag]
}
```

そして、そのprotocolに適合したstructを作成します。
固定データを返す `GetQiitaTagsFromLocalAction` と、必ずエラーにする `GetQiitaTagsErrorAction` の二つを作成しました。

```swift
struct GetQiitaTagsFromLocalAction: GetQiitaTagsAction {
    func callAsFunction(page: Int, perPage: Int, sort: String) async throws -> [Tag] {
        [
            .init(followersCount: 10,
                  iconUrl: nil,
                  id: "テストID",
                  itemsCount: 1)
        ]
    }
}

struct GetQiitaTagsErrorAction: GetQiitaTagsAction {
    struct GetQiitaTagsErrorActionError: Error {}
    
    func callAsFunction(page: Int, perPage: Int, sort: String) async throws -> [Tag] {
        throw GetQiitaTagsErrorActionError()
    }
}
```

## EnvironmentValue用のキーとアクションを追加

EnvironmentValuesにQiitaのタグ取得用のアクションを追加します。
EnvironmentKeyが必要になるので同時に追加します。
defaultValueが必要になるので、デフォルトのGetQiitaTagsActionを追加しておきます。

```swift
private struct GetQiitaTagsActionKey: EnvironmentKey {
    static let defaultValue: GetQiitaTagsAction = GetQiitaTagsFromLocalAction()
}

extension EnvironmentValues {
    var getQiitaTags: GetQiitaTagsAction {
        get { self[GetQiitaTagsActionKey.self] }
        set { self[GetQiitaTagsActionKey.self] = newValue }
    }
}
```

# カスタムActionを使う

実際のViewでアクションを使います。
以下のコード例は、ビュー表示時にQiitaのタグ取得を行い、結果を表示します。
task部分でgetQiitaTagsアクションを実行し、結果を取得しています。
もしエラーが起きた場合はアラートを表示します。

```swift
struct ContentView: View {
    struct ContentViewError: LocalizedError {
        let error: Error
        
        var errorDescription: String? {
            error.localizedDescription
        }
    }
    
    @Environment(\.getQiitaTags) private var getQiitaTags
    @State private var tags: [Tag] = []
    @State private var contentViewError: ContentViewError?
    @State private var isError = false
    
    var body: some View {
        List(tags, id: \.self.id) { tag in
            VStack {
                Text(tag.id)
                Text(tag.followersCount, format: .number)
                Text(tag.itemsCount, format: .number)
            }
        }
        .alert(isPresented: $isError, error: contentViewError) {
            Button {
                
            } label: {
                Text("OK")
            }
        }
        .task {
            do {
                tags = try await getQiitaTags(page: 2, perPage: 20, sort: "count")
            } catch {
                contentViewError = .init(error: error)
                isError = true
            }
        }
    }
}
```

## プレビュー
実際のgetQiitaTags処理はenvironmentで注入することができるので、アプリ実行時のenvironmentを変更せずに、プレビューのenvironmentを設定することで様々なシチュエーションを確認することができます。


### GetQiitaTagsFromLocalAction

```swift
#Preview {
    ContentView()
        .environment(\.getQiitaTags, GetQiitaTagsFromLocalAction())
}
```

![GetQiitaTagsFromLocalActionを使って固定データを表示する](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/149bd194-61ff-e39c-027b-8887114c20f6.png)


### GetQiitaTagsErrorAction

```swift
#Preview {
    ContentView()
        .environment(\.getQiitaTags, GetQiitaTagsErrorAction())
}
```

![GetQiitaTagsErrorActionを使ってエラー時の画面を確認する](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/a9b3fdec-9e29-6f20-3f9d-9bf74793364f.png)