質問をすることでしか得られない、回答やアドバイスがある。

15分調べてもわからないことは、質問しよう!

新規登録して質問してみよう
ただいま回答率
85.48%
iPad

iPadは、Appleがデザインしたタブレット型コンピュータです。iPadアプリケーションは通常Xcode IDEのObjective-Cで書かれますが、iPadアプリケーションを組むためのほかのツールを使うことも可能です。

Swift

Swiftは、アップルのiOSおよびOS Xのためのプログラミング言語で、Objective-CやObjective-C++と共存することが意図されています

Q&A

解決済

2回答

1556閲覧

[SwiftUI] 親ViewでListから行を削除すると子Viewで範囲外アクセスでクラッシュする

shigeoxa

総合スコア5

iPad

iPadは、Appleがデザインしたタブレット型コンピュータです。iPadアプリケーションは通常Xcode IDEのObjective-Cで書かれますが、iPadアプリケーションを組むためのほかのツールを使うことも可能です。

Swift

Swiftは、アップルのiOSおよびOS Xのためのプログラミング言語で、Objective-CやObjective-C++と共存することが意図されています

0グッド

2クリップ

投稿2020/03/29 05:31

前提・実現したいこと

親のViewの中でList Viewを作り、行を子供のViewで表示する構成です。
子Viewでは、PickerやStepperで元の配列の要素を直接参照しています。
この状態でListから行を削除(左スワイプ)すると、子Viewで配列の範囲外アクセスでクラッシュしてしまいます。
下記のサンプルでは簡略化してStringの読み出しにしていますが、同様の症状になります。

発生している問題・エラーメッセージ

Listから行を削除すると、子Viewで以下のエラーが発生します。

Thread 1: Fatal Error: Index out of range

デバッガでindex値を確認すると、確かに行削除する前には存在していたが削除で範囲外になったindexでアクセスしています。

SceneDelegate.swiftは以下のように変更しています。
let contentView = ContentView().environmentObject(Test())

該当のソースコード

SwiftUI

1import SwiftUI 2 3struct Data: Hashable, Identifiable { 4 let id = UUID() 5 let name: String 6} 7class Test: ObservableObject { 8 @Published var datas = [Data]() 9 10 init() { 11 datas.append(Data(name: "A")) 12 datas.append(Data(name: "B")) 13 } 14 15 func index(_ data: Data) -> Int { 16 return (datas.firstIndex(where: { $0.id == data.id })!) 17 } 18} 19 20struct RowView1: View { 21 @EnvironmentObject var test: Test 22 let index: Int 23 24 var body: some View { 25 Text(test.datas[index].name) 26 } 27} 28 29struct RowView2: View { 30 @EnvironmentObject var test: Test 31 var data: Data 32 var index: Int { 33 test.datas.firstIndex(where: { $0.id == data.id })! 34 } 35 36 var body: some View { 37 Text(test.datas[index].name) 38 } 39} 40 41struct ContentView: View { 42 @EnvironmentObject var test: Test 43 44 var body: some View { 45 List { 46 ForEach(test.datas) { data in 47 RowView1(index: self.test.index(data)).environmentObject(self.test) 48// RowView2(data: data).environmentObject(self.test) 49// Text(self.test.datas[self.test.index(data)].name) 50 }.onDelete { offsets in 51 self.test.datas.remove(atOffsets: offsets) 52 } 53 } 54 } 55}

試したこと

コード中でコメントアウトしている、引数をindexではなくオブジェクトを渡すRowView2()でもクラッシュします。
Thread 1: Fatal Error: Unexpectedly found nil while unwrapping an Optional value
削除済オブジェクトのidで配列を探しているようです。

また、もう一つのコメントアウトのように、子供のViewを作らずに親Viewの中で処理を直接記述すると意図通りに動作しました。
しかし、実際に実現したい行の処理は大きいため子供のViewを作りたいのでこの方法では回避できません。
iPadOS13.1,13.3,13.4いずれも同様の症状でした。

EnvironmentObjectで監視対象にして行削除すればListが正しく更新表示される仕様だと思うのですが、Viewをまたぐ構成には対応していないのでしょうか?
仕様の誤解やコードの間違えなどがあったらご指摘よろしくお願いします。

補足情報(FW/ツールのバージョンなど)

環境はXcode Version 11.4で、iPadシミュレータとiPadOS13.4の実機で確認しています。

気になる質問をクリップする

クリップした質問は、後からいつでもMYページで確認できます。

またクリップした質問に回答があった際、通知やメールを受け取ることができます。

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

hoshi-takanori

2020/03/29 23:29

オブジェクトを index で参照するという発想が良くないのだと思います。RowView2 でせっかく data を渡しているのに、いったん index にして data を取得し直す意味がわかりません。渡された data をそのまま使えばいいのでは。
shigeoxa

2020/03/31 10:54

回答ありがとうございます。 確かにサンプルだと配列を直接参照する意味がありませんが、本来やりたいことはPickerやStepperで構造体配列の要素を直接変更する必要があるためです。 この方法はSwiftUIのチュートリアルLandmarkDetail.swiftにあった以下のコードを参考にしました。 Button(action: {self.userData.landmarks[self.landmarkIndex].isFavorite.toggle()}) RowView2がこのパターンです。 その後色々試したところ回避策を見つけることができました。「解決方法」に記入します。
guest

回答2

0

時間のある時に少しずつ回答を書いていたところ、既に自己解決されていましたが、
せっかく書いたので、遅ればせながら一応回答を入れておきます。

コードをコピペして動きを確認してみました。

RowView1のindexは、RowView1インスタンス生成時に初期化され、以後不変に保持される変数なのに対して、test.datasは、セルの削除に伴って配列の要素数が変化するデータです。
セルを削除した時にonDeleteでtest.datasの要素数を1個に減らすと、RowView1の@EnvironmentObjectで宣言した変数の変化が検知され、Viewの再構築が動作し、indexが1で初期化されているRowView1の再構築動作の中で test.datas[1]にアクセスしてクラッシュしているようです。

onDeleteでセルを削除したのだから、indexが1のRowViewの再構築は動作せずにそのまま破棄されるような動作になっているのであれば、エラーは発生しないと思いますが、現実の動作は削除したRowViewに対してViewの再構築が動作するようです。
簡単に修正するなら、次のようにして

swift

1struct RowView: View { 2 @EnvironmentObject var test: Test 3 let index: Int 4 5 var body: some View { 6 Text(index < test.datas.count ? test.datas[index].name : "") 7 } 8}

indexが現在のtest.datasの要素数以内なら test.datas[index]にアクセスするようにガードすれば、一応正常に動作するようです。(これは、自己解決された方法とほぼ同じやり方です。)

しかし問題の根本は、時系列に変化するデータであるtest.datasをインスタンス生成時に初期化されたindexでアクセスしている(時系列の異なるデータにアクセスしている)ことにあると思います。

例えば、次のようにして

swift

1struct RowView: View { 2 let test: Test 3 let index: Int 4 5 var body: some View { 6 Text(test.datas[index].name) 7 } 8}

testを@EnvironmentObject変数ではなく、インスタンス生成時に初期化される変数として保持しておき、
ForEachの中で

RowView(test: self.test, index: self.test.index(data))

このようにRowViewを初期化すれば、@EnvironmentObjectの test.datasがどのように変化しても
RowViewに引き渡したtest.datasは変化しないので、エラーは発生しなくなります。

しかし、そもそもある1つの配列要素を表示するためにあるRowView1が、配列全体のtest.datasにアクセスしていること自体がおかしな設計になっていると思います。

普通は、次のようにRowViewを定義して

struct RowView: View { let data: Data var body: some View { Text(data.name) } }

次のようにdataを渡してRowViewを初期化するだけで正常に動作します。

RowView(data: data)

(4/1 23:40追記)

配列要素内の変数値をStepper等で変更したい場合、
私だったら次のよう@Bindingで配列要素内のDataを受け渡しして処理します。
配列全体(test.datas)をRowView1に参照させるべきではないと思います。

swift

1struct RowView1: View { 2 @Binding var data: Data 3 4 var body: some View { 5 Stepper(value: $data.count) { 6 Text(data.name + "(data.count)") 7 } 8 } 9} 10 11struct ContentView: View { 12 @EnvironmentObject var test: Test 13 14 var body: some View { 15 List { 16 ForEach(0..<test.datas.count, id: .self) { i in 17 RowView1(data: self.$test.datas[i]) 18 }.onDelete { offsets in 19 self.test.datas.remove(atOffsets: offsets) 20 } 21 } 22 } 23}

(4/6 23:55追記)

コメントを受けて、追加確認しました。
確かに@EnvironmentObject var test: Testを追加するとAppDelegateでIndex out of rangeのエラーが発生します。

それで紹介していただいた記事等を読んだ上でよく考え直してみたのですが、前回提示したコードでRowViewに受け渡している self.$test.datas[i] は、一見、配列の要素であるDataだけを受け渡しているように見えますが、実は内部ではtest全体を共有した状態で受け渡しているのではないかと考えるようになりました。DataはstructなのでDataの要素を書き換えるにはDataインスタンスの差し替えが必要で、Dataインスタンスの差し替えをするには、共有されたData配列全体の何番目の要素を差し替えるか知っていないといけないからです。
つまり、前回示したコードは、RowViewの中で配列全体や配列番号にアクセスするようなコードは自分で明示的に書いていませんが、実は$dataの内部で配列全体へのアクセスが発生しており、削除された配列番号にアクセスしてしまうという問題は結局解決されていないのではないかと思います。

EnvironmentObjectの有無でエラーが発生するかしないかは、ListからViewが削除される前にViewを再構築するか否かという微妙な動きの違いで、たまたまエラーが発生したりしなかったりするというだけなのではないかと思います。

そのため、受け渡し先で配列要素を更新するには、配列全体の中から、これから更新するData位置を検索して、存在していればそれを更新し、削除されて既に存在していなければ更新しないという形で処理するしかない(それが最も安全なやり方である)と思うようになりました。これはつまり、Appleのチュートリアルでやっているやり方(RowView2のやり方)と同じ考え方です。ただ、Appleのチュートリアルは、データの削除がないという前提のコードなのでfirstIndexメソッドの結果を !でアンラップしていて、そこでエラーが発生しています。データの削除があることを前提にするなら、それがnilだった場合は何もしないようにしたので良さそうに思います。それを踏まえて、配列要素内の値をStepperで変更したい場合、自分ならこうするというのをもう一度書き直してみました。

swift

1struct RowView: View { 2 @EnvironmentObject var test: Test 3 let data: Data 4 5 @State var count: Int = 0 6 7 var body: some View { 8 Stepper(value: $count, onEditingChanged: {touchDown in 9 if !touchDown { 10 if let index = self.test.datas.firstIndex(where: {$0.id == self.data.id}) { 11 self.test.datas[index].count = self.count 12 } 13 } 14 }) { 15 Text(data.name + "(data.count)") 16 }.onAppear { 17 self.count = self.data.count 18 } 19 } 20} 21 22struct ContentView: View { 23 @EnvironmentObject var test: Test 24 25 var body: some View { 26 List { 27 ForEach(test.datas) { data in 28 RowView(data: data) 29 }.onDelete { offsets in 30 self.test.datas.remove(atOffsets: offsets) 31 } 32 } 33 } 34} 35

今回の件は、とても勉強になりました。ありがとうございます。

投稿2020/03/31 15:25

編集2020/04/06 14:57
TakeOne

総合スコア6299

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

shigeoxa

2020/04/01 12:04

回答ありがとうございます。 回避できたとは言えモヤモヤしていたのでスッキリしました。 もう少し以下の点についてご意見頂けると嬉しいです。 コメント頂いたように、確かに「表示」だけが目的であれば配列要素の値渡しで良いのですが、実現したかったことはStepperなどでの「変更」です。サンプルが良くなかったので書き換えてみました。 struct Data: Hashable, Identifiable { let id = UUID() let name: String var count = 0 // この値を更新したい } struct RowView1: View { @EnvironmentObject var test: Test let index: Int var body: some View { Stepper(value: $test.datas[index].count) { Text(test.datas[index].name + "(test.datas[index].count)") } } } struct RowView2: View { @EnvironmentObject var test: Test var data: Data var index: Int { test.datas.firstIndex(where: { $0.id == data.id })! } var body: some View { Stepper(value: $test.datas[index].count) { Text(test.datas[index].name + "(test.datas[index].count)") } } } このようにユーザ操作で配列の中身を変更する、というのが本来やりたかったことです。 こうした要件の場合、どのような設計が良いのかヒントなどありましたらよろしくお願いします。 今回の方法はSwiftUIのチュートリアルで見つけました。 https://developer.apple.com/tutorials/swiftui/handling-user-input Section6のStep2です。これがRowView2のパターンです。 しかし、この方法だと要素の値を渡しているのに子供のViewの中でindexを算出して元の配列を変更するので、呼び出し側からすると気持ちが悪いと思いました。 そこで、いっそのこと子供が配列をアクセスすることを明確にするためindex渡しにしてみました(RowView1)。 しかし、確かにRowViewは対象の行だけを扱う方が良いと思います。Cだと配列の要素のポインタを渡すところですが、SwiftUIでは良い方法が思いつきませんでした。 ちなみに上記チュートリアルのコードにonDeleteを付けた場合もfatal errorになりました。
TakeOne

2020/04/01 14:44

Stepperを使った場合の修正例を追記しておきました。
shigeoxa

2020/04/03 12:20

回答ありがとうございます。 このサンプルコードだと範囲チェックなしで行削除も意図通りに動作するのを確認できました。 ところが、目的のコードに組み込んでみたところ、今度はclass AppDelegateでfatal errorとなってしまいました。 頂いたサンプルコードでも以下のようにRowView1に一行追加して行削除を実行すると、上記と同様のエラーになりました。 struct RowView1: View { @EnvironmentObject var test: Test // これを追加 @Binding var data: Data var body: some View { Stepper(value: $data.count) { Text(data.name + "(data.count)") } } } 目的のコードでも上記と同様に対象の配列を含んだクラスを@EnvironmentObjectで参照しているのが悪いのかもしれません。 この参照をやめるようにコードを再整理してみようと思いますが、ちょっと気になるページも見つけました。 https://yutailang0119.hatenablog.com/entry/delatable-table-with-textfield-on-swiftui このページに書いてあることはまだ良く理解できていないので、もう少し勉強してみようと思います。 いろいろありがとうございました。
TakeOne

2020/04/06 14:58

前回提示したコードは、結局問題解決になっていないのではないかと思うようになりました。新たな回答を追加しておきました。
shigeoxa

2020/04/09 10:22

色々解析情報ありがとうございます。 実は内部で配列全体を受け渡しているのでは、という考えは確かにその通りだと思いました。 あと、Stepperに渡す@Stateのプロパティをローカルに配置する方法は以前試したのですが、元の値を表示させる方法が分からなくて諦めていましたが、onAppearで設定する方法があったのですね。 大変参考になりました。今後活用したいと思います。ありがとうございました。
guest

0

自己解決

以下のようにindexが配列内のときだけText()するように変更したらエラーにならなくなりました。
最初Groupを思い付かなくてif文を入れるとコンパイルエラーになって諦めてましたが、Groupの存在を知ったので解決できました。

SwiftUI

1struct RowView1: View { 2 @EnvironmentObject var test: Test 3 let index: Int 4 5 var body: some View { 6 Group { 7 if (index < test.datas.count) { 8 Text(test.datas[index].name) 9 } 10 } 11 } 12}

投稿2020/03/31 11:06

shigeoxa

総合スコア5

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

あなたの回答

tips

太字

斜体

打ち消し線

見出し

引用テキストの挿入

コードの挿入

リンクの挿入

リストの挿入

番号リストの挿入

表の挿入

水平線の挿入

プレビュー

15分調べてもわからないことは
teratailで質問しよう!

ただいまの回答率
85.48%

質問をまとめることで
思考を整理して素早く解決

テンプレート機能で
簡単に質問をまとめる

質問する

関連した質問