[SwiftUI] プレースホルダ(.redacted)の実装パターン

前回 に続いて、今回も実装パターン的な話になります。

.redacted(reason:) モディファイア

SwiftUI では 2.0(iOS 14.0+)という早いタイミングで、読み込み中のプレースホルダを表現するための redacted(reason:)というモディファイアが提供されました。



このように読み込み中にコンテンツの骨組みをプレースホルダとして見せられる仕組みになっています。

API としての使い方は簡単で、他の一般的なモディファイアと同じように.redacted(reason: .placeholder) を適用するだけです。

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            // 通常表示
            content()

            // プレースホルダ表示
            content()
                .redacted(reason: .placeholder)
        }
        .padding()
    }
    
    func content() -> some View {
        VStack(alignment: .leading) {
            Text("SwiftUI")
                .font(.title)
            Text("SwiftUIでは、Swiftのパワーを活用し、驚くほど少ないコードですべてのAppleプラットフォーム向けに美しいアプリを構築できます。")
                .font(.body)
        }
    }
}


こうやって並べてみると、フォントサイズの設定は反映されてはいるものの、実際のテキストレンダリング結果に従ってプレースホルダが表示されているわけではないようです。

複数行のテキストは読み込み結果によって文字数・行数が変化するのが当たり前なのでこの挙動は問題ないとも言えますが、文字数を増やすとしっかりとプレースホルダの見た目(行数)も変化するので、見た目を調整する際にちょっと面倒だなと感じます。

この点についてはこの記事の趣旨から外れるので、これくらいの単なる感想として留めておきたいと思います。

通信中にプレースホルダ表示

まず、プレースホルダを使用しない一般的な読み込みのコードを見てみます。

/// 表示内容
struct Data {
    var title: String
    var description: String
    
    static let example: Data = .init(
        title: "SwiftUI",
        description: "SwiftUIでは、..."
    )
}

/// ViewModel
@MainActor
final class ContentViewModel: ObservableObject {
    @Published var data: Data?
    
    init() {
        Task {
            do {
                self.data = try await fetch()
            } catch {
                // エラー処理...
            }
        }
    }
    
    // 通信中をシミュレート
    func fetch() async throws -> Data {
        try await Task.sleep(for: .seconds(3))
        return .example
    }
}

/// View
struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        // 読み込み終わったら表示
        if let data = viewModel.data {
            VStack(alignment: .leading) {
                Text("\(data.title)")
                    .font(.title)
                Text("\(data.description)")
                    .font(.body)
            }
            .padding()
        }
    }
}

通信中のインジケータ表示やエラー時のリトライなどの UI/UX を作り込むこともあり、その場合は enum などで状態表現することもあるかもしれませんが、これがもっともシンプルな実装パターンの1つになるかと思います。

この View をプレースホルダにシンプルに置き換えると以下のようになります。

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        VStack(alignment: .leading) {
            // 読み込み中(`nil`)の場合はプレースホルダ用のテキスト
            Text("\(viewModel.data?.title ?? "タイトル")")
                .font(.title)
            Text("\(viewModel.data?.description ?? "SwiftUIでは、...")")
                .font(.body)
        }
        .padding()
        // 読み込み中(`nil`)の場合はプレースホルダ
        .redacted(reason: viewModel.data == nil ? .placeholder : [])
    }
}

これはシンプルな実装ではありますが、プレースホルダ用のテキストが View の定義内に埋め込まれており、個人的には View の定義がやや煩雑(ノイズ)にも感じられます。(これは余談ですが、実際に表示されるサンプル値をコメントで記述するのはリーダビリティにおいてかなり有用だと感じており、私は // e.g. SwiftUI のコメントを多用しています)

この例ではコード量がさほど多くありませんが、プレースホルダとして表示したい要素が増えるほど「シンプルだけどごちゃごちゃしている」という感覚が増えてくるように思います。

プレースホルダ用の値を初期値として利用する

プレースホルダ用の値を事前に定義しておき、それを初期値として利用するような実装パターンも1つのアイデアとして考えられます。

/// 表示内容
struct Data: Equatable {
    var title: String
    var description: String
    
    static let example: Data = .init(
        title: "SwiftUI",
        description: "SwiftUIでは、..."
    )

    // ✅ プレースホルダ用の値を定義
    static let placeholder: Data = .example
}

/// ViewModel
@MainActor
final class ContentViewModel: ObservableObject {
    @Published var data: Data = .placeholder // ✅ 初期値にプレースホルダ用の値を設定
    @Published var isLoading = true

    init() {
        Task {
            do {
                self.data = try await fetch()
                self.isLoading = false
            } catch {
                // エラー処理...
            }
        }
    }
    
    // 通信中をシミュレート
    func fetch() async throws -> Data {
        try await Task.sleep(for: .seconds(3))
        return .example
    }
}

/// View
struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        // 読み込み中は初期値(プレースホルダ用の値)で redacted されて表示される
        VStack(alignment: .leading) {
            Text("\(viewModel.data.title)")
                .font(.title)
            Text("\(viewModel.data.description)")
                .font(.body)
        }
        .padding()
        .redacted(reason: viewModel.isLoading ? .placeholder : [])
    }
}

プレースホルダ用の定義が分離されてコード全体がスッキリしており、個人的には読みやすくメンテナンス性も高いように感じられます。

防御的なマスク処理

この例では(Xcode Previews に利用するサンプル値である) example をそのままプレースホルダとして利用していますが、前述のように文字数によってプレースホルダの見た目も変わるので、意図せずプレースホルダの見た目が変わってしまう事故を避けるために別定義にするほうが安全かもしれません。

また、トレードオフではありますが、(将来的な変更時の)修正ミスで意図せずプレースホルダ用のテキストが redacted されずに表示されるようなバグを防ぐため、万が一画面に表示されても問題ないようなデータを使用する、あるいはマスクしておくとより安全な作りになるかと思います。

static let placeholder: Data = .init(
    title: "SwiftUI".masked(),
    description: "SwiftUIでは、...".masked()
)

extension String {
    func masked() -> String {
        // ...
    }
}

間違って本番環境で Push 通知してしまっても問題ないように、「テスト」みたいな差し当たりのないテキストを使用するのと同じような感じですね。

実装時の動作確認や QA のタイミングで気付かない可能性は低いように感じられるので、ここまで慎重にならなくても十分なケースが多いかなと感じる一方、商品金額などの取引やサービスの信用に関わる部分については安全側に倒すのも手かな、と感じます。

あとがき

そんなわけで 前回 に続き、私が使用したことのある実装パターンの紹介でした。

昨今の UI/UX の流行り廃れの影響なのか(あるいはたまたまなのか)、なぜか私が実際に関わったプロダクトだと .redacted(reason:) を使ったプレースホルダのような UI/UX にはまったく出会わなかったのですが、最近ある画面を SwiftUI 化した際に利用してみたので記事にしてみました。

書き終えてから気づいたのですが、どちらも「1つのデータ構造として扱う」という点で共通した考え方に基づいているのかもしれません。