これは はてなエンジニアAdvent Calendar 2019 1日目のエントリーです。
今年のAdvent Calendarでは初日を担当します id:yutailang0119 です!
去年はツール作りの話を書きましたが、今年は先日のアンケート記事でも言及していたSwiftUIの話です。
お題
- SwiftUIで
- 編集可能なテキストの
- リストを作り
- 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に投稿した質問。
解説
リストを作る
まずは、SwiftUIの初歩とも言えるリストを作ってみます。
import SwiftUI struct ContentView: View { private var animals: [String] = ["🐶", "🐱"] var body: some View { List { ForEach(animals, id: \.self) { animal in Text(animal) } } } }
この後の形式と揃えるために List
と ForEach
の両方を使用していますが、単一な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で確認してみましょう。
できていそう!!!
これではRuntime Errorが起きる
PreviewでRowを削除してみると、Previewの端末画面が真っ白になってしまいます。
これはRuntimeでエラーが起きたということです。 *4
シミュレーターで実行し、再度エラーを発生させ、ログを見てみます。
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 をお楽しみに!