[SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その4: List 表示している要素を修正する)

単体要素の修正であれば、問題なく行えます。
Realm から取得した複数要素も freeze() 指定して使うことで、List 等にも使えます。

ただし、この2つを組み合わせようとすると 少し工夫が必要となります。その工夫を説明します。

Realm シリーズ第4回です。以前の回は、以下です。

[SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その1 : 要素作成) [SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その2 : 要素編集) [SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その3 : UNDO/REDO 実装)

これまでの復習

ここまでみてきたところで、以下のことがわかっています。

  • Realm の Object は、@ObservedObject で受けられる
  • List に渡すときは、freeze() 指定しないと、エラーになる

詳細ビューで変更するパターンへの対応

リストによって要素が列挙されている画面から、1要素をクリックして詳細画面へ遷移して、要素の編集を行うパターンは、定番の操作です。

これまでの方法では、このパターンを実装しようとするとエラーが発生してしまいます。

これまでの延長線上での実装

List に渡すときには、freeze() して渡さないといけません、また、Realm の Object は、@ObservedObject で受けられるので、
普通に作ると以下のように実装出来そうです。

ContentView

struct ContentView: View {
  @ObservedObject var appModel: AppModel
  @ObservedObject var textLines: RealmSwift.List
  
  var body: some View {
    VStack {
      NavigationView {
        List {
          ForEach( textLines.filter("askedForDelete == false").freeze() ) { textLine in
             // (1) ナビゲーション先で編集予定
            NavigationLink(textLine.text, destination: TextDetailView(textLine: textLine.id)))
            // Text("\(textLine.askedForDelete.description) \(textLine.id)  \(textLine.text)").font(.footnote)
          }
          .onDelete(perform: delete)
          .onMove(perform: move)
        }
        .navigationBarItems(leading: HStack {
          Button(action: { self.appModel.undo() }, label: {
            Image(systemName: "arrow.uturn.left.circle")
          })
          .disabled(self.appModel.canUndo != true)
          Button(action: { self.appModel.redo() }, label: {
            Image(systemName: "arrow.uturn.right.circle")
          })
          .disabled(self.appModel.canRedo != true)
        },
        trailing: HStack {
          EditButton()
          Button("Add", action: {
            let newName = "name" + String(Int.random(in: 0...2000))
            let createCommand = CreateTextLineCommand(appModel: self.appModel, newTextLineText: newName)
            self.appModel.executeCommand(createCommand)
          })
        }
        )
        .navigationBarTitle("Realm Doc")
      }
      HStack {
        Text("# of item \(textLines.filter("askedForDelete == false").count)")
      }
    }
  }
  /* snip */
}

// (2) 
struct TextDetailView: View {
  // (3)
  @ObservedObject var textLine: TextLine
  var body: some View {
    TextField(textLine.text, text: $textLine.text)
  }
}

(1) で、下位ビューに移行するようにして、(2) で定義している下位ビューで、テキストを編集できるようにしています。
変更を伝播するため、(3) で @ObservedObject で受けています。

実行すると・・・

以下のようなエラーが発生します。

エラーメッセージ
Exception	NSException *	"Frozen Realms do not change and do not have change notifications."

理由は、以下の箇所で (a) で freeze() して渡した要素を、(b) で Binding して渡そうとしている点です。

ContentView から抜粋

                                                       // ⬇️ (a)
  ForEach( textLines.filter("askedForDelete == false").freeze() ) { textLine in
                                            // ⬇️ (b) 
    NavigationLink(textLine.text, destination: textLine)
  }

Realm のドキュメントには、以下のように説明されています。

When working with frozen objects, an attempt to do any of the following throws an exception:

  • Opening a write transaction on a frozen realm.
  • Modifying a frozen object.
  • Adding a change listener to a frozen realm, collection, or object.

解決策も以下のように書かれています。

To modify a frozen object, query for it on an unfrozen realm, then modify it.

そのままですね。再度、Query しなさいとのことでした。

再 Query するように修正

準備として id から要素を取得する以下のような関数を作ります。

textLineFromID

  func textLineFromID(id: String) -> TextLine {
    let realm = try! Realm()
    return realm.object(ofType: TextLine.self, forPrimaryKey: id)!
  }

そして、TextDetailView へ渡す TextLine を、再取得してから渡すようにします。

ContentView から抜粋 (id から TextLine 再取得)

  ForEach( textLines.filter("askedForDelete == false").freeze() ) { textLine in
    NavigationLink(textLine.text, destination: TextDetailView(textLine: self.textLineFromID(id: textLine.id)))
  }

改めて実行してみると

別のエラーが・・・・

エラーメッセージ
Exception	NSException *	"Attempting to modify object outside of a write transaction - call beginWriteTransaction on an RLMRealm instance first."

TextField に String の Binding を渡して変更しようとしているのですが、Realm では、Object の変更は、以下のように、write ブロックで変更しなければいけません。

Realm への書き込み

  try! realm.write {
     // Realm 要素の変更処理等
  }

TextField には、onCommit: で編集終了時の処理を記述する方法がありますので、以下のようにします。

TextField の onCommit で Realm へ反映

    TextField(textLine.text, text: $tmpText, onCommit: {
      let realm = try! Realm()
      try! realm.write {
        textLine.text = tmpText
      }
    })
MEMO
テキストを変更するコマンドを作って、onCommit で実行するほうが良いですが、省略しています。

やっと・・・

SwiftUI の期待するやり方と、Realm の期待するやり方をうまく整合させることが必要になりましたが、スムーズに動作させることができました。

参考までに、改めて、コードを提示します。

ContentView

struct ContentView: View {
  @ObservedObject var appModel: AppModel
  @ObservedObject var textLines: RealmSwift.List
  
  var body: some View {
    VStack {
      NavigationView {
        List {
          ForEach( textLines.filter("askedForDelete == false").freeze() ) { textLine in
            NavigationLink(textLine.text, destination: TextDetailView(textLine: self.textLineFromID(id: textLine.id)))
          }
          .onDelete(perform: delete)
        }
        .navigationBarItems(leading: HStack {
          Button(action: { self.appModel.undo() }, label: {
            Image(systemName: "arrow.uturn.left.circle")
          })
          .disabled(self.appModel.canUndo != true)
          Button(action: { self.appModel.redo() }, label: {
            Image(systemName: "arrow.uturn.right.circle")
          })
          .disabled(self.appModel.canRedo != true)
        },
        trailing: HStack {
          EditButton()
          Button("Add", action: {
            let newName = "name" + String(Int.random(in: 0...2000))
            let createCommand = CreateTextLineCommand(appModel: self.appModel, newTextLineText: newName)
            self.appModel.executeCommand(createCommand)
          })
        }
        )
        .navigationBarTitle("Realm Doc")
      }
      HStack {
        Text("# of item \(textLines.filter("askedForDelete == false").count)")
      }
    }
  }
  
  func delete(at offsets:IndexSet) {
    let target = textLines.filter("askedForDelete == false")[offsets.first!]
    let deleteCommand = DeleteTextLineCommand(appModel: self.appModel, targetId: target.id, newValue: true)
    self.appModel.executeCommand(deleteCommand)
  }
  
  }
  
  func textLineFromID(id: String) -> TextLine {
    let realm = try! Realm()
    return realm.object(ofType: TextLine.self, forPrimaryKey: id)!
  }
}

struct TextDetailView: View {
  @ObservedObject var textLine: TextLine
  @State private var tmpText = ""
  var body: some View {
    TextField(textLine.text, text: $tmpText, onCommit: {
      let realm = try! Realm()
      try! realm.write {
        textLine.text = tmpText
      }
    })
  }
}

まとめ

SwiftUI と Realm を組み合わせて使うには
  • Realm の Object は、Observable なので、そのまま使える
  • SwiftUI の List に渡すには、freeze() して渡す
  • freeze した Realm Object は、再度 Query してから、変更する
  • Realm Object は、Realm.write ブロックで変更する必要があるので、onCommit 等を使用して、自分で変更処理を行う

SwiftUI の学習のお供

SwiftUI は、GUI のライブラリでもあるので、どのようなコンポーネントかを視覚的に確認できると学習がはかどります。
以下の”SwiftUI Views Mastery Bundle"という本がビジュアル的に確認して探せるので、超便利です。

英語ですが、画面のスナップショットが多くありますので、英語を読む必要は少ないと思います。

SwiftUIViewsMastery

説明は以上です。
不明な点やおかしな点ありましたら、ご連絡いただけるとありがたいです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です