💁

[SwiftUI] enumを用いた画面の状態管理のすすめ

2023/09/02に公開

この記事は、iOSDC Japan 2023 SwiftUIでの非同期処理データの状態管理を考えるの発表内容を拡張し、文章化した物です。

サンプルコード
https://github.com/kntkymt/AsyncDataManagementSamples

初めに: 「画面の状態管理」とは

以下のように、ユーザー情報をAPIから取得して表示する画面を考えます。

この画面を実装する際に

  • ユーザー情報を表示する 「成功」

の画面以外に

  • ロードを表示する 「ロード中」
  • エラーを表示する 「失敗」

の二つの画面の状態が存在し、合計三つの画面の状態が存在すると思います。

ロード中 成功 失敗

この記事は、APIから取得するデータに伴う画面の状態管理方法について、簡単な画面からページングまで様々な仕様を例に挙げながら、enumによる実装を見ていく記事になっています。enumというのは以下のような画面・データの状態を表すenumです。

enum DataState<V, E: Error> {
    case idle
    case loading
    case success(V)
    case failure(E)
}

enumを用いた画面の状態管理は、enumによって必要な変数を減らし、コード量を減らし、可読性を上げる効果があると考えています。この記事を通して、enumによるスマートな画面の状態管理の導入から、ページングなどの応用までを知っていただければと思います。

Case 0: enumの導入

Case(仕様・実装シーン) に関して、Before(改善前)After(改善後) でみていきたいと思います。

おさらいも兼ねて、enumの導入からやっていきます。

Before: 普通に記述

まず、普通の変数を使って「ロード中」「成功」「失敗」の3状態を管理する方法を考えましょう。

以下のように3つの変数が必要になると思います。( Result を使えば2変数になるかもしれませんね!)

@State private var data: User?
@State private var error: (any Error)?
@State private var isLoading = false

この3変数を以下のようにbodyで利用してViewを出しわけます。

var body: some View {
    List {
        if isLoading {
	    // ロード中
            EmptyView()
        } else {
            if let data {
                // 成功
                content(user: data)
            } else if error != nil {
                // 失敗
                ErrorStateView()
              }
            }
        }
    }
    .overlay {
        // ロード中
        if isLoading {
            ProgressView()
        }
    }
}

最後に、APIを叩くメソッドを実装します。

func fetchUser() async {
    if isLoading { return }
    // ロード開始
    isLoading = true
    error = nil
    data = nil

    do {
	// APIを叩いて成功したらdataに代入
        data = try await API.getUser()
    } catch {
        // 失敗したらerrorに代入
        self.error = error
    }

    // ロード終了
    isLoading = false
}
全体コード
import SwiftUI

struct Case0_Before: View {

    // MARK: - Property

    @State private var data: User?
    @State private var error: (any Error)?
    @State private var isLoading = false

    // MARK: - Body

    var body: some View {
        List {
            if isLoading {
                EmptyView()
            } else {
                if let data {
                    content(user: data)
                } else if error != nil {
                    ErrorStateView {
                        Task {
                            await fetchUser()
                        }
                    }
                    .frame(height: 300)
                    .frame(maxWidth: .infinity)
                }
            }
        }
        .background(Color(.systemGroupedBackground))
        .overlay {
            if isLoading {
                ProgressView()
            }
        }
        .task {
            await fetchUser()
        }
    }

    func content(user: User) -> some View {
        Group {
            LabeledContent("ID", value: user.id)
            LabeledContent("Name", value: user.name)
            LabeledContent("Email", value: user.email)
            LabeledContent("Location", value: user.location)
        }
    }

    // MARK: - Private

    private func fetchUser() async {
        if isLoading { return }
        isLoading = true
        data = nil
        error = nil

        do {
            data = try await API.getUser()
        } catch {
            self.error = error
        }

        isLoading = false
    }
}

ここで、この実装にはいくつかの問題(不満)があります。

1. bodyのネストが深い

ifが3つあって少し読みにくいです。

var body: some View {
    List {
	// if 1
        if isLoading {
            EmtpyView()
        } else {
	    // if 2
            if let data {
                content(user: data)
	    // if 3
            } else if error != nil {
                ErrorStateView()
              }
            }
        }
    }
}

2. 変数が複数あるため管理が煩雑

(お行儀よく書くと)「dataが取得できたのに、前回のerrorが残っている」等の状態にならないためににAPIを叩く前にerrorとdataを削除する必要があります。

また、data = try await API.getUser()のような結果の代入と、isLoading = falseのロードの終了が独立しているのも微妙です。

func fetchUser() async {
    if isLoading { return }
    isLoading = true
    
    // 前回の状態を削除
    error = nil
    data = nil

    do {
        data = try await API.getUser()
    } catch {
        self.error = error
    }
    
    // 結果の代入とロード終了が独立
    isLoading = false
}

After: enumの導入

以下の、画面・データの状態を表すenumを導入します。

enum DataState<V, E: Error> {
    case idle       // 初期状態
    case loading    // ロード中
    case success(V) // 成功 V: データ
    case failure(E) // 失敗 E: エラー
}

これを用いれば、bodyの記述が以下のように変数が一つで済み、一つのswitchで済むのでネストも浅く見やすくなります。

@State private var dataState: DataState<User, any Error> = .idle

var body: some View {
    List {
        switch dataState {
        case .idle, .loading:
            EmptyView()

        case .success(let value):
            content(user: value)

        case .failure:
            ErrorStateView()
        }
    }
    .overlay {
	if dataState.isLoading {
	    ProgressView()
	}
    }
}

非同期処理も簡潔になります。

func fetchUser() async {
    if dataState.isLoading { return }
    dataState = .loading

    do {
        dataState = .success(try await API.getUser())
    } catch {
        dataState = .failure(error)
    }
}

enumなのでロード開始時に.loadingを入れれば前回の状態は削除されるし、.successを入れればロードも終了し、isLoading = falseも不要です。
常に一つの変数を操作するだけなので、記述が簡潔になります。

また、User?(any Error)?は型の表現力的にそれ以上の意味を持たないので、enumで表現することで画面の状態が明確になり、可読性も上がります。

全体コード
import SwiftUI

private enum DataState<V, E: Error> {
    case idle
    case loading
    case success(V)
    case failure(E)
}

extension DataState {
    var isLoading: Bool {
        if case .loading = self {
            return true
        }

        return false
    }
}

struct Case0_After: View {

    // MARK: - Property

    @State private var dataState: DataState<User, any Error> = .idle

    // MARK: - Body

    var body: some View {
        List {
            switch dataState {
            case .idle, .loading:
                EmptyView()

            case .success(let value):
                content(user: value)

            case .failure:
                ErrorStateView {
                    Task {
                        await fetchUser()
                    }
                }
                .frame(height: 300)
                .frame(maxWidth: .infinity)
            }
        }
        .background(Color(.systemGroupedBackground))
        .overlay {
            if dataState.isLoading {
                ProgressView()
            }
        }
        .task {
            await fetchUser()
        }
    }

    func content(user: User) -> some View {
        Group {
            LabeledContent("ID", value: user.id)
            LabeledContent("Name", value: user.name)
            LabeledContent("Email", value: user.email)
            LabeledContent("Location", value: user.location)
        }
    }

    // MARK: - Private

    private func fetchUser() async {
        if dataState.isLoading { return }
        dataState = .loading

        do {
            dataState = .success(try await API.getUser())
        } catch {
            dataState = .failure(error)
        }
    }
}

さて、おさらい終了ということで、この記事は上記のような状態管理用のenumを用いた様々な実装を見ていく内容になっています。

Case 1: 再読み込み中もデータを表示

再読み込みのことを考えてみましょう。

理想の挙動

ポイントは、再読み込み中にロード表示とデータが同時に表示されている点です。

Before: Case 0のenumで実装

Case 0のenumを利用した場合

enum DataState<V, E: Error> {
    case idle
    case loading
    case success(V)
    case failure(E)
}
@State private var dataState: DataState<User, any Error> = .idle

var body: some View {
    List {
        switch dataState {
        case .idle, .loading:
            EmptyView()

        case .success(let value):
            content(user: value)

        case .failure:
            ErrorStateView()
        }
    }
    .overlay {
	if dataState.isLoading {
	    ProgressView()
	}
    }
    .refreshable {
        // Pull To Refresh(プルリフ)による再読み込み
        await fetchUser()
    }
}

再読み込みをすると、ロード中はデータが消えてしまいます。 また、Pull To Refresh(プルリフ)と二重でロード表示が出てしまいます。

こうなってしまう理由は、再読み込みをすると success(V)loadingsuccess(V) と状態が変化しますがloading ではVが存在せず、データを保持できないためです。

全体コード

After: reLoading(V)

enumに reLoading(V) のcaseを追加します。
(あまり関係ないですが、isLoadingのcomputed propertyの命名の関係上loadinginitialLoadingに変更しています)

enum DataState<V, E: Error> {
    case idle
    
    case initialLoading // 名前変更
    case reLoading(V)   // 追加
    
    case success(V)
    case failure(E)
}

再読み込み時にこのreLoading(V)を利用することで、再読み込み中にもデータを表示できます。

bodyも以下のように変更します。

var body: some View {
    List {
        switch dataState {
        case .idle,
	        .initialLoading:
    	    EmptyView()

        case .success(let value),
	        .reLoading(let value): // successと同じケースに入れる
	    content(user: value)

        case .failure:
	    ErrorStateView()
        }
    }
    .overlay {
        // initialLoadingだけロードを表示
        if dataState.isInitialLoading {
	    ProgressView()
        }
    }
}

.reLoading.successと同じcaseに入れます。これによって、再読み込み中もデータを表示できます。また、読み込み(initialLoading)と再読み込み(reLoading)が分離されたことで、読み込み(initialLoading)時のみProgressViewを表示できるようになり、プルリフと二重でロード表示されることもなくなりました。

非同期処理に関しても変更します。

func fetchUser() async {
    // ロード開始
    switch dataState {
    case .idle, .failure:
        self = .initialLoading
    case .success(let value):
        // successからロードするときはreLoadingへ
        self = .reLoading(value)
    case .initialLoading,
            .reLoading:
        return
    }

    do {
        dataState = .success(try await API.getUser())
    } catch {
        dataState = .failure(error)
    }
}

successからロードするときはreLoadingにし、successのAssociated ValueをreLoading渡します。

また、ロード開始の処理は基本的に画面に依存しないので、startLoadingのようなメソッドに出しておくと各画面での非同期処理が簡潔に記述できます。

extension DataState {
    mutating func startLoading() {
        switch self {
        case .idle, .failure:
            self = .initialLoading
        case .success(let value):
            self = .reLoading(value)
        default:
            return
        }
    }

    var isLoading: Bool {
        switch self {
        case .initialLoading,
                .reLoading:
            return true

        default:
            return false
        }
    }
}

func fetchUser() async {
    if dataState.isLoading { return }
    dataState.startLoading()

    do {
        dataState = .success(try await API.getUser())
    } catch {
        dataState = .failure(error)
    }
}

これで、再読み込み中もデータを表示できました。

全体コード

Alt: loading(V?)

別の実装方法も考えてみましょう。reLoading(V)ではなくloading(V?)を追加する方法もあります。

enum DataState<V, E: Error> {
    case idle
    case loading(V?)
    case success(V)
    case failure(E)
}
var body: some View {
    List {
        switch dataState {
        case .idle,
	        .loading(nil):
    	    EmptyView()

        case .success(let value),
	        .loading(let value?):
	    content(user: value)

        case .failure:
	    ErrorStateView()
        }
    }
    .overlay {
        if dataState.isInitialLoading {
	    ProgressView()
        }
    }
}

.loading(nil).loading(let value?)で分けて記述することで、reLoading(V)の際と同様に再読み込み中にswitchのcaseが変わらないようにします。

extension DataState {
    mutating func startLoading() {
        switch self {
        case .idle, .failure:
            self = .loading(nil)
        case .success(let value):
            self = .loading(value)
        default:
            return
        }
    }
}

func fetchUser() async {
    if dataState.isLoading { return }
    dataState.startLoading()

    do {
        dataState = .success(try await API.getUser())
    } catch {
        dataState = .failure(error)
    }
}
全体コード

reLoading(V)loading(V?)どちらが良いの?

  • initialLoading=loading(nil)
  • reLoading(let value)=loading(let value?)

に対応しており、ほぼ同じ方法で利用できると考えられるため、基本的にはシンタックス上の違いで、好みかなと思います。

ただ、個人的な見解を述べれば、reLoading(V)の方が1 caseが1状態と紐づいている上、結局人間がコードを読むときは「loading(let value?)はVがあるから、前はsuccessで、再読み込みだな」と脳内で変換すると思います。それならば、最初から「再読み込み」を名前で表現しているreLoadingの方が可読性が高いのかなと考えています。

Case 2: 再読み込み中もエラーを表示

エラーからの再読み込みの時を考えます。

理想の挙動

ポイントは、再読み込み中に、ロード表示とエラーが同時に表示されている点です。

Before: Case 1のenumで実装

Case 1のenumで実装してみましょう。

enum DataState<V, E: Error> {
    case idle
    
    case initialLoading
    case reLoading(V)
    
    case success(V)
    case failure(E)
}

Case 1: 再読み込み中もデータを出したい と同じ現象ですね。再読み込みをすると、エラーが消えてしまいます。また、プルリフと二重にロードが表示されているのも微妙です。

エラーからの再読み込みをするとfailure(E)initialLoadingsuccess(V)と状態が変化しますが、initialLoadingにはEが存在しないため、再読み込み中にエラーを表示できません。

全体コード

After: retryLoading(E)

enumにretryLoading(E)を追加します。

enum DataState<V, E: Error> {
    case idle
    
    case initialLoading
    case reLoading(V)
    case retryLoading(E) // 追加
    
    case success(V)
    case failure(E)
}

エラーからの再読み込み時にはこのretryLoading(E)を利用します。

bodyも以下のように修正します。

var body: some View {
    List {
        switch dataState {
        case .idle,
	        .initialLoading:
    	    EmptyView()

        case .success(let value),
	        .reLoading(let value):
	    content(user: value)

        case .failure,
		.retryLoading: // failureと同じケースに入れる
	    ErrorStateView()
        }
    }
    .overlay {
        if dataState.isInitialLoading {
	    ProgressView()
        }
    }
}

failureと同じcaseにretryLoadingを入れます。これによって、エラーからの再読み込み中もエラーを表示できますし、エラーからの再読み込みをinitialLoadingから分離できたことで、プルリフが二重に表示される問題も解消されます。

非同期処理はほぼCase 1と同じで、startLoadingfailureからretryLoadingへの分岐を追加するだけです。

extension DataState {
    mutating func startLoading() {
        switch self {
        case .idle, .failure:
            self = .initialLoading
        case .success(let value):
            self = .reLoading(value)
	// failureからロードするときはretryLoadingへ
	case .failure(let error):
	    self = .retryLoading(error)
        default:
            return
        }
    }
}

func fetchUser() async {
    if dataState.isLoading { return }
    dataState.startLoading()

    do {
        dataState = .success(try await API.getUser())
    } catch {
        dataState = .failure(error)
    }
}

これで実装できました。

全体コード

Alt: loading(V?, E?)

別の実装方法も考えてみましょう。reLoading(V) + retryLoading(E)ではなくloading(V?, E?)を追加する方法もあります。

enum DataState<V, E: Error> {
    case idle
    case loading(V?, E?)
    case success(V)
    case failure(E)
}
var body: some View {
    List {
        switch dataState {
        case .idle,
                .loading(nil, nil),
                .loading(_?, _?):
            EmptyView()

        case .success(let value),
                .loading(let value?, nil):
            content(user: value)

        case .failure,
                .loading(nil, _?):
            ErrorStateView()
        }
    }
}

(現時点では)発生しないはずの、「.loading(_?, _?): VとEが両方存在する状態」も表現できてしまいまうという問題があります。

また、シンタックス上の話でも、.loading(let value?, nil).loading(nil, _?)を人間が読む場合は「.loading(let value?, nil)は...VがあってEがないから...前successで、再読み込みだな」とやはり頭の中で変換が必要になると思います。それならば最初から.reLoading(V), .retryLoading(E)で表現した方が可読性が高いと考えています。

loading(V?)の際は「好みもある」と言いましたが、.loading(V?, E?)(つまり、2個以上OptionalのAssociated Valueを持つ場合)は複雑性がかなり増しますし、存在しない状態も表現できてしまうため個人的にはナシだと思います。

全体コード

考察: enumの状態が増えて複雑になった?

Case 1, 2と見てきて、enumにreLoading(V)retryLoading(E)のcaseを追加しました。
なんだか、状態が増えて複雑になったと感じるかもしれません。

enum DataState<V, E: Error> {
    case idle
    
    case initialLoading
    case reLoading(V)
    case retryLoading(E)
    
    case success(V)
    case failure(E)
}

少し状態を図解してみましょう。
このように状態が存在すると思います。

これに、遷移の矢印を書くとこうなります。

実は、Case 0, 1, 2でやってきたことは、この遷移の矢印の呼び方を変えているだけなんです。
Case 0では、すべての遷移をloading と呼ぶことにしました。

Case 1では、successからの遷移をreLoading と呼ぶことにしました。

Case 2では、failureからの遷移をretryLoading と呼ぶことにしました。

なので、Case 0から1, 2にかけて「全く関係ない状態が増えた」というよりかは、「loadingの分類を細分化」しただけなんです。「どの状態からロードしたか」でloadingをセマンティックに分類しており、表現力が上がっていると言えます。

見方によっては、むしろCase 0, 1の方が「情報を落として簡略化している」と言えるかもしれません。

Case 3: ページング

次はページングを考えます。
投稿(Post)一覧を簡単に表示するListを考えましょう。

struct Post: Identifiable {
    var id: Int
    var title: String
}

理想の挙動

ポイントは、ページング中やページング失敗でも、前ページのデータが表示される点です。

ページング中 ページング失敗

After:  paging(V), pagingFailure(V, E)

ページング中: paging(V), ページング失敗: pagingFailure(V, E)を追加します。
また、failureloadingFailureに名前を変更しています。

enum PagingDataState<V, E: Error> {
    case idle

    case initialLoading
    case retryLoading(E)
    case reLoading(V)

    case success(V)
    case loadingFailure(E)    // 変更

    case paging(V).           // 追加
    case pagingFailure(V, E)  // 追加
}

bodyは以下のようになります。

var body: some View {
    List {
        switch dataState {
        case .idle, .initialLoading:
            EmptyView()

        case .success(let value),
                .reLoading(let value),
		// (A) successと同じ分岐に入れる
                .paging(let value),
                .pagingFailure(let value, _):
	    // (B) 配列が空の時に出すEmptyStateView
            if value.isEmpty {
                Text("投稿はありません")
            } else {
	        // (C) Listの内容
                ForEach(value) { post in
                    Text(post.title)
                }

                // (D) List下部にページングのエラー表示 or ロード表示
                if dataState.isPagingFailure {
                    ErrorStateView {
                        Task {
                            await fetchMore()
                        }
                    }
                } else {
                    ProgressView()
                        .onAppear {
                            Task {
                                await fetchMore()
                            }
                        }
                }
            }

        case .retryLoading,
                .loadingFailure:
            ErrorStateView()
                .frame(height: 300)
                .frame(maxWidth: .infinity)
        }
    }
    .overlay {
        if dataState.isInitialLoading {
            ProgressView()
        }
    }
}

少しだけ複雑なので、分けて解説します。

  • A: paging(V), pagingFailure(V, E)successと同じ分岐に入れます。これによって、ページングやページング失敗でも前ページのデータを表示できます。
  • B: 配列が空の時は「投稿はありません」と表示するEmptyStateViewを表示します。List表示をする画面によくある実装かなと思い紹介しています。
  • C: ForEachで投稿一覧を表示します。
  • D: List下部にページングのエラー表示またはロード表示をします。AでpagingFailure(V, E)successと同じ分岐にした上で、ifでpagingFailure(V, E)をチェックしているのがポイントです。

非同期処理も見ていきましょう。

初回ページ取得

extension PagingDataState {
    mutating func startLoading() {
        switch self {
        case .idle:
            self = .initialLoading

        case .success(let value),
                .pagingFailure(let value, _):
            self = .reLoading(value)

        case .loadingFailure(let error):
            self = .retryLoading(error)

        default:
            return
        }
    }
}

func fetchInitial() async {
    if dataState.isLoading || dataState.isPaging { return }
    dataState.startLoading()

    do {
        dataState = .success(try await API.getPosts(minId: 0, count: 30))
    } catch {
        dataState = .loadingFailure(error)
    }
}

今までとほぼ一緒です。異なる点は以下2点です。

  • dataState.isPagingもロード前にチェックしている点
  • startLoading.pagingFailure(V, E)から.reLoading(V)の遷移が増えている点
    • ページング失敗状態から、List上部に戻ってプルリフをする操作に相当

2ページ目以降の取得(ページング)

extension PagingDataState {
    mutating func startPaging() {
        switch self {
        case .success(let value),
                .pagingFailure(let value, _):
            self = .paging(value)

        default:
            return
        }
    }
}

func fetchMore() async {
    if dataState.isLoading || dataState.isPaging { return }
    guard let users = dataState.value, let lastId = users.last?.id else { return }
    dataState.startPaging()

    do {
        let newUsers = try await API.getPosts(minId: lastId + 1, count: 30)
        dataState = .success(users + newUsers)
    } catch {
        dataState = .pagingFailure(users, error)
    }
}

ページングのカーソルの処理が入っていますが、ほぼ一緒です。異なる点は以下の2点です。

  • startLoadingstartPagingになっている点
    • ページングできる(ページングが発火する)のはsuccess: 成功状態とpagingFailure: ページング失敗状態だけです。
  • loadingFailurepagingFailureになっている点

これで実装できました。

全体コード

これも図解してみましょう。
まずベースはCase 2のenumで、ここは共通しています。

ここに、paging(V)pagingFailure(V, E)を追加します。

最後に、pagingFailure(V, E)でも再読み込み(プルリフ)はできるので、successloadingFailureへのreLoadingの遷移を生やします。

ページングまで対応できているにしては、以外と単純な構造なんじゃないでしょうか。

Case EX: 読み込み中にプレイスホルダーを表示

redacted()を用いてロード表示をする場合を考えてみましょう。redacted()は、スケルトンViewのようなプレイスホルダー表示ができるmodifierです。

https://developer.apple.com/documentation/swiftui/view/redacted(reason:)

// Case 2と同じenum
enum DataState<V, E: Error> {
    case idle
    
    case initialLoading
    case reLoading(V)
    case retryLoading(E)
    
    case success(V)
    case failure(E)
}
extension DataState {
    var value: V? {
        switch self {
        case .reLoading(let value),
                .success(let value):
            return value

        default:
            return nil
        }
    }
}

var body: some View {
    List {
        switch dataState {
        case .idle,
                .initialLoading,
                .success,
                .reLoading:

            let value = dataState.value ?? .stub
            content(user: value)
                .redacted(reason: dataState.value == nil ? .placeholder : [])

        case .failure,
                .retryLoading:
            ErrorStateView()
        }
    }
}

ポイントは以下2点です。

  • .idle, .initialLoading, .success, .reLoadingをすべて同じcaseで記述する点
  • .redacted(reason: dataState.value == nil ? .placeholder : [])と三項演算子でredactedを利用する点

これによって、「ロード中のプレイスホルダーView」と「ロード後の実際のView」の Structural Identityを変えずに記述することができ、余分な描画を減らすことができます。

全体コード

また、以下のような書き方は、「ロード中のプレイスホルダーView」と「ロード後の実際のView」の間でStructural Identityが変わっており、余分な再描画が行われています。

var body: some View {
    List {
        switch dataState {
        case .idle, .initialLoading:
            content(user: .stub)
                .redacted(reason: .placeholder)

        case .success(let value),
                .reLoading(let value):
            content(user: value)

        case .failure,
                .retryLoading:
            ErrorStateView()
        }
    }
}

ForEachの時はどちらでも良い

前述話はForEachを使わない場合の話であって、ForEachの時(ページングするList)は少し事情が異なります。
結論から言うと、ForEachの時は前述の工夫はしてもしなくてもどちらでも良いです。
以下二つは同じ挙動をします。

var body: some View {
    List {
        switch dataState {
        case .idle,
                .initialLoading:
	    ForEach(Post.stub) { post in
	        Text(post)
	    }
	    .redacted(reason: .placeholder)
	
                
	case .success(let value),
                .reLoading(let value):
            ForEach(value) { post in
	        Text(post)
	    }

        case .failure,
                .retryLoading:
            ErrorStateView()
        }
    }
}
var body: some View {
    List {
        switch dataState {
        case .idle,
                .initialLoading,
                .success,
                .reLoading:

            let value = dataState.value ?? .stub
            ForEach(value) { post in
	        Text(post)
	    }
            .redacted(reason: dataState.value == nil ? .placeholder : [])

        case .failure,
                .retryLoading:
            ErrorStateView()
        }
    }
}

.idle, .initialLoading, .success, .reLoadingをすべて一つのcaseで記述してStructural Identityを同一にしても、ForEach以下はExplicit Identityで管理されているため、stub<->value間でidが変化した時に再描画が行われます。このことから、上二つのコードは同じ挙動をすると言え、ForEachの時はどちらで書いても問題ありません。

全体コード

ただし、ForEach以外の部分にViewがある場合は、そのViewはStructural Identityで管理されているため、再描画を減らすため一つのcaseで記述した方が良いと言えます。

var body: some View {
    List {
        switch dataState {
        case .idle,
                .initialLoading,
                .success,
                .reLoading:
		
	    Section {
                let value = dataState.value ?? .stub
                ForEach(value) { post in
	            Text(post)
	        }
                .redacted(reason: dataState.value == nil ? .placeholder : [])
	    } header: {
	        Header()
	    } footer: {
	        Footer()
	    }
	    
        case .failure,
                .retryLoading:
            ErrorStateView()
        }
    }
}

Case 4: 再読み込みでエラーが発生しても、前のデータを表示し続ける

再読み込みでエラーが発生しても、前のデータを表示し続けるというケースを考えましょう。
ただ、エラー表示をしないと不親切なので代わりにバナーでエラーを表示します。

Before: Case 2のenumを用いて実装

(当たり前ではありますが)Case 2のenumではエラーが発生するとfailure(E)に遷移するので、前のデータは消えてしまいます。

enum DataState<V, E: Error> {
    case idle
    
    case initialLoading
    case reLoading(V)
    case retryLoading(E)
    
    case success(V)
    case failure(E)
}

全体コード

After: reLoadingFailure(V, E)

reLoadingFailure(V, E)を追加します。

再読み込みからの失敗時はこのreLoadingFailure(V, E)を用います。
(また、failureloadingFailureに名前を変更しています。)

enum DataState<V, E: Error> {
    case idle
    
    case initialLoading
    case reLoading(V)
    case retryLoading(E)
    
    case success(V)
    
    case loadingFailure(E)      // 変更
    case reLoadingFailure(V, E) // 追加
}

bodyは以下のようになります。

extension DataState {
    var isFailure: Bool {
        switch self {
        case .loadingFailure,
                .reLoadingFailure: // valueを持っているが、失敗扱い
            return true

        default:
            return false
        }
    }

    var error: (any Error)? {
        switch self {
        case .retryLoading(let error),
                .loadingFailure(let error),
                .reLoadingFailure(_, let error):
            return error

        default:
            return nil
        }
    }
}

var body: some View {
    List {
        switch dataState {
        case .idle,
                .initialLoading:
            EmptyView()

        case .success(let value),
                .reLoading(let value),
		// successと同じケースに入れる
                .reLoadingFailure(let value, _):
            content(user: value)

        case .loadingFailure,
                .retryLoading:
            ErrorStateView()
                .frame(height: 300)
                .frame(maxWidth: .infinity)
        }
    }
    .onChange(of: dataState.isFailure) { isFailure in
        // エラーバナーを表示
        guard isFailure, let error = dataState.error else { return }
        MessageBanner.showError("エラーが発生しました", with: error.localizedDescription)
    }
}

.reLoadingFailure(let value, _)successと同じcaseに入れます。これによって再読み込みでエラーになっても前のデータを表示し続けることができます。また、dataState.isFailureonChangeで監視することで、reLoadingFailureのエラーの発生を検知することができ、バナーによるエラー表示ができます。(バナーのインターフェースが命令的なのはご容赦ください)

非同期処理は以下のようになります。

extension DataState {
    mutating func startLoading() {
        switch self {
        case .idle:
            self = .initialLoading
        case .success(let value),
                .reLoadingFailure(let value, _):
            self = .reLoading(value)
        case .loadingFailure(let error):
            self = .retryLoading(error)
        default:
            return
        }
    }
    
    var value: V? {
        switch self {
        case .reLoading(let value),
                .success(let value),
                .reLoadingFailure(let value, _):
            return value

        default:
            return nil
        }
    }
}

func fetchUser() async {
    if dataState.isLoading { return }
    dataState.startLoading()

    do {
        dataState = .success(try await API.getUser())
    } catch {
        // dataState.valueがあったら=reLoadingだったら、reLoadingFailure
        dataState = dataState.value.map { .reLoadingFailure($0, error) } ?? .loadingFailure(error)
    }
}

今までと異なる点はエラー時の遷移だけです。
エラーが発生した際に、dataState.valueがあったら(=reLoadingだったら)reLoadingFailure、そうでない場合はloadingFailureに遷移させます。

全体コード

これも図解して見ましょう。

pagingに似ています。「初回successするまで」と「初回successした後」でパートを分けて考えるとわかりやすいです。

初回successするまで

初回successするまでの失敗がloadingFailure(E)で表されています。一度successしたら、loadingFailure(E)が使われることはもうありません。

初回successした後

初回successした後の失敗がreLoadingFailure(V, E)で表されています。
一度データを取得したら、それを保持し続けることがわかります。

ページング

ページングでも実装できます。

enum PagingDataState<V, E: Error> {
    case idle

    case initialLoading
    case retryLoading(E)
    case reLoading(V)

    case paging(V)

    case success(V)
    case loadingFailure(E)      // 変更
    case reLoadingFailure(V, E) // 追加
    case pagingFailure(V, E)
}

全体コード

これも図解して見ましょう。

流石にここまでくると複雑と言わざるを得ませんが、こちらも「初回successするまで」と「初回successした後」でパートを分けると多少わかりやすくなります。

初回successするまで

ここはページングなしと同じです。

初回successした後

ここが複雑ですが

  • どの状態からもreLoading, pagingが可能
  • reLoadingの失敗はreLoadingFailureに遷移
  • pagingの失敗はpagingFailureに遷移

という形になっています。

考察: 結局どれを選択すれば良い?

ここまで5つのCaseを見てきて、enumの状態が細分化されている方が、より多くの仕様に対応できることがわかりました。
しかし、結局どのenumを採用すれば良いのでしょうか?

大は小を兼ねる

一つ重要なポイントとして、本記事のCaseで紹介したenumは、それ以前のCaseの実装も実現できます。

たとえば、「Case 4: 再読み込みでエラーが発生しても、前のデータを表示し続ける」 のenumを用いて 「Case 2: 再読み込み中もエラーを表示」 の挙動を実装することができます。

enum DataState<V, E: Error> {
    case idle

    case initialLoading
    case reLoading(V)
    case retryLoading(E)

    case success(V)
    case loadingFailure(E)
    case reLoadingFailure(V, E)
}

var body: some View {
    List {
        switch dataState {
        case .idle,
                .initialLoading:
            EmptyView()

        case .success(let value),
                .reLoading(let value):
            content(user: value)

        case .loadingFailure,
		.reLoadingFailure,
                .retryLoading:
            ErrorStateView()
        }
    }
}

Case 4のenumは、Case2のenumのfailurereLoadingFailure(V, E)loadingFailure(E)に細分化した物であるため、reLoadingFailure(V, E)loadingFailure(E)を同じcaseに入れれば、Case 2と同じ挙動に戻せるということです。

細分化されたenumを選択することが常に最善とは限らない

とはいえ、使わないのにreLoadingFailure(V, E)を持っていても仕方がないですし、余分な複雑性が増すだけなので、細分化されたenumを選択することが常に最善とは限らないと思います。

ここの選択は、「仕様」「将来の拡張性」「可読性」「ロジック管理コスト」などのバランスをとった上で、最適なenumを各自が選択するのが良いと思いました。

コンポーネントを用いた状態管理

今までenum単体でViewを実装する方針を見てきました。
enum単体で実装すると、「考察: 結局どれを選択すれば良い? 大は小を兼ねる」 で言及した様に、 switchで各caseの分岐を変えることで、画面ごとに挙動を作り変えることが可能です。

しかし、実際の開発シーンでは、多くの画面で仕様が統一されていることが多く、多くの画面で同じようなswitchのボイラープレートコードを書くことになってしまいます。

ここで、enumによる管理は辞めて、コンポーネントを用いて記述を共通化することを考えましょう。

var body: some View {
    PagingList(
        success: listContent,
        fetchInitial: { try await API.getPosts(minId: 0, count: 30) },
        fetchMore: { try await API.getPosts(minId: $0.id + 1, count: 30) }
    )
}

このPagingListsuccess:にView, fetchInitial, fetchMoreに非同期処理を渡せるため、画面の状態管理を共通化して簡潔に記述できるコンポーネントです。これを用いることで、ボイラープレートコードが減ることが期待できます。

しかし、このコンポーネントも万能ではないので、どうしてもコンポーネントでは対応できないケースは存在すると思います。

enum + コンポーネントを用いた状態管理

ならば、enumとコンポーネントのいいとこ取りをしましょう!

状態管理にenumを採用した上で、ボイラープレートを回避するためのコンポーネントを採用し、可能な限りコンポーネントを用いて実装するのが良いと思います。 これは挙動だけでなく、エラー表示やロード表示をコンポーネント内で共通化できるためデザインの統一にも効果的で、デザインシステムとも相性が良いです。
その上で、コンポーネントでは実装できないケースや、アプリの性質上全く共通化できない場合は、enumを直接用いて実装するのが良いと思います。

コンポーネントの実装方法は様々ですが、4種類考えてみました。
(各Styleの名前は適当につけています)

Switch Fetch Bindable Fetch Simple Fetch
状態の操作 利用者 👨‍💻 内部 🤖 (利用者も可👨‍💻) 内部 🤖 内部 🤖
状態の取得 可能 ⭕ 可能 ⭕ 可能 ⭕ 不可能 ❌

Switch View Style

例は 「Case 4: 再読み込みでエラーが発生しても、前のデータを表示し続ける」 のページングのenumを採用しています。

@State private var dataState: PagingDataState<[Post], any Error> = .idle

var body: some View {
    PagingList(dataState: dataState, listContent: listContent) {
        Task {
            await fetchMore() // ページング
        }
    }
    .refreshable {
        await fetchInitial() // プルリフ
    }
    .task {
        await fetchInitial() // 初回読み込み
    }
    .onChange(of: dataState.isFailure) { bool in
        // エラー表示
        guard bool, let error = dataState.error else { return }
        MessageBanner.showError("エラーが発生しました", with: error.localizedDescription)
    }
}

これは内部でenumのswitchをするだけのコンポーネントです。これだけでも十分利用側が簡潔になると思います。

また、今「ページング」「プルリフ」「初回読み込み」「エラー表示」の4つのユースケースを書いています。SwitchViewStyleは内部でenumのswitchしてるだけなので、こういったイベントは従来のままコンポーネントの外側に書きます。一番使いやすいコンポーネントだと思います。

全体コード

Fetch View Style

@StateObject private var viewStore: LoadingContentViewStore<Post> = .init(
    fetchInitial: { try await API.getPosts(minId: 0, count: 30) },
    fetchMore: { try await API.getPosts(minId: $0.id + 1, count: 30) }
)

var body: some View {
    PagingList(viewStore: viewStore, success: listContent)
        .task {
	    // 利用側からもイベントを発火可能
            await viewStore.loadInitial()
        }
        .onChange(of: viewStore.dataState.isFailure) { bool in
	    // エラー表示
            guard bool, let error = viewStore.dataState.error else { return }
            MessageBanner.showError("エラーが発生しました", with: error.localizedDescription)
        }
}

これは内部で状態操作もするコンポーネントです。
専用のObservableObjectであるLoadingContentViewStoreに状態管理を任せ、これをコンポーネントに渡しています。

ポイントは、 viewStore.loadInitial()のように利用側からもイベントを発火可能な点です。
基本は共通してるけど、「初回取得やエラー表示は画面ごとに変えたい」など、少しだけ画面ごとに挙動をカスタマイズしたいケースに合っています。

全体コード

Bindable Fetch View Style

@State private var dataState: PagingDataState<[Post], any Error> = .idle

var body: some View {
    PagingList(
        dataState: $dataState,
        success: listContent,
        fetchInitial: { try await API.getPosts(minId: 0, count: 30) },
        fetchMore: { try await API.getPosts(minId: $0.id + 1, count: 30) }
    )
    .onChange(of: dataState.isFailure) { isFailure in
        guard isFailure, let error = dataState.error else { return }
        MessageBanner.showError("エラーが発生しました", with: error.localizedDescription)
    }
}

Bindable Fetch View Styleは先ほどのFetch View Styleとほぼ同じですが、StateObjectを使わずにBindingを渡す形なので、外側からロードの発火ができなくなっています。利用者側から状態を読むことだけはできます。

ほぼコンポーネント共通でいいが、「エラー表示などは画面ごとにやりたい」など、状態のReadだけ用いて挙動をカスタマイズできれば良いケースに合っています。

全体コード

Simple Fetch View Style

var body: some View {
    PagingList(
        success: listContent,
        fetchInitial: { try await API.getPosts(minId: 0, count: 30) },
        fetchMore: { try await API.getPosts(minId: $0.id + 1, count: 30) }
    )
}

「コンポーネントを用いた状態管理」で紹介したコンポーネントと同一です。
Simple Fetch View Styleは、すべて内部で行うコンポーネントです。そのため、利用者側からは何も挙動をカスタマイズできませんが、最も簡潔に記述できます。
全く挙動をカスタマイズする必要がないケースに良いと思います。

全体コード

まとめ

  • enumの表現力を上げれば様々な仕様に対応可能
    • 使わない状態を持っていても意味がないため、最適な粒度を選択
  • 多くのケースではコンポーネントを利用してボイラープレートを回避
        - ボイラープレートを回避しつつ、カスタマイズもできる
    • 挙動・デザインの統一にも効果的
    • 最適な実装を選択

他の意見

サンプルコード

僕はenumによる状態遷移とデータやエラーの保持を分けるかなと思いました。

Case 2などは状態のreLoading, retryLoadingは本質的には「loading」と「success, failureどちらからの遷移か」の状態の組み合わせなので、V, Eをenumの外で管理し、enumは単純な形にして必要になったら組み合わせるのも選択としてアリだと思っています。(ただ、状態操作が増えるので、ここを許容できるか否かが選択のポイントになると思います。)

private enum DataState {
    case idle
    case loading
    case success
    case failure
}

extension DataState {
    var isLoading: Bool {
        if case .loading = self {
            return true
        }

        return false
    }
}

struct Case2_Alt2: View {

    // MARK: - Property

    @State private var dataState: DataState = .idle
    @State private var user: User?
    @State private var error: (any Error)?

    // MARK: - Body

    var body: some View {
        List {
            if let user {
                content(user: user)
            }
            if let _ = error {
                ErrorStateView()
                    .frame(height: 300)
                    .frame(maxWidth: .infinity)
            }
        }
        .background(Color(.systemGroupedBackground))
        .overlay {
            if dataState.isLoading {
                ProgressView()
            }
        }
        .refreshable {
            await fetchUser()
        }
        .task {
            await fetchUser()
        }
    }

    func fetchUser() async {
        if dataState.isLoading { return }
        dataState = .loading

        do {
            user = try await API.getUser()
            dataState = .success
            error = nil
        } catch {
            self.error = error
            dataState = .failure
            user = nil
        }
    }
}

僕はenumはObservableObjectの内部だけで使って、View側に見せるときはOptional等にしてます。...(中略)...状態遷移は型安全にするけど、Viewはただデータがあれば表示するだけみたいな。

これはとても面白い視点だと思いました!Viewでは各状態について意識せず、「存在するなら、表示する」とすればViewの記述が減り、switchする必要もなくなって良いかもしれません!ObservedObjectを使わない場合でも同様に利用できて良いですね。(コレだと処理の隠蔽の側面は実現できていませんが)

@State private var dataState: DataState<User, any Error> = .idle

var body: some View {
    List {
        if let user = dataState.data {
            content(user: user)
        }
        if let _ = dataState.error {
            ErrorStateView()
                .frame(height: 300)
                .frame(maxWidth: .infinity)
        }
    }
    .background(Color(.systemGroupedBackground))
    .overlay {
        if dataState.isInitialLoading {
            ProgressView()
        }
    }
    .refreshable {
        await fetchUser()
    }
    .task {
        await fetchUser()
    }
}
サンプルコード

enum + コンポーネントによる管理に似た形になっており、似たようなことをしている方がいて安心しました。
handleErrorのような状態操作のメソッドが生えているのが面白いと思いました。

参考

モバイルアプリにおける読み込み処理周りのUIパターンとSwiftUIによる実装例
https://zenn.dev/rockname/articles/0a0e8e603455ff

Async
https://github.com/bannzai/Async

Building reusable content loading view in SwiftUI
https://dmytro-anokhin.medium.com/building-reusable-content-loading-view-with-swiftui-and-combine-f4886fe77e2b

SwiftUI で「グループスタンプ」というチャット機能を5日間で作った話
https://medium.com/kauche/swiftui-group-chat-812cd6636e49

Discussion