ForEach のような API を持ったコンテナ View の作り方(型パズルを解く) - Part 2

前回の記事では ForEach のような API を持つ、何の装飾もされないプレーンな List である PlainList を作成しました。

今回は応用として、連番付きのリストを作成してみます。

NumberedList

今回作りたいものは以下のような、連番を自動的に振ってくれるリストです。



前回の PlainList と同様に、全く同じインターフェースで利用できるようにします。

struct Book: Identifiable {
    var id: Int
    var title: String
}

let strings: [String] = [
    "SwiftUI",
    "SwiftData",
    "Combine"
]

let books: [Book] = [
    .init(id: 1, title: "Joel on Software"),
    .init(id: 2, title: "ハッカーと画家"),
]

struct ContentView: View {
    var body: some View {
        HStack(alignment: .top) {
            // id指定なし
            NumberedList(books) { book in
                Text("\(book.title)")
            }
            // id指定あり
            NumberedList(strings, id: \.self) { string in
                Text(string)
            }
        }
    }
}

連番の振り方

まずは共通化しない状態での書き方を見てみます。

連番を振るためには、配列の 要素 に加えて インデックス が欲しくなりますが、それは Swift Algolithmsindexed() が利用できます。

import Algorithms

let books: [Book] = [
    .init(id: 1, title: "Joel on Software"),
    .init(id: 2, title: "ハッカーと画家"),
]

struct ContentView: View {
    var body: some View {
       List {
            ForEach(books.indexed(), id: \.element.id) { index, book in
                HStack {
                    Text("\(index + 1).") // 連番
                    Text("\(book.title)") // 本のタイトル
                }
            }
        }
    }
}


indexed() すると [Book] から IndexedCollection <[Book]> に変換されます。

そして、その要素である IndexedCollection <[Book]>.Element の実体は (index: Array<Book>.Index, element: Book) というタプルになるため、

  • \.element.id
  • \.index

といったキーパスで id を指定できるようになります。

ちなみにタプルなので以下のようにインデックスで指定することも可能です。

ForEach(books.indexed(), id: \.1.id) { index, book in
    ...
}

あとは ForEach のクロージャ引数にインデックスと要素のタプルが渡されるようになるので、以下のようにアクセスできるようになります。

ForEach(books.indexed(), id: \.element.id) { index, book in
    ...
}

共通化の試み

さて、これを共通化しようとした場合、全体の外観としては以下のようになるはずです。

struct NumberedList<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

    init(...) { ... }

    var body: some View {
        List {
            ForEach(data.indexed(), id: /* どうやって指定する? */) { index, data in
                HStack {
                    Text("\(index + 1).")
                    content(data)
                }
            }
        }
    }
}

問題となるのは /* */ でもコメントした id をキーパスで指定する部分です。

indexd() したことで要素の型は IndexedCollection <[Book]>.Element となるため、KeyPath<Data.Element, ID> では指定できません。

試しにそのまま渡してみると、型の不一致によるコンパイルエラーになります。



Cannot convert value of type ‘KeyPath<Data.Element, ID>’ to expected argument type ‘KeyPath<IndexedCollection.Element, ID>’

キーパスの合成

キーパスへの理解を深めるため、以下のようなデータ構造を考えてみます。

struct Book {
    var title: String
    var author: Author
}

struct Author {
    var name: String
}
  1. Book から author へのキーパス
  2. Author から name へのキーパス

は、それぞれ以下のように宣言できます。

let keyPath1: KeyPath<Book, Author> = \Book.author
let keyPath2: KeyPath<Author, String> = \Author.name

これは以下のように使用することができます。

let book = Book(
    title: "Joel on Software",
    author: .init(name: "Joel Spolsky")
)

print(book[keyPath: keyPath1])
// => Author(name: "Joel Spolsky")

print(book.author[keyPath: keyPath2])
// => Joel Spolsky

キーパスは KeyPath<Root, Value> という型で宣言されていますが、ここで注目したいのは keyPath1ValuekeyPath2Root がどちらも Author で一致するという点です。

関数 f の出力と、関数 g の入力が一致する場合、それらを組み合わせて関数合成することができますが、

func compose<S, T, U>(_ f: @escaping (S) -> T, _ g: @escaping (T) -> U) -> ((S) -> U) {
    { arg in g(f(arg)) }
}

func getAuthor(_ book: Book) -> Author {
    book.author
}

func getName(_ author: Author) -> String {
    author.name
}

let f = getAuthor
let g = getName

// 合成された関数(g・f)
let h: (Book) -> String = compose(f, g)

print(h(book))
// => Joel Spolsky

キーパスでも同様のことが可能になっており、具体的には appending(path:) を使用して KeyPath<Book, String> というキーパス型に合成することができます。

let keyPath1: KeyPath<Book, Author> = \Book.author
let keyPath2: KeyPath<Author, String> = \Author.name

...

let keyPath3: KeyPath<Book, String> = keyPath1.appending(path: keyPath2)

print(book[keyPath: keyPath3])
// => Joel Spolsky


完全に余談ですが、このような対称性を考えると Key Path Expressions as Functions (SE-0249) も自然に感じられるかもしれません。

指定するキーパスを作成する

さて、このことを踏まえると、今回は IndexedCollection<Data>.Element から Data.Element へのキーパスである KeyPath<IndexedCollection<Data>.Element, Data.Element> さえ用意できれば、あとは KeyPath<Data.Element, ID> と合成することで必要なキーパスが手に入ります。

実際のコードとしては以下のようになります。

var keyPath: KeyPath<IndexedCollection<Data>.Element, ID> {
    let path = \IndexedCollection<Data>.Element.element
    return path.appending(path: id)
}

あるいは、() で優先度付けして以下のように記述することもできます。

var keyPath: KeyPath<IndexedCollection<Data>.Element, ID> {
    (\IndexedCollection<Data>.Element.element).appending(path: id)
}

これで型が一致するため、 Listid 属性に渡すことができます。

List {
    ForEach(data.indexed(), id: keyPath) { index, data in
        HStack {
            Text("\(index + 1).") // まだここはコンパイルエラー
            content(data)
        }
    }
}

Data.Index の型を限定する

さて、最後に Text("\(index + 1).") の部分で発生している以下のコンパイルエラーを修正する必要があります。

Cannot convert value of type ‘Data.Index’ to expected argument type ‘Int’

ただ、これは Data.Index の型を Int に限定するだけで対応できます。

// `Data.Index == Int` を型制約に追加
struct NumberedList<Data, ID, Content>: View where Data: RandomAccessCollection, Data.Index == Int, ID: Hashable, Content: View {
    ...
    var body: some View {
        List {
            ForEach(data.indexed(), id: keyPath) { index, data in
                HStack {
                    Text("\(index + 1).")
                    content(data)
                }
            }
        }
    }
}

もちろんこの形でも構いませんが、 Data.IndexInt に限定するとなると、実用上は配列を受け付けるインターフェースで十分かもしれません。

抽象レベルは一段階下がりますが、以下のように型宣言はややスッキリします。

// `RandomAccessCollection`の代わりに配列を受け付けるようにしたバージョン
struct NumberedList<Data, ID, Content>: View where ID: Hashable, Content: View {
    private var data: [Data]
    private var id: KeyPath<Data, ID>
    @ViewBuilder private var content: (Data) -> Content

    init(
        _ data: [Data],
        id: KeyPath<Data, ID>,
        content: @escaping (Data) -> Content
    ) {
        self.data = data
        self.id = id
        self.content = content
    }

    var keyPath: KeyPath<IndexedCollection<[Data]>.Element, ID> {
        let path = \IndexedCollection<[Data]>.Element.element
        return path.appending(path: id)
    }

    var body: some View {
        List {
            ForEach(data.indexed(), id: keyPath) { index, data in
                HStack {
                    Text("\(index + 1).")
                    content(data)
                }
            }
        }
    }
}

コード全体

完成したコード全体を掲載します。

// MARK: NumberedList

struct NumberedList<Data, ID, Content>: View where Data: RandomAccessCollection, Data.Index == Int, 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 keyPath: KeyPath<IndexedCollection<Data>.Element, ID> {
        let path = \IndexedCollection<Data>.Element.element
        return path.appending(path: id)
    }

    var body: some View {
        List {
            ForEach(data.indexed(), id: keyPath) { index, data in
                HStack {
                    Text("\(index + 1).")
                    content(data)
                }
            }
        }
    }
}

extension NumberedList 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)
    }
}

// MARK: Entity

struct Book: Identifiable {
    var id: Int
    var title: String
}

// MARK: Data

let books: [Book] = [
    .init(id: 1, title: "Joel on Software"),
    .init(id: 2, title: "ハッカーと画家"),
]

let strings: [String] = [
    "SwiftUI",
    "SwiftData",
    "Combine"
]

// MARK: View

struct ContentView: View {
    var body: some View {
        HStack(alignment: .top) {
            NumberedList(books) { book in
                Text("\(book.title)")
            }
            NumberedList(strings, id: \.self) { string in
                Text(string)
            }
        }
    }
}


ところで、 NumberedList 内で連番を振る代わりに、以下のようにクロージャ引数で index を受け取るインターフェースもありではないでしょうか。

NumberedList(books) { index, book in
    HStack {
        Text("\(index + 1).")
        Text("\(book.title)")
    }
}

これは興味のある方への宿題という形にしたいと思います。

おわりに

今回は連番付きのリストを NumberedList というコンテナ View として作成しました。

インデックスを使用するために Swift Algorithms の indexed() を使い、キーパスを合成することで id の型不一致を解消し、型制約によってインデックスを Int に限定することで、連番として使用できるようにしました。

実際にはこのような単純な連番付きリストを共通のコンポーネントとして設計することは無いかもしれませんが、このような型パズルの解き方に慣れておくと、実際に複雑なコンポーネントを作成する必要が出たときに迷わないかもしれません。

なお、途中でも述べたように、Protocol Oriented Programming により最大の抽象化を得ることができますが、場合によっては具体的な型を使ってシンプルに書いたほうが、費用対効果にマッチする場合もあります。

まずはもっとも欲しいインターフェースでシンプルに定義した上で、実際に必要が発生した時により高いレベルの抽象化を導入するというアプローチも一つの手でしょう。