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 Algolithms の indexed() が利用できます。
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
}
Book
からauthor
へのキーパス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> という型で宣言されていますが、ここで注目したいのは keyPath1
の Value
と keyPath2
の Root
がどちらも 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)
}
これで型が一致するため、 List
の id
属性に渡すことができます。
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.Index
を Int
に限定するとなると、実用上は配列を受け付けるインターフェースで十分かもしれません。
抽象レベルは一段階下がりますが、以下のように型宣言はややスッキリします。
// `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 により最大の抽象化を得ることができますが、場合によっては具体的な型を使ってシンプルに書いたほうが、費用対効果にマッチする場合もあります。
まずはもっとも欲しいインターフェースでシンプルに定義した上で、実際に必要が発生した時により高いレベルの抽象化を導入するというアプローチも一つの手でしょう。