[SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その2 : 要素編集)

Realm を使用するアプリのプロジェクトの設定が終わり、Realm 中の要素を List 表示するところまでできましたので、要素を編集していきます。
具体的には、要素を追加したり順序を変更したりします。

Realm シリーズ第2回です。関係回は、以下です。

[SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その1 : 要素作成) [SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その3 : UNDO/REDO 実装) [SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その4: List 表示している要素を修正する)

想定するアプリ概要

以下のような機能を追加していきます。

要素作成
"要素追加"ボタンを押すと、要素が追加され、リストが更新される
要素削除
"編集"ボタンを押下後、削除指定することで要素が削除され、リストが更新される
順序入れ替え
"編集"ボタンを押下後、要素をドラッグすることで、順序を入れ替えることができ、リストも更新される。

モデル準備

前回、要素追加のための関数を追加していますので、削除用の関数を、AppModel に追加しました。

AppModel に追加した 削除関数

    func removeTextLine(_ textLine: TextLine) {
        guard let index = textLineList.index(of: textLine) else { return }
        let realm = try! Realm()
        try! realm.write {
            realm.delete(textLine)
            textLineList.remove(at: index)
        }
    }

UI 準備

以下は、いずれも、ContentView に対して変更を入れています。

編集ボタンと追加ボタン

編集ボタンは、EditButton を使うことにします。

追加ボタンは、ボタンを表示させ、押下された時に、適当な文字列を設定した TextLine を作成することとします。

複雑な機能ではないので、closure に実装し、関数化していません。(コード全体は後ほど)

追加コード:編集ボタンと追加ボタン

   List {
        // snip
     }
     .navigationBarItems(leading: EditButton()
                 , trailing: Button("Add", action: {
                 let newName = "name" + String(Int.random(in: 0...2000))
                 self.appModel.addTextLine(newName)
                 })
                 )

削除と順序入れ替え

.onDelete, .onMove を使って実装していきます。
表示に使用している List の中の ForEach に以下のように追加していきます。

.onDelete 実装

    List {
        ForEach( textLines ) { textLine in
            Text("\(textLine.id)  \(textLine.text)")
        }
        .onDelete(perform: delete)
        .onMove(perform: move)
    }

perform 後の delete と move は、以下で実装している ContentView の関数です。

削除実装

.onDelete から使われる機能を実装します。
お約束通り、IndexSet を受け取って処理するようにしています。

削除関数 実装

    func delete(at offsets:IndexSet) {
        if let realm = textLines.realm {
            try! realm.write {
                realm.delete(textLines[offsets.first!])
            }
        } else {
            textLines.remove(at: offsets.first!)
        }
    }

注意点としては、List から削除しただけでは、Realm 中に存在していますので、Realmからもきちんと削除するようにしています。

順序入れ替え

.onMove から使われる機能を実装します。

以下が、移動関数です。こちらは引数として IndexSet と Int を受け取って処理するようになっています。

移動関数

    func move(fromOffsets offsets: IndexSet, toOffset to: Int) {
        textLines.realm?.beginWrite()
        textLines.move(fromOffsets: offsets, toOffset: to)
        try! textLines.realm?.commitWrite()
    }

動かない???

ここで一度動かしてみると、削除すると例外が発生してアプリがクラッシュすることがわかります。

エラ〜メッセージ
*** Terminating app due to uncaught exception 'RLMException', reason: 'Index 12 is out of bounds (must be less than 12).'

理由は、SwiftUI は削除等の動作を行う時にアニメーションを入れてくれます。このアニメーションを行うためには、動作の前後の差異を理解する必要があります。

また、SwiftUI は、リスト等の対象は、immutable (変更されない) と想定しています。

Realm は、変更が発生すると直接そのオブジェクトを変更してしまいます。

# どちらもそれぞれの想定で実装されているのでどちらが悪いということではありません。

このため、以前に渡したリストを SwiftUI がアクセスしようとして不整合が発生しエラーになっています。

今回は削除操作だったため、リストのサイズが変わってしまっています。この点が、SwiftUI が想定していない点です。

Realm は、Realm 5.0 以降で、SwiftUI 対応されています。

具体的には、Collection タイプに対して、freeze() という関数が定義され、immutable な 集合を返してくれます。この集合を SwiftUI に渡すことで不整合を防げます。

以下が、修正も含めた ContentView.swift です。

ContentView.swift

//
//  ContentView.swift
//  Shared
//
//  Created by Tomoaki Yagishita on 2020/09/05.
//

import SwiftUI
import RealmSwift

struct ContentView: View {
    var appModel: AppModel
    @ObservedObject var textLines: RealmSwift.List
    var body: some View {
        NavigationView {
            List {
                ForEach( textLines.freeze() ) { textLine in // 1
                    Text("\(textLine.id)  \(textLine.text)")
                }
                .onDelete(perform: delete)
                .onMove(perform: move)
            }
            .navigationBarItems(leading: EditButton()
                                , trailing: Button("Add", action: {
                                    let newName = "name" + String(Int.random(in: 0...2000))
                                    self.appModel.addTextLine(newName)
                                })
                                )
            .navigationBarTitle("Realm Doc")
        }
        HStack {
            Text("# of item \(textLines.count)")
        }
    }

    func delete(at offsets:IndexSet) {
        if let realm = textLines.realm {
            try! realm.write {
                realm.delete(textLines[offsets.first!])
            }
        } else {
            textLines.remove(at: offsets.first!)
        }
    }

    func move(fromOffsets offsets: IndexSet, toOffset to: Int) {
        textLines.realm?.beginWrite()
        textLines.move(fromOffsets: offsets, toOffset: to)
        try! textLines.realm?.commitWrite()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(appModel: .init(), textLines: .init())
    }
}

1: freeze() を追加しただけですが、実行してもエラーが発生しないのが確認できます。

まとめ

Realm 内部の変更に対応させるためには、immutable Collection を SwiftUI に渡すことがポイント

おすすめの Realm 本

Web で API 等を Reference で確認することができますが、じっくりと腰を据えて学習したいときには、学習の順番が考慮されている本を使うのがおすすめです。

Realm については、本がほとんど出版されていませんが、以下の本は おすすめです。

説明は以上です。

コメントを残す

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