🎨

モバイルアプリにおける読み込み処理周りのUIパターンとSwiftUIによる実装例

2021/12/21に公開

どのモバイルアプリを作るにあたっても、何かしらコンテンツを読み込んで表示するという処理があると思います

しかしながら、実際にデザインデータとして表現されるのは正常に読み込めた状態 (Ideal State) のみで、読み込み中や読み込み失敗時のUI表現についてはデザインデータとして表現されないケースも多いかと思います


(https://www.scotthurff.com/posts/why-your-user-interface-is-awkward-youre-ignoring-the-ui-stack/ より)

本記事では、モバイルアプリにおいてコンテンツを読み込んで表示するにあたって、どのようなUIパターンが考えられるかについて網羅的に紹介します

さらに、それらUI表現のSwiftUIによる実装例もご紹介します

(こちらの記事は ミクシィグループ Advent Calendar 2021 の21日目の記事になります)

読み込み処理のフロー

コンテンツをネットワーク経由で取得して表示するまでに、簡単ですが以下のような状態が考えられます

以降ではそれぞれの状態におけるUIパターンについて考えていきます

読み込み中

コンテンツを読み込み中の際、仮に何も表示しなかった場合に、読み込み時間が長いとユーザーにとっては何かアプリが不具合で固まってしまったかのように見えてしまいます

そうならないようにも、ユーザーに読み込み中の進捗を伝えてあげる必要があります

その常套手段として、 Indicator がよく使用されます
読み込み中の画面中央にクルクルと表示させるUIパターンです


(https://material.io/components/progress-indicators#circular-progress-indicators より)

それ以外にも、さらにユーザーの体感待ち時間を減らす工夫として、 Skeleton Screen のUIパターンが考えられます
Skeleton Screenとは、コンテンツが完全に読み込まれる前にプレースホルダーを表示するUIパターンです

Material Designのドキュメントからリンクされた Connect, No Matter the Speed という記事でもこのSkeleton ScreenのUIパターンは推奨されています

Human Interface Guidelinesでも、以下のような Skeleton Screenを推奨するような一文 が見受けられます

Show the screen immediately, and use placeholder text, graphics, or animations to identify where content isn't available yet. Replace these placeholder elements as the content loads.

読み込み完了

読み込みの結果によって、表示すべきUIは大きく変わっていきます
以下ではそれぞれのケースについて説明していきます

読み込み成功

読み込みに成功した場合、素直にそのコンテンツを表示して上げましょう

しかし、正常に読み込めはしたが、そのコンテンツが空である場合もあります
これを Empty State と呼びます

この状態の場合、ユーザーはアプリにまだ慣れていないケースが多いため、ユーザーを案内するような文言及び導線と一緒にUIを表示できると良さそうです


(https://material.io/design/communication/empty-states.html#content より)

読み込み失敗

読み込みに失敗した場合、ユーザーにどのようなエラーであるかわかりやすい文言で説明しつつ、もし復帰可能なエラーであればリトライできる導線を表示して上げましょう


(https://material.io/archive/guidelines/patterns/errors.html#errors-app-errors より)

代表的なエラー内容として、インターネットに接続されていないというケースが挙げられます
もし、オンラインであることが前提となる画面であれば、インターネット接続を常に監視し、その状態に変化があればわかりやすくユーザーに表示して上げられると良いでしょう


(https://medium.com/google-design/connect-no-matter-the-speed-3b81cfd3355a より)

追加読み込み

リスト表示のような場合、追加で読み込む処理が考えられます

上部への追加の読み込みは、Pull to Refreshがパターンとして挙げられます

(https://material.io/design/platform-guidance/android-swipe-to-refresh.html#usage より)

下部への追加の読み込みは、リストが下までスクロールしたらIndicatorを下部に表示するパターンが良いでしょう


(https://material.io/components/progress-indicators#circular-progress-indicators より)

追加の読み込み処理でエラーが発生してしまった場合、AlertやDialogを表示してユーザーの動作を妨げるのではなく、 Snackbarのようなコンポーネントを使うのが好ましいでしょう


(https://material.io/components/snackbars#behavior より)

あるいは、上部, 下部のIndicatorが表示されていた領域にエラーのメッセージを表示してあげると、どのアクションに対するフィードバックであるかがわかりやすくて良いでしょう
Twitterでは追加の読み込みに失敗するとそのようなUIが表示されます

SwiftUIによる実装例

さて、ここまで説明してきたUIパターンを、SwiftUIで実装に落とし込んでみます

サンプルアプリとして、Twitterのようにユーザーの投稿一覧を表示するアプリを作ります
前提として、アーキテクチャにはMVVMを採用します

先に、サンプルアプリのリポジトリはこちらになります
https://github.com/rockname/SwiftUILoadingStateSample


まずは、読み込み処理におけるStateについて整理しましょう

Stateは大きく以下の4つが考えられます

  • 読み込み前
  • 読み込み中
  • 読み込み失敗
  • 読み込み成功

このStateを表す型として LoadingState をenumで定義します

enum LoadingState<Value> {
    case idle          // 読み込み前
    case loading       // 読み込み中
    case failed(Error) // 読み込み失敗
    case loaded(Value) // 読み込み成功
}

このStateをViewModelで保持し、SwiftUIのViewへbindingします

TimelineViewModel.swift
@MainActor
class TimelineViewModel: ObservableObject {
    @Published private(set) var loadingState: LoadingState = .idle
    ...
}
TimelineView.swift
struct TimelineView: View {
    @StateObject private var viewModel: TimelineViewModel()

    var body: some View {
        Group {
            switch viewModel.loadingState {
            case .idle: ...
            case .loading: ...
            case .loaded: ...
            case .failed: ...
            }
        }
    }
}

ここからは、各状態における実装をひとつずつ考えていきます

まずは .idle です
ここでは読み込み前の状態なので何も表示するものがありません
空のViewを定義しつつ、ViewModelへonAppearのイベントを伝搬させましょう

TimelineView.swift
Group {
    switch viewModel.loadingState {
    case .idle: VStack {}
    case .loading: ...
    case .loaded: ...
    case .failed: ...
    }
}
.onAppear {
    viewModel.onAppear()
}

ViewModel側ではonAppearのタイミングでTimelineを取得しにいく処理を書いてあげましょう
その際、LoadingStateは適切な値に更新して上げましょう

TimelineViewModel.swift
func onAppear() {
    fetchTimeline()
}

private func fetchTimeline() {
    loadingState = .loading
    Task {
        do {
            let fetched = try await postRepository.fetchTimeline()
            loadingState = .loaded(fetched)
        } catch {
            loadingState = .failed(error)
        }
    }
}

次に .loading 状態のView実装について考えましょう
SwiftUIでは redacted というmodifierが用意されていて、これによりSkeleton Screenの実装が容易になります
(詳しい実装については こちらの記事 をご参考ください)

Skeleton Screenで表示するTimeline用のダミーデータが必要となるので、ViewModelに定義します

TimelineViewModel.swift
let placeholder: [Post] = (0..<5).map {
    Post(
        id: $0,
        userName: Array(repeating: " ", count: 10).joined(),
        iconURL: URL(string: "https://example.com/sample.png")!,
        content: Array(repeating: " ", count: 30).joined()
    )
}

View側では、上記placeholderを参照して .redacted で装飾されたViewを表示します

TimelineView.swift
case .loading: 
    List(viewModel.placeholder) { post in
        PostView(post: post)
            .padding(.vertical, 8)
            .listRowSeparator(.hidden)
        }
    }
    .listStyle(.plain)onPostButtonTapped
    .redacted(reason: .placeholder)
    .modifier(EaseInOutAnimation())
    .disabled(true)

次は .failed 状態について考えます
読み込みに失敗したら、適切なエラーメッセージとリトライボタンをViewに配置して上げましょう

TimelineView.swift
case .failed:
    HStack {
        Spacer()
        VStack(spacing: 8) {
            Text("Failed to load your timeline")
                .font(.body)
                .foregroundColor(Color(uiColor: .secondaryLabel))
            Button {
                viewModel.onLoadingRetry()
            } label: {
                Text("Try again")
                    .foregroundColor(Color(uiColor: .link))
            }
        }
        Spacer()
    }

ViewModel側ではリトライボタンをイベントを受け取って、Timelineを取得しなおします

TimelineViewModel.swift
func onLoadingRetry() {
    fetchTimeline()
}

次は .loaded について考えます

読み込みが完了したら、Timelineが空かどうかを判別し、空であればEmpty Stateを表示しましょう
Empty Stateでは、Timelineが空であることを通知しつつ、ユーザーの投稿を促すような導線を配置しましょう

TimelineView.swift
case let .loaded(timeline):
    if timeline.isEmpty {
        HStack {
            Spacer()
            VStack(spacing: 8) {
                Text("There are no posts yet")
                    .font(.body)
                    .foregroundColor(Color(uiColor: .secondaryLabel))
                Button {
                    viewModel.onPostButtonTapped()
                } label: {
                    Text("Let's post!")
                        .foregroundColor(Color(uiColor: .link))
                }
            }
            Spacer()
        }
    } else {
        ...
    }

Timelineが空でなければ、コンテンツを表示して上げましょう

TimelineView.swift
case let .loaded(timeline):
    if timeline.isEmpty {
        ...
    } else {
        List(timeline) { post in
            PostView(post: post)
                .padding(.vertical, 8)
                .listRowSeparator(.hidden)
        }
    }

さて、ここまでで読み込み処理におけるすべての状態を表現できました
ここからはさらに、Timelineを追加で読み込む場合について考えていきましょう

まずは、Timelineを下までスクロールして一番下までたどり着いた時に、追加で読み込めるようにすることを考えます

SwiftUIでこの仕様を満たそうとする場合、まずはListで表示している各Viewの onAppear イベントをViewModelへ伝搬します

TimelineView.swift
List(timeline) { post in
    PostView(post: post)
        .padding(.vertical, 8)
        .listRowSeparator(.hidden)
+       .onAppear {
+           viewModel.onPostAppear(post: post)
+       }
}

ViewModel側では、そのViewの表示しているPostがTimelineの末尾であるかを判定し、追加で読み込む処理を発火します

TimelineViewModel.swift
func onPostAppear(post: Post) {
    if timeline.firstIndex(where: { $0.id == post.id }) == timeline.endIndex - 1 {
        // Timeline追加読み込み処理
    }
}

Timelineの追加読み込み状態をハンドリングできるようにするために、LoadingStateが読み込み結果を保持しないように修正をしつつ、「Timelineを管理する変数」と「初回読み込み状態を管理する変数」と「末尾からの追加読み込み状態を管理する変数」を新たに定義していきます

- enum LoadingState<Value> {
+ enum LoadingState {
    case idle
    case loading
    case failed(Error)
-   case loaded(Value)
+   case loaded
}
TimelineViewModel.swift
@MainActor
class TimelineViewModel: ObservableObject {
+   @Published private(set) var timeline = [Post]()
-   @Published private(set) var loadingState: LoadingState = .idle
+   @Published private(set) var loadingAtFirstState: LoadingState = .idle
+   @Published private(set) var loadingOlderState: LoadingState = .idle
    ...
-   private func fetchTimeline() {
+   private func fetchTimelineAtFirst() {
-       loadingState = .loading
+       loadingAtFirstState = .loading
        Task {
            do {
                let fetched = try await postRepository.fetchTimeline()
-               loadingState = .loaded(fetched)
+               loadingAtFirstState = .loaded(fetched)
            } catch {
-               loadingState = .failed(error)
+               loadingAtFirstState = .failed(error)
            }
        }
    }
    ...
}

追加読み込み状態を管理する変数 loadingOlderState が定義できたので、末尾からの追加読み込み処理を行うメソッドを定義していきます

TimelineViewModel.swift
func onPostAppear(post: Post) {
    if timeline.firstIndex(where: { $0.id == post.id }) == timeline.endIndex - 1 {
        fetchTimelineOlder()
    }
}

private func fetchTimelineOlder() {
    loadingOlderState = .loading
    Task {
        do {
            let fetched = try await postRepository.fetchTimeline(before: timeline.last!.id)
            loadingOlderState = .loaded
            timeline = timeline + fetched
        } catch {
            loadingOlderState = .failed(error)
        }
    }
}

続いてはView側の実装です
追加読み込み中であることをユーザーへ伝えるために、リストの下側にIndicatorを表示しようと思います

それを可能にするために、Listの内側にForEachを組み合わせて使います

TimelineView.swift
List {
+   ForEach(viewModel.timeline) { post in
        PostView(post: post)
            .padding(.vertical, 8)
            .listRowSeparator(.hidden)
            .onAppear {
                viewModel.onPostAppear(post: post)
            }
+   }
}

あとは、追加読み込み中であればScrollViewの下側にIndicatorが表示されるように実装していきます

TimelineView.swift
List {
    ForEach(viewModel.timeline) { post in
        ...
    }

    switch viewModel.loadingOlderState {
    case .idle, .loaded: EmptyView()
    case .loading:
        HStack {
            Spacer()
            ProgressView()
            Spacer()
        }
    case .failed: ...
    }
}

追加読み込みに失敗した場合、Indicatorを表示していた箇所にエラー文言とリトライ導線を表示して上げましょう

TimelineView.swift
case .failed:
    HStack {
        Spacer()
        VStack(spacing: 8) {
            Text("Failed to load your timeline")
                .font(.body)
                .foregroundColor(Color(uiColor: .secondaryLabel))
            Button {
                viewModel.onLoadingOlderRetry()
            } label: {
                Text("Try again")
                    .foregroundColor(Color(uiColor: .link))
            }
            .buttonStyle(PlainButtonStyle())
        }
        Spacer()
    }


(リトライ時にはなぜかIndicatorが表示されないというバグを引いています...知見のある方は何か教えていただけると幸いです)

最後に、上から追加で読み込む場合を考えましょう

SwiftUIではPull to Refreshを実装するためのAPIとして refreshable が提供されているのでこちらを活用します
下からの追加読み込みと同様に、エラー文言とリトライ導線はIndicatorを表示していた部分に表示できるのが理想ではありますが、現状このrefreshable APIではIndicatorの表示領域をカスタマイズすることは残念ながらできません
そこで代わりに、Material Designの Snackbar 相当のコンポーネントを作成して利用することを考えます (実装は こちら を参考にしました)

まずはViewModel側にrefreshableのイベントを伝搬します
このとき、ViewModelのメソッドはasyncにする必要があります
awaitしているTaskが実行中の間、Pull to RefeshのIndicatorが表示される仕組みになっています
読み込み失敗時に表示されるようにsnackbarのカスタムmodifierも追加しておきます

TimelineView.swift
List { ... }
    .refreshable {
        await viewModel.onRefresh()
    }
    .snackbar(configuration: $viewModel.snackbarConfiguration)

ViewModel側では以下のように上から新しいTimelineを取得しつつ、失敗したらSnackbar表示のために用意した@Publishedなpropertyを更新します

TimelineViewModel.swift
@Published var snackbarConfiguration = Snackbar.Configuration(
    text: "",
    isShown: false
)

...

func onRefresh() async {
    await fetchTimelineNewer()
}

private func fetchTimelineNewer() async {
    do {
        let fetched = try await postRepository.fetchTimeline(after: timeline.first!.id)
        timeline = fetched + timeline
    } catch {
        showSnackbar(text: "Failed to load newer timeline")
    }
}

private func showSnackbar(text: String) {
    snackbarConfiguration = .init(text: text, isShown: true)
}


以上がモバイルアプリにおける読み込み処理周りのUIパターンとSwiftUIによる実装例でした

改めて、サンプルアプリのリポジトリはこちらになります
https://github.com/rockname/SwiftUILoadingStateSample

最後に

読み込み処理一つを取っても、表現すべき状態は意外と多く、それらが仕様として明確にされないまま雰囲気で実装が進むことも多いかと思います

この記事で紹介したようなUIパターンをあらかじめガイドライン化などしておけると、チーム開発の効率を上げつつ、作成するアプリの品質も向上させることができると思っています

本記事がそのような取り組みの一助になれば幸いです

Discussion