ForEach のような API を持ったコンテナ View の作り方(型パズルを解く)
SwiftUI において繰り返しを表現する ForEach
では、主に2つの主要な API の利用方法があります。
id
をキーパスで指定する方法と、
let strings: [String] = [
"SwiftUI",
"SwiftData",
"Combine"
]
ForEach(strings, id: \.self) { string in
...
}
Identifiable プロトコルに準拠した型をそのまま渡す方法です。
struct Book: Identifiable {
var id: Int
var title: String
}
let books: [Book] = [
.init(id: 1, title: "Joel on Software"),
.init(id: 2, title: "ハッカーと画家"),
]
ForEach(books) { book in
...
}
後者は Identifiable
プロトコルから使用する id
が特定できるため指定が不要になっていますが、以下のように明示的に記述しても等価です。
ForEach(books, id: \.id) { book in
...
}
さて、独自のコンテナ的な View を作る際は、この ForEach
のような2つのインターフェースをサポートできていると便利です。
例えば、以下のような感じです。
// id指定あり
MyList(strings, id: \.self) { string in
...
}
// id指定なし
MyList(books) { book in
...
}
このように利用できるようにしておけば、 Identifiable
に準拠した型なら id
指定を省略できますし、そうでない型でもキーパスで id
を指定すれば利用できます。
しかし、このような API をどうやって宣言するのでしょうか?
PlainList
SwiftUI の List はデフォルトの外観や余白などが設定されています。
以下は ForEach
を List で囲っただけの例です。
List {
ForEach(books) { book in
Text("\(book.title)")
}
}

このようなデフォルトのスタイルが好ましいケースもありますが、時には外観や余白を完全に無視したプレーンなリストが欲しいケースもあります。
デフォルトでは、
- 外観(スタイル)
- セパレータ
- 行の余白
- 行の最小の高さ
が List に設定されていますが、以下のように記述することでそれらを無効にできます。
List {
ForEach(books) { book in
Text("\(book.title)")
.listRowSeparator(.hidden) // 罫線を消す
.listRowInsets(.init()) // 要素の余白を0に
}
}
.listStyle(.plain) // プレーンな外観
.environment(\.defaultMinListRowHeight, 0) // 行の最小の高さを0に
表示結果は以下のように何の装飾もされない状態になります。

SwiftUI はパフォーマンス観点から List を使用せざるをえないケースも多く、そうした際にこのような何の装飾もされていないプレーンな List
が役立つこともあるでしょう。
これを共通化して以下のように記述できたら便利かもしれません。
PlainList(books) { book in
Text("\(book.title)")
}
また、要素が Identifiable
に準拠しないケースも想定すると、以下のようにも記述できると便利です。
PlainList(strings, id: \.self) { string in
Text(string)
}
さて、いい感じの題材を用意できたところで、これの作り方を見ていきます。
id 指定なしバージョンの PlainList
まず、引数が少ない id 指定なしバージョンの PlainList を作成してみます。(構造を理解すると id 指定ありバージョンから作成したほうが効率的なのですが、それについては後ほど分かります)
// 最終的に提供したいインターフェース
PlainList(books) { book in
Text("\(book.title)")
}
このインターフェースを考えると、body 関数は以下のような見た目になるはずです。
var body: some View {
List {
ForEach(items) { item in // 繰り返す要素を表す`items`
content(item) // 利用者が要素のViewを生成するクロージャ`content`
.listRowSeparator(.hidden)
.listRowInsets(.init())
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0)
}
ForEach
で繰り返す対象 items
と、その要素 item
から View を生成する関数 content
を外部から与えられるようにすればいいことになります。
まず items
ですが、シンプルに考えると任意の型の配列を受け取るのが分かりやすそうです。(よりベターな方法については後ほど紹介します)
var items: [Item]
そうすると、content
は Item
を受け取って任意の View 型 Content
を返すインターフェースになることになります。
@ViewBuilder var content: (Item) -> Content
ここでは @ViewBuilder
を指定していますが、こうしておくことで、if
/ switch
などの ViewBuilder として許容された構文をそのままクロージャ内に記述することができます。
例えば、売り切れ以外の本を表示したい場合、以下のように if 構文を追加するだけで対応できます。
PlainList(books) { book in
if book.isSoldOut == false { // `@ViewBuilder`` 指定が無ければコンパイルエラーになる
Text("\(book.title)")
}
}
あとはこれらの変数の型宣言や型制約をつければ完成です。
struct PlainList<Item, Content>: View where Item: Identifiable, Content: View {
var items: [Item]
@ViewBuilder var content: (Item) -> Content
var body: some View {
List {
ForEach(items) { item in
content(item)
.listRowSeparator(.hidden)
.listRowInsets(.init())
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0)
}
}
id 指定なしの ForEach
を利用しているので、配列の要素である Item
は Identifiable
に準拠している必要があり、 Content
は任意の View になるので View
に準拠している必要があります。
ここまでのコードで以下のように記述できるようになります。
PlainList(items: books) { book in
Text("\(book.title)")
}
引数名の items
が余計ですが、これは Initializer を用意すればよいだけなので最後に回します。
id 指定ありのバージョンの PlainList
次に id 指定ありのバージョンの PlainList を考えてみます。
// 最終的に提供したいインターフェース
PlainList(strings, id: \.self) { string in
Text(string)
}
body 関数の中身はほとんど一緒で、キーパス用の id
という変数が増えたのみです。
var body: some View {
List {
ForEach(items, id: id) { item in // id として利用する値を指定するキーパス `id`
content(item)
.listRowSeparator(.hidden)
.listRowInsets(.init())
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0)
}
id
は Item
を受け取って、id として利用可能な任意の型を返す形となるので、以下のような型宣言になります。
var id: KeyPath<Item, ID>
ところで id として利用可能な任意の型 とは具体的に何を指すのでしょうか?
Identifiable の id
にどういう型制約がついているか調べれば分かりそうです。
public protocol Identifiable<ID> {
/// A type representing the stable identity of the entity associated with
/// an instance.
associatedtype ID : Hashable
/// The stable identity of the entity associated with this instance.
var id: Self.ID { get }
}
ID : Hashable
という宣言から、Hashable
に準拠していれば id として利用可能な任意の型 として振る舞えることが分かります。
少なくとも id は等値比較が可能(Equatable)で、比較コストの最適化のためにハッシュ値を提供(Hashable)する必要があると考えると、ID: Hashable
という型制約の意図が分かりやすいかもしれません。
さて、型変数や型制約を含めた全体のコードは以下のようになります。
struct PlainList<Item, ID, Content>: View where ID: Hashable, Content: View {
var items: [Item]
var id: KeyPath<Item, ID>
@ViewBuilder var content: (Item) -> Content
var body: some View {
List {
ForEach(items, id: id) { item in
content(item)
.listRowSeparator(.hidden)
.listRowInsets(.init())
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0)
}
}
id をキーパスで指定するようになったので、Item: Identifiable
の型制約が不要になり、前述したように ID: Hashable
の型制約が追加されています。
ここまでのコードで、以下のように記述できるようになりました。
PlainList(items: strings, id: \.self) { string in
Text(string)
}
2つを共通化する
これで2つのバージョンが実装できましたが、残念ながら定義として独立してしまっています。
共通化させたいところですが、どちらのバージョンをベースにすべきでしょうか?
Identifiable
プロトコルについて考えてみると、id 用の id
プロパティが用意されているので、 Identifiable
プロトコルに準拠した型から id
へのキーパスは導出することが可能だと分かります。
func getId(_ element: some Identifiable) -> some Hashable {
element[keyPath: \.id] // idへのキーパスを指定して取得
}
このことを考えると、Identifiable
な要素を渡せる id 指定なしのバージョンからは、 id 指定ありのバージョンの Initializer を呼び出す実装に変更できそうです。
例えば、以下のように extension で Initializer を定義することで可能です。
// id指定なしのバージョン
extension PlainList where Item: Identifiable, ID == Item.ID { // 型変数`ID`を埋める
init(
items: [Item],
@ViewBuilder content: @escaping (Item) -> Content
) {
self.init(
items: items,
id: \.id, // `Item: Identifiable`から`id`へのキーパスを指定
content: content
)
}
}
// id指定ありのバージョン
struct PlainList<Item, ID, Content>: View where ID: Hashable, Content: View {
var items: [Item]
var id: KeyPath<Item, ID>
@ViewBuilder var content: (Item) -> Content
var body: some View {
...
}
}
それほど特筆すべき点はありませんが、PlainList の型変数 ID
を埋めるために ID == Item.ID
という型制約が必要なのは注意が必要かもしれません。
最後の仕上げ
さて、これでほとんど完成していますが、最後に以下の2点を改善したいと思います。
- 配列ではなく
RandomAccessCollection
を受け入れるようにする - 第1引数の引数名をなくす
ここまで説明の都合上シンプルな配列で宣言していましたが、ForEach の型宣言 を見てみると必ずしも配列である必要なく、 RandomAccessCollection に準拠さえしていれば問題ないことが分かります。
このような API 設計方法は、Swift において プロトコル指向プログラミング(Protocol Oriented Programming) という名前でも知られており、必要最低限な API 要件だけを Protocol で指定することで、より多くの具体的な実装をそのまま受け入れられる柔軟なインターフェースになります。
RandomAccessCollection
に変更したバージョンは以下のとおりです(ForEach
にあわせて変数名を items
から data
に変更しています)。
extension PlainList where Data.Element: Identifiable, ID == Data.Element.ID {
init(
data: Data,
@ViewBuilder content: @escaping (Data.Element) -> Content
) {
self.init(data: data, id: \.id, content: content)
}
}
struct PlainList<Data, ID, Content>: View where Data: RandomAccessCollection, ID: Hashable, Content: View {
var data: Data
var id: KeyPath<Data.Element, ID>
@ViewBuilder var content: (Data.Element) -> Content
var body: some View {
...
}
}
配列に比べると型宣言が複雑ですが、
[Item]
がData
Item
がData.Element
に置き換わっただけです。
さて、最後に Initializer を導入して、第1引数の引数名を省略するようにした完成形のコード全体を載せます。
// id指定ありのバージョン
struct PlainList<Data, ID, Content>: View where Data: RandomAccessCollection, ID: Hashable, Content: View {
private var data: Data
private var id: KeyPath<Data.Element, ID>
@ViewBuilder private var content: (Data.Element) -> Content
init(
_ data: Data,
id: KeyPath<Data.Element, ID>,
content: @escaping (Data.Element) -> Content
) {
self.data = data
self.id = id
self.content = content
}
var body: some View {
List {
ForEach(data, id: id) { data in
content(data)
.listRowSeparator(.hidden)
.listRowInsets(.init())
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0)
}
}
// id指定なしのバージョン
extension PlainList where Data.Element: Identifiable, ID == Data.Element.ID {
init(
_ data: Data,
@ViewBuilder content: @escaping (Data.Element) -> Content
) {
self.init(data, id: \Data.Element.id, content: content)
}
}
これで期待していたインターフェースが手に入りました。
struct Book: Identifiable {
var id: Int
var title: String
}
let books: [Book] = [
.init(id: 1, title: "Joel on Software"),
.init(id: 2, title: "ハッカーと画家"),
]
let strings: [String] = [
"SwiftUI",
"SwiftData",
"Combine"
]
struct ContentView: View {
var body: some View {
HStack(alignment: .top) {
// id引数の指定なし
PlainList(books) { book in
Text("\(book.title)")
}
// id引数の指定あり
PlainList(strings, id: \.self) { string in
Text(string)
}
}
}
}

おわりに
Swift の標準 API と同様、SwiftUI では プロトコル指向プログラミング(Protocol Oriented Programming) の考え方が API 設計に大きく影響を与えており、結果としてより柔軟に API を利用できる設計になっています。
標準の API をそのまま利用するだけでも強力ですが、本記事で取り上げたように標準の API をビルディングブロックとして活用して、自分たちに必要なビルディングブロックを構築することさえ可能です。
必要以上の(とくに早期な)共通化や抽象化には注意が必要ですが、必要な時にこうした共通化や抽象化というカードを切れるようにしておくのは、複雑なプロジェクトを成功させるうえでの強力な武器になるかもしれません。
お仕事募集中
Sunabalab では、iOS/Android アプリの開発支援をしています。
直近では SwiftUI まわりの開発・技術支援がしやすい状況ですので、お気軽にホームページのフォーム、または @tobi462(Twitter) の DM までご相談くださいませ。