SwiftUI で List の末尾を削除したらクラッシュする問題の対処

注意事項

概要

SwiftUI で List の末尾を削除したらクラッシュする事象が発生したので、その原因と対処方法について述べる。

環境:
 Swift 5.3.2
 Xcode 12.3

前置き

Objective-C から Swift に移行しようと腰を上げ始めたぐらいでそのまま2年が経過し、その間に SwiftUI まで登場した。せっかくなので SwiftUI のサンプルを作成してみたが、しょうもないところで躓いた。割と引っかかりやすいミスだと思う割には初歩的すぎるのかググってもあまり出てこないので、問題と対処についてメモを残す。

サンプルプログラムの概要

表示された行をタップすると表示文字を編集できる画面に遷移する。スライドで行削除できる。

f:id:nashikachi:20210127001426g:plain
動作説明

クラッシュが起こる操作

このサンプルプログラムは、下記の操作を行うとクラッシュする。
 1. 最終行をタップして画面遷移
 2. 最初の画面に戻る
 3. 最終行を削除する

f:id:nashikachi:20210127002159g:plain
クラッシュ操作

クラッシュした際には下記のように Index out of range のメッセージが出る。

Fatal error: Index out of range: file Swift/ContiguousArrayBuffer.swift, line 444

問題のプログラム

ContentView.swift

import SwiftUI

struct ContentView: View {
    @State var strArr:[String] = ["aaa", "bbb","ccc"]
    
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(strArr.indices, id: \.self) { index in
                        NavigationLink(destination: SecondView(strArr: $strArr, index: index)) {
                            Text(strArr[index])
                        }
                    }
                    .onDelete(perform: deleteTest) 
                }
                .listStyle(PlainListStyle())
            }
            .padding()
        }
    }    

    func deleteTest(at offsets: IndexSet) {
        strArr.remove(atOffsets: offsets)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

SecondView.swift

import SwiftUI

struct SecondView: View {
    @Binding var strArr: [String]  //ContentViewで保持している配列
    @State private var editFlag = false //編集画面の切り替えフラグ
    let index : Int  //strArrの要素を指すindex
        
    var body: some View {
        VStack {
            if(editFlag) {
                Button(action: { editFlag.toggle() }){ Text("完了") }
                HStack {
                    Text("文字: ")
                    TextField("入力", text: $strArr[index])
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                }
            } else {
                Button(action: { editFlag.toggle() }){ Text("編集") }
                HStack {
                    Text("文字: ")
                    Text(strArr[index])
                    Spacer()
                }
            }
        }
        .padding()
    }
}

struct SecondView_Previews: PreviewProvider {
    static var testData : [String] = ["aa", "bb", "cc"]
    static var previews: some View {
        SecondView(strArr: .constant(testData), index: 1)
    }
}

クラッシュする原因

結論から述べるとクラッシュする原因は、遷移後の画面の文字列表示に、最初の画面で利用している@Stateな変数をそのまま利用しているからである。
具体的には、ContentView のプロパティである下記の

@State var strArr:[String] = ["aaa", "bbb","ccc"]

を、SecondView で @Binding で受け取り、それをText()などの表示でそのまま使っていることが原因。

@Binding var strArr: [String] 
  ………
Text(strArr[index])

なぜ遷移後のViewである SecondView で、遷移前のViewのプロパティである strArr をそのまま表示に使うことでエラーが発生するかというと、@State の仕組みが関係してくる。

まず動作の流れを見ていくと、最終行(3行目)をタップして SecondView に画面遷移をした時、SecondView の プロパティの index には 2 の値が格納される。
なので Text(strArr[index]) では、strArr[2] に格納されている "ccc" が使われる。
その後、元の画面の ContentView に戻り最終行を削除すると、strArr[2] の値 "ccc" が削除され、strArrは[0]と[1]の2つの要素だけになる。
ここで @State の仕組みが関係してくる。
@State の属性を付与したプロパティは値に変更がかかると View が再描画される。
それは、@State属性の strArr を @Binding で使っている SecondView でも同じで、こちらも strArr の値が変更されると自動で再描画が発生する。
そのため strArr の[2]を削除した際、ContentView および SecondView で再描画が発生する。(なぜ今は表示していない SecondView まで再描画されるのかは調査できていないが、さっき表示していた SecondView の再描画も走っている)
そして、SecondView で再描画が走る際 index の値は 2 のままのため、Text(strArr[index]) の処理で、既に存在していない strArr[2] にアクセスしようとしてしまい、Index out of range でクラッシュする。

個人的には、ContentView 上での削除処理だったのでまさか SecondView まで再描画が走るとは思っておらず、この問題にしばらく躓いてしまった。もしかすると、1つ前の画面は少なくともまだアプリ的に生きている画面として保持されていて、それで更新対象になっているのかもしれない。

この問題の対処を色々試していくうち、3通りの解法を考えた。
3通りの方法どれでも対処できるが、対処方法その3が個人的に最もよいかと思う。

対処方法その1

修正作業量的に最もシンプルな対処パターン。
SecondView.swift の body に、 if文を1つ追加するだけ。
似たような事象のQ&Aにあった方法を参考にさせて頂いた。

    var body: some View {
                        
        VStack {
            if(index < strArr.count) { //ここを追加
                if(editFlag) {
                    Button(action: { editFlag.toggle() }){ Text("完了") }
                    HStack {
                        Text("文字: ")
                        TextField("入力", text: $strArr[index])
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                    }
                } else {
                    Button(action: { editFlag.toggle() }){ Text("編集") }
                    HStack {
                        Text("文字: ")
                        Text(strArr[index])
                        Spacer()
                    }
                }
            }
        }
        .padding()
    }

SecondView で当該エラーが発生するのは、SecondView のプロパティの index の値が最終行のまま固定されていて、その index が既に存在しない配列の要素を使おうとするからである。
なので、index の値が現在の配列の要素数より小さい場合のみ描画をするようにすれば、当該エラーは発生しないというロジック。
やや雑さを感じるが、もう画面を使わなくなった後に起きる事象の対処なので細かいことは考えなくてよい性質を利用した合理的な対処。

対処方法その2

根本対処に臨んだ対処パターン。
SecondView での画面表示に使うプロパティは、全て SecondView が持つものを使うように変更し、根本的に当該エラーの発生をなくしている。
修正量は多いが、本来はこうすべきだったのだろうと思う。

struct SecondView: View {
    @Binding var strArr: [String]  //ContentViewで保持している配列
    @State private var editFlag = false //編集画面の切り替えフラグ
    @State private var inputStr:String //textFieldで使う変数。init()で初期化する
    let index : Int  //strArrの要素を指すindex
    
    //初期化。inputStrを初期化したいがためだけに用意。
    init(strArr: Binding<[String]>, index: Int) {
        self._strArr = strArr
        self.index = index
        self._inputStr = State(initialValue: strArr[index].wrappedValue)
    }
    
    var body: some View {
        VStack {
            if(editFlag) {
                Button(action: { editFlag.toggle() }){ Text("完了") }
                HStack {
                    Text("文字: ")
                    TextField("入力", text: $inputStr)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .onDisappear {
                            strArr[index] = inputStr
                        }
                }
            } else {
                Button(action: { editFlag.toggle() }){ Text("編集") }
                HStack {
                    Text("文字: ")
                    Text(inputStr)
                    Spacer()
                }
            }
            
        }
    }
}

今回原因となっていた箇所は Text()で使っていた strArr なので、そこを SecondView が持つプロパティ inputStr に置き換える。
たまたま今回の原因になっていなかったTextField()の部分も、同様に inputStr に置き換える。
inputStr には、strArr[index] の値で初期化したかったため、init()を新たに作成して初期化している。

なお、今回追加したプロパティの inputStr は上記では private にしているが、そのせいで init処理を追記する必要が発生しており、それが面倒であれば private にはせず、ContentView から NavigationLink で飛んでくる際に引数で受け取ると楽ができる。
対処法その2では ContentView のコードを変更せずに対処する方法を検討したため、上記のようなやり方になっている。

対処方法その3

対処方法2の考えを引き継いだ上で、実用ベースで改良した対処パターン。多数変更を加えている。
そもそも String の配列単体だけを遷移先に渡すということ自体、実際のアプリのコードでは起きないと思う。
渡すのであれば Stringを包含した struct か class のプロパティが現実的だろう。
ということで、Stringを包含したstruct に置き換え、それベースでコードを見直した。

ContentView.swift

import SwiftUI

struct TestStr: Identifiable { 
    let id = UUID()
    var str: String
}

struct ContentView: View {
    @State var strArr:[TestStr] =
        [TestStr(str: "aaa"), TestStr(str: "bbb"), TestStr(str: "ccc"),]  //前回までの [String] から [TestStr] に変更
    
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(strArr) { tmp in
                        NavigationLink(destination: SecondView(strArr: $strArr, tmpStr: tmp)) {
                            Text(tmp.str)
                        }
                    }
                    .onDelete(perform: deleteTest) 
                }
                .listStyle(PlainListStyle())
            }
            .padding()
        }
    }    

    func deleteTest(at offsets: IndexSet) {
        strArr.remove(atOffsets: offsets)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

SecondView.swift

import SwiftUI

struct SecondView: View {
    @Binding var strArr: [TestStr]  //ContentViewで保持している配列
    @State var tmpStr : TestStr //表示用に使うデータ
    @State private var editFlag = false //編集画面の切り替えフラグ
    var index : Int  { //strArrの要素を指すindex
        strArr.firstIndex(where: { $0.id == tmpStr.id})!
    }
        
    var body: some View {
        VStack {
            if(editFlag) {
                Button(action: { editFlag.toggle() }){ Text("完了") }
                HStack {
                    Text("文字: ")
                    TextField("入力", text: $tmpStr.str)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .onDisappear {
                            strArr[index].str = tmpStr.str
                        }
                }
            } else {
                Button(action: { editFlag.toggle() }){ Text("編集") }
                HStack {
                    Text("文字: ")
                    Text(tmpStr.str)
                    Spacer()
                }
            }
        }
        .padding()
    }
}

struct SecondView_Previews: PreviewProvider {
    static var testData : [TestStr] = [TestStr(str: "aaa"), TestStr(str: "bbb"), TestStr(str: "ccc")]
    static var previews: some View {
        SecondView(strArr: .constant(testData), tmpStr: testData[1])
    }
}


まず、String のプロパティを持つ下記の構造体を定義。
ForEachで楽するために、Identifiableプロトコルに準拠する。

struct TestStr: Identifiable { 
    let id = UUID()
    var str: String
}

先ほどまで使っていたString配列の strArr は、TestStr配列のプロパティとして置き換える。

SecondView のプロパティも同様に変わり、また strArr が id を持つようにもなっているので、ContentView からの遷移で直接要素の値を受け取るのではなく直接対象のTestStr の値を受け取り(tmpStr)、tmpStr の id と strArrの各要素の id とを比較することで、index を求めるように変更している。

 var index : Int  { //strArrの要素を指すindex
    strArr.firstIndex(where: { $0.id == tmpStr.id})!
 }

SecondViewのプロパティが変更になり配列の要素番号を直接受け取らなくなったこと、および TestStr が Identifiable に準拠していることから、 ContentView の List の中の ForEach も index でのループから変更になっている。

ForEach(strArr) { tmp in
    NavigationLink(destination: SecondView(strArr: $strArr, tmpStr: tmp)) {
        Text(tmp.str)
    }
}

まとめ

まだ SwiftUI は触り始めたばかりで、すでに表示していないViewの再描画が走る理由など分からない点しかないが、以前より GUI コードを書くのはかなり楽になっていた。
何はともあれ、他の階層のViewの操作で削除される値を持つプロパティを、Viewの表示にそのまま使ってはいけないという教訓を得た。
これを意識していれば本事象は回避できる。

あと今回は、@State なプロパティを別のViewに渡すということをしているが、同様のことをしたい場合、本来は @State ではなく @ObservedObject なり @EnvironmentObject を使うべきだろうと思う。