Deletable Table with TextField on SwiftUI #はてなエンジニアAdventCalendar

これは はてなエンジニアAdvent Calendar 2019 1日目のエントリーです。
今年のAdvent Calendarでは初日を担当します id:yutailang0119 です!
去年はツール作りの話を書きましたが、今年は先日のアンケート記事でも言及していたSwiftUIの話です。

お題

  1. SwiftUIで
  2. 編集可能なテキストの
  3. リストを作り
  4. Rowごとに削除が可能

これが実は難しいお題だったので、解説していきます。

環境

Xcode 11.2.1 (11B500)

2024/02/19追記

Xcode 15.2 (15C500b) 現在 (おそらくこれ以前に) 、ForEachにBindingなinit(_:content:) が追加*1されたため、単純に実装が可能になりました。
ただし、1入力ごとにstateが確定し、キーボードが閉じてしまうため、この条件ではうまく機能しない気がします。

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        VStack {
            EditButton()
            List {
                ForEach($animals, id: \.self) { $animal in
                    TextField("", text: $animal)
                }
                .onDelete { indexSet in
                    self.animals.remove(atOffsets: indexSet)
                }
            }
        }
    }
}

解答

最初に解答なんですが、こちらが自分がstack overflowに投稿した質問。

stackoverflow.com

解説

リストを作る

まずは、SwiftUIの初歩とも言えるリストを作ってみます。

import SwiftUI

struct ContentView: View {
    private var animals: [String] = ["🐶", "🐱"]
    
    var body: some View {
        List {
            ForEach(animals, id: \.self) { animal in
                Text(animal)
            }
        }
    }
}

この後の形式と揃えるために ListForEach の両方を使用していますが、単一なViewのリストであれば、 List のみでも実現できます。

こんな感じ

import SwiftUI

struct ContentView: View {
    private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        List(animals, id: \.self) { animal in
            Text(animal)
        }
    }
}

テキストを編集可能にする

前述のリストの各項目を編集可能にしてみます。
Textで文字列を表示していた箇所をTextFieldに変更すればよさそうです。
しかし、 TextField のinitializerは、 Binding<String> を引数として要求して、更新を反映していきます。
ForEach のmapでは Binding<T> を取得することはできないので、配列のカウントをRangeでループさせ、indexを使って Binding<String> を扱えるようにします。 *2
合わせて、DataSourceである animals@State を付与しておきます。

import SwiftUI

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        List {
            ForEach(0..<animals.count) { i in
                TextField("", text: self.$animals[i])
            }
        }
    }

これで、各項目のテキストを編集できるようになりました。
ここまでのリストとTextFieldの組み合わせだけでも、ちょっとトリッキーなことをしている印象はありますね。

Rowを削除可能にする

最後の条件です。
ForEach には .onDeleteが実装されていて、これを使うと EditMode.active の時には、UITableView.isEditingと近い振る舞いをできるようになります。 *3
EditMode == .active である必要があるので、 EditButton も追加しておきましょう。

以上を満たした実装をします。

import SwiftUI

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        VStack {
            EditButton()
            List {
                ForEach(0..<animals.count) { i in
                    TextField("", text: self.$animals[i])
                }
                .onDelete { indexSet in
                    self.animals.remove(atOffsets: indexSet)
                }
            }
        }
    }
}

Previewで確認してみましょう。

Preview実行し、EditButtonを押したところ

できていそう!!!

これではRuntime Errorが起きる

PreviewでRowを削除してみると、Previewの端末画面が真っ白になってしまいます。
これはRuntimeでエラーが起きたということです。 *4

シミュレーターで実行し、再度エラーを発生させ、ログを見てみます。

Thread 1: Fatal error: Index out of range

Index out of Range が発生しているということがわかりました。

このログを考察すると、🐶を削除したにも関わらず、 ForEach のループは2回起きているようです。
念の為に、animals から🐶が削除できているのか確認してみます。

(lldb) po animals.count
1

(lldb) po animals
▿ 1 element
  - 0 : "🐱"

🤔🤔🤔

ここからいろいろ試してみますが、うまくいかず、冒頭のstack overflowの質問をしました。

例えば、最初の Text のリストの削除はうまくいく

import SwiftUI

struct ContentView: View {
    private var animals: [String] = ["🐶", "🐱"]
    
    var body: some View {
        List {
            ForEach(animals, id: \.self) { animal in
                Text(animal)
            }
            .onDelete { indexSet in
                self.animals.remove(atOffsets: indexSet)
            }
        }
    }
}

stack overflowでの解答

https://stackoverflow.com/a/58911168/11264346

解答にあるように、ForeEach.init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content) のDocument Commentを読んでみます。

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension ForEach where Data == Range<Int>, ID == Int, Content : View {

    /// Creates an instance that computes views on demand over a *constant*
    /// range.
    ///
    /// This instance only reads the initial value of `data` and so it does not
    /// need to identify views across updates.
    ///
    /// To compute views on demand over a dynamic range use
    /// `ForEach(_:id:content:)`.
    public init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content)
}

なるほど

  • data の初期値のみを読み取るので、更新時のView識別が必要ない
    • -> 要素数に更新があっても、初期値のみを読み取るので、それに追従されない。
  • ダイナミックに計算するには、 ForEach(_:id:content:)を使用する。

なるほど
それだと、 Binding<T> が扱えないんだけど!

答え

解答してくれた Asperiさんが、ちゃんと実装も答えてくれていました。
以下が解です。

import SwiftUI

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        VStack {
            EditButton()
            List {
                ForEach(animals, id: \.self) { animal in
                    EditorView(container: self.$animals, index: self.animals.firstIndex(of: animal)!, text: animal)
                }
                .onDelete { indexSet in
                    self.animals.remove(atOffsets: indexSet)
                }
            }
        }
    }
}

struct EditorView : View {
    var container: Binding<[String]>
    var index: Int

    @State var text: String

    var body: some View {
        TextField("", text: self.$text, onCommit: {
            self.container.wrappedValue[self.index] = self.text
        })
    }
}

ForEach では、要素数の変更を反映できるように .init(_:id:content:) を使用し、EditorViewの中で、TextFieldでの変更を元の animals に更新できるように作ると解決できました!

まとめ

SwiftUIはまだまだ足りないパーツも多いし、ちょっと凝ったことをしようとすると、ドキュメントが少なく苦労することが多いので、挫折しがちです。
特にEditModeが絡む実装をしようとすると、途端にハイレベルになる印象があります。
SwiftUIがよりよいものになるように、集合知を集めて行きましょう!!!

明日は id:masawada です!

はてなエンジニアAdvent Calendar 2019 をお楽しみに!

*1:iOS 13にBackport済み

*2:ちょっと綺麗な形ではないですね

*3:実際にはスワイプ削除等が効かないので、近い振る舞い

*4:Previewでのエラーはログが出ない