コンテンツへスキップ

SwiftUIでマルチセレクターを作る

プロトタイピングのために、SwiftUIに足りないコンポーネントを自作する方法を紹介します。

SwiftUIでマルチセレクターを作る

SwiftUIを使って初めて本格的なアプリを開発しているとき、UIの開発速度がどれほど向上したかに常に感動していました。特に、既存のビューがそのままユースケースに合う場合はなおさらです。もちろん、カスタムUIが必要な場面では独自のビューを書く必要がありますが、既存のビューを組み合わせてモディファイアで調整していくことになります。ただ、データの表示やユーザー入力の受付に必要な一般的なビューくらいは、SwiftUIが最初からサポートしていてほしいものです。

もしそうなれば、SwiftUIはプロトタイピングにも使えるようになります。「動くけど見た目はまだ」というバージョンのアプリを素早く作ってユーザーに見せ、そのアプリのアイデアに可能性があるかどうかを検証できます。また、どの部分がもっとわかりやすいUIを必要としているか(まだ十分に理解されていない部分)、どの部分はデフォルトのコンポーネントに少しビジュアルを調整する程度で済むかについても、すばやくフィードバックを集められます。

つまり、SwiftUIにはMVP駆動のプロダクト開発をもっと多くの開発者にとって魅力的にするポテンシャルがあると思っています。これは間違いなく良いことで、最終的にうまくいかないことに時間を費やすのを大幅に節約できます。これはリーンスタートアップ手法とも一致する考え方で、新しいプロダクトに取り組む上で素晴らしいアプローチだと思います。

SwiftUIの現状

これを実現するには、ユーザー登録やその他のデータ入力に必要なフォームの一般的な入力タイプをSwiftUIがすべてカバーしていることが期待されます。多くのアプリは結局のところ、入力データを受け取り、何らかの変換を行い、特別な形式やタイミングでデータを返すフォームにすぎないからです。残念ながら、SwiftUIはまだそこまで到達していません。

AppleがSwiftUIで取っているアプローチは、最も不足しているコンポーネントを検討し、毎年いくつかを追加していくというものです。たとえば、WWDC 2020ではProgressViewGaugeText内でのImageサポートが追加され、既存ビューの多くの細部がパフォーマンスと柔軟性の両面で改善されました。WWDC 2021では、AsyncImage.refreshable.taskビューモディファイアなど、async/await関連のAPIが複数追加され、その他の改善・追加も行われました。

このアプローチの利点は、一度フレームワークに追加されたものは長期間にわたって同じ動作が保証されるため、リリースごとに大きなコード変更が不要になることです(Swift 4以前の言語自体の変更とは対照的です)。欠点は、まだ多くのコンポーネントが欠けていることです。そこで、コミュニティが一時的なソリューションを提供し、将来Appleから正式なコンポーネントが提供された際に簡単に置き換えられるようにすればよいと考えています。

マルチセレクションビューコンポーネントの実装

この記事では、そのようなコンポーネントの1つに焦点を当て、初期的な解決策を提供したいと思います。与えられた選択肢のセットから複数のオプションを選択するためのマルチセレクターです。現時点ではAppleはPickerを提供していますが、複数項目の選択をサポートしておらず、1つを選んだ時点で自動的にリスト画面から離れてしまいます。では、さっそく作っていきましょう!

マルチセレクターが必要になるデータ構造とはどのようなものでしょうか?以下の例を見てみましょう:

struct Goal: Hashable, Identifiable {
    var name: String
    var id: String { name }
}

struct Task {
    var name: String
    var servingGoals: Set<Goal>
}

このアプリにはゴールのコレクションとタスクのコレクションがあり、各タスクがどのゴールに貢献しているかという関係をモデル化したいと考えています。Taskを作成・編集する際に、そのタスクがどのゴールに貢献しているかを選択できるようにします。以下がTaskEditViewのSwiftUIコードです:

import SwiftUI

struct TaskEditView: View {
    @State
    var task = Task(name: "", servingGoals: [])

    var body: some View {
        Form {
            Section(header: Text("Name")) {
                TextField("e.g. Find a good Japanese textbook", text: $task.name)
            }

            Section(header: Text("Relationships")) {
                Text("TODO: add multi selector here")
            }
        }.navigationTitle("Edit Task")
    }
}

struct TaskEditView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            TaskEditView()
        }
    }
}

上記のコードはこのようなプレビューをレンダリングします:

Implementing a multi 3

もしゴールが1つだけの場合にどう動くか見せるために、TODOのTextエントリを以下のようなPickerに置き換えることができます:

// mock data:
let allGoals: [Goal] = [
    Goal(name: "Learn Japanese"),
    Goal(name: "Learn SwiftUI"),
    Goal(name: "Learn Serverless with Swift")
]

Picker("Serving Goal", selection: $task.servingGoal) {
    ForEach(allGoals) {
        Text($0.name).tag($0 as Goal)
    }
}

TaskEditViewはこのようになります:

Implementing a multi 6

ピッカーをクリックすると、詳細ビューはこうなります:

Implementing a multi 5

とてもシンプルですね。なお、GoalIdentifiableに準拠している必要があるため、最初にvar id: String { name }を追加しています。マルチセレクターでは見た目はほぼ同じにしつつ、1つではなく複数の項目を選択できるようにしたいと思います。

まず、TaskEditView内のエントリを再作成する必要があります。Pickerの代わりとなるタイプ名としてMultiSelectorを選びました。以下がその実装です:

import SwiftUI

struct MultiSelector<LabelView: View, Selectable: Identifiable & Hashable>: View {
    let label: LabelView
    let options: [Selectable]
    let optionToString: (Selectable) -> String
    var selected: Binding<Set<Selectable>>

    private var formattedSelectedListString: String {
        ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) })
    }

    var body: some View {
        NavigationLink(destination: multiSelectionView()) {
            HStack {
                label
                Spacer()
                Text(formattedSelectedListString)
                    .foregroundColor(.gray)
                    .multilineTextAlignment(.trailing)
            }
        }
    }

    private func multiSelectionView() -> some View {
        Text("TODO: add multi selection detail view here")
    }
}

各エントリをStringで表現することにしたため、オプションの型からString表現を提供するoptionToStringクロージャが必要になります。

ListFormatter.localizedStringの呼び出しにより、選択されたオプションのリストが正しいローカライゼーション形式で結合されます(例:["A", "B", "C"]は英語では「A, B and C」になります)。

以下がこのビューのプレビューコードです:

struct MultiSelector_Previews: PreviewProvider {
    struct IdentifiableString: Identifiable, Hashable {
        let string: String
        var id: String { string }
    }

    @State
    static var selected: Set<IdentifiableString> = Set(["A", "C"].map { IdentifiableString(string: $0) })

    static var previews: some View {
        NavigationView {
            Form {
                MultiSelector<Text, IdentifiableString>(
                    label: Text("Multiselect"),
                    options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
                    optionToString: { $0.string },
                    selected: $selected
                )
            }.navigationTitle("Title")
        }
    }
}

Goalの代わりに内部型を使っているのは、プレビューを特定のプロジェクトから独立させるためです。プレビューはこのように見えます:

Implementing a multi

これをTaskEditViewに配置して、そのコンテキストでどう見えるか確認しましょう。TODOのText呼び出しを以下に置き換えます:

MultiSelector(
    label: Text("Serving Goals"),
    options: allGoals,
    optionToString: { $0.name },
    selected: $task.servingGoals
)

プレビューが以下のように変わります。期待通りですね:

Implementing a multi 4

しかし、クリックするとこのように表示されます。まだ完成ではありません:

Implementing a multi 2

では、詳細ビューを実装しましょう。詳細ビューのタイプ名としてMultiSelectionViewを選びました。以下がそのコードです:

import SwiftUI

struct MultiSelectionView<Selectable: Identifiable & Hashable>: View {
    let options: [Selectable]
    let optionToString: (Selectable) -> String

    @Binding
    var selected: Set<Selectable>

    var body: some View {
        List {
            ForEach(options) { selectable in
                Button(action: { toggleSelection(selectable: selectable) }) {
                    HStack {
                        Text(optionToString(selectable)).foregroundColor(.black)

                        Spacer()

                        if selected.contains { $0.id == selectable.id } {
                            Image(systemName: "checkmark").foregroundColor(.accentColor)
                        }
                    }
                }.tag(selectable.id)
            }
        }.listStyle(GroupedListStyle())
    }

    private func toggleSelection(selectable: Selectable) {
        if let existingIndex = selected.firstIndex(where: { $0.id == selectable.id }) {
            selected.remove(at: existingIndex)
        } else {
            selected.insert(selectable)
        }
    }
}

labelを除けば、基本的に同じプロパティを持っています。ただし今回は実際に使用されており、例えばselectedコレクションに対してcontainsを呼び出してチェックマークを表示すべきかどうかを判定しています。

エントリがクリックされると、toggleSelectionが使用されてエントリをselectedプロパティから削除または挿入します。チェックマークには、Pickerのチェックマークアイコンとまったく同じ見た目のSF Symbol「checkmark」を使っています。

以下が詳細ビューのプレビューコードです。MultiSelectorのプレビューとほぼ同じ内容です:

struct MultiSelectionView_Previews: PreviewProvider {
    struct IdentifiableString: Identifiable, Hashable {
        let string: String
        var id: String { string }
    }

    @State
    static var selected: Set<IdentifiableString> = Set(["A", "C"].map { IdentifiableString(string: $0) })

    static var previews: some View {
        NavigationView {
            MultiSelectionView(
                options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
                optionToString: { $0.string },
                selected: $selected
            )
        }
    }
}

Xcodeのプレビューではこのように見えます:

Implementing a multi 7

最後に、MultiSelectionViewMultiSelectorに統合しましょう。TODOのTextエントリを以下に置き換えます:

MultiSelectionView(
    options: options,
    optionToString: optionToString,
    selected: selected
)

基本的にデータを詳細ビューにそのまま渡しているだけです。では、シミュレータから録画したアニメーションGIFでアプリがどう見えるか確認しましょう:

Implementing a multi

いいですね、動いています!

デモプロジェクトをGitHubにアップロードしました。MultiSelectorMultiSelectionViewの中身をコピーしたい方は、このフォルダをご覧ください。

この記事を楽しんでいただけましたか?エキスパートアドバイスを受けてみませんか!

この記事が参考になりましたか?BlueskyMastodonでフォローして、Swiftのヒントやインディー開発の最新情報をチェックしてください。