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]

そうすると、contentItem を受け取って任意の 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 を利用しているので、配列の要素である ItemIdentifiable に準拠している必要があり、 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)
}

idItem を受け取って、id として利用可能な任意の型を返す形となるので、以下のような型宣言になります。

var id: KeyPath<Item, ID>

ところで id として利用可能な任意の型 とは具体的に何を指すのでしょうか?

Identifiableid にどういう型制約がついているか調べれば分かりそうです。

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点を改善したいと思います。

  1. 配列ではなく RandomAccessCollection を受け入れるようにする
  2. 第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
  • ItemData.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 までご相談くださいませ。