これは はてなエンジニア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に投稿した質問。
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)
}
}
}
}
この後の形式と揃えるために 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 で確認してみましょう。
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 {
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がよりよいものになるように、集合知 を集めて行きましょう!!!
はてなエンジニアAdvent Calendar 2019 をお楽しみに!