CoreDataとMagicRecordとSwiftUI

CoreDataとSwiftUIに加えて、MagicRecordを使うサンプルを作ってみました。

つかう要素

以下の要素を合わせて使うプロジェクトを作りました。

  • SwiftUI
  • CoreData
  • MagicRecord

最初の2つは、Web上でもよく見つかるのですが、3つすべてを組み合わせた例があまり見つかりませんでした。

普通に動くのか、問題山積みなのか、よくわからないので、自分で作って確かめてみることに。

プロジェクト作成

普通に、SwiftUIとCoreDataを指定してプロジェクト作成。
MagicRecordは、SwiftPackageには対応していないようなので、CocoaPodsでインストールすることに。

いつも通り、いったんプロジェクトを閉じて、コマンドラインで、"% pod init"した後、作成されたPodfileに以下を追加しました。

pod 'MagicalRecord/CocoaLumberjack', :git => 'https://github.com/magicalpanda/MagicalRecord'
MEMO
MagicRecordのWebのInstall方法に書いてあるママです。

作成された.xcworkspaceを開いて、作業を継続します。

CoreDataモデルの作成

簡単なモデルを作りました。

  • Record
    • date:Date
    • volume:Double

例えば、毎日どれくらいの水を摂取しているかを記録するアプリを作るイメージです。

SampleCDDataModel

MagicRecordの準備

アプリ起動時のMagicRecord設定

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    MagicalRecord.setupCoreDataStack(withAutoMigratingSqliteStoreNamed: "CoreDataWithMagicRecord.sql")
    
    return true
}

アプリ終了時のMagicRecord設定

func sceneDidEnterBackground(_ scene: UIScene) {
    // Called as the scene transitions from the foreground to the background.
    // Use this method to save data, release shared resources, and store enough scene-specific state information
    // to restore the scene back to its current state.
    // Save changes in the application's managed object context when the application transitions to the background.
    MagicalRecord.cleanUp()
    (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
}

applicationWillTerminateとかじゃなく、Sceneがなくなる時にcleanUpするようにしました。

# ここまでの作業を終えて、コンパイルできることを確認。

いろいろとセットアップできたので、さっそく。

SwiftUIでメインのUI作成

SwiftUIの流儀に沿って、NSManagedObjectContextとNSFetchResultを使用。とりあえず、

  • Record要素をListで表示
  • NavigationViewにいれて、各要素をクリックすると詳細ビューへ
  • 右上に+ボタンがあり、押すと、要素追加
  • 左上に編集ボタンがあり、押すと要素削除ができる

Recordの配列をListに表示するUI

struct ContentView: View {
    @Environment(\.managedObjectContext) var moc
    @FetchRequest(entity: Record.entity(), sortDescriptors: [
        NSSortDescriptor(keyPath: \Record.date, ascending: true)
    ]) var records: FetchedResults

    var body: some View {
        NavigationView {
            List {
                ForEach(records, id: \.self) { record in
                    HStack {
                        Text("\(record.wrappedDate.description)")
                        .padding()
                        Text("\(record.volume) ml")
                        
                    }
                }
            }
        }
    }
}

ちなみに、CoreDataの要素に対しての簡単なWrapperを作ってます。

extension Record {
    var wrappedDate: Date {
        return date ?? Date()
    }
    var formattedDate: String {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.timeStyle = .short
        return formatter.string(from: self.wrappedDate)
    }
    var formattedVolume: String {
        return "\(self.volume) ml"
    }
}

MagicRecordとSwiftUI+CoreDataの整合性

と、ここで実行してみるとエラー発生。

SwiftUIは、SceneDelegateでNSManagedObjectContext(長いので、以降MOCと書きます)を作りますが、MagicRecordとconflictするみたい。当たり前ですね。
MagicRecordを優先して使用するために、SceneDelegateでのmoc作成部分をコメントアウトして、.environmentで渡さないようにします。

再度コンパイルするも、エラー。

@FetchRequestは、使いたいと思って残したのですが、Environmentの中の.managedObjectContextを使用しているようで、設定しないとContextが無いというエラーを起こします。

MagicRecord側が用意してくれるFetchRequestを使おうかとも考えたのですが、せっかくの自動更新機能を使わないのはもったいないので、折衷案としました。
つまり、MagicRecordが作ってくれたMOCを.environmentで渡します。
SceneDelegate側で、MagicRecordが使うContextを.environmentに設定

let context = NSManagedObjectContext.mr_default()
// Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath.
// Add `@Environment(\.managedObjectContext)` in the views that will need the context.
let contentView = ContentView().environment(\.managedObjectContext, context)

ContentView側では、@FetchRequestを使用。

struct ContentView: View {
    @FetchRequest(entity: Record.entity(), sortDescriptors: [
        NSSortDescriptor(keyPath: \Record.date, ascending: true)
    ]) var records: FetchedResults

    var body: some View {
        NavigationView {
            List {
                ForEach(records, id: \.self) {record in
                    HStack {
                        Text("\(record.formattedDate)")
                        .padding()
                        Text("\(record.formattedVolume)")
                    }
                }
            }
        .navigationBarTitle("Water Recorder")
        .navigationBarItems(trailing: Button(action: {
            print("add record")
            // DrinkView() will be trigged from here
        }, label: {
            Image(systemName: "plus")
        }))
        }
    }
}

追加等行うと問題が発生するかもしれませんが、それはその時に。-> 最後までうまく動作しました。

Record追加

+ボタンを押したら、現在の時間を使って、飲んだ水の量を入力することにしましょう。.sheetを使って、表示させます。
入力画面となるDrinkViewは、こんな感じ

DrinkView

struct DrinkView: View {
    @Environment(\.presentationMode) var presentationMode
    
    @State var waterVolume:Double = 100
    var body: some View {
        VStack {
            Text("how much water?   \(waterVolume) ml")
            .padding()
            Slider(value: $waterVolume, in: 50...500, step: 10,
                   label: {
                Text("how much water")
            })
            .labelsHidden()
            .padding()
            Button("Done") {
                let record = Record.mr_createEntity()
                record?.volume = self.waterVolume
                self.presentationMode.wrappedValue.dismiss()
            }
        }
    }
}

struct DrinkView_Previews: PreviewProvider {
    static var previews: some View {
        DrinkView()
    }
}

DrinkViewを表示するようにしたContentViewは、こちら

struct ContentView: View {
    @FetchRequest(entity: Record.entity(), sortDescriptors: [
        NSSortDescriptor(keyPath: \Record.date, ascending: true)
    ]) var records: FetchedResults

    @State var showingDrinkView = false
    
    var body: some View {
        NavigationView {
            List {
                ForEach(records, id: \.self) {record in
                    HStack {
                        Text("\(record.formattedDate)")
                        .padding()
                        Text("\(record.formattedVolume)")
                    }
                }
            }
        .navigationBarTitle("Water Recorder")
        .navigationBarItems(trailing: Button(action: {
            self.showingDrinkView = true
        }, label: {
            Image(systemName: "plus")
        }))
        }
        .sheet(isPresented: $showingDrinkView) {
            DrinkView()
        }
    }
}

いくつか記録した時ののContentView

MainListView

削除等の追加

左へのスライドや、編集ボタンでの変更も実装してみます。
ContentViewは、こんな感じです。

struct ContentView: View {
    @FetchRequest(entity: Record.entity(), sortDescriptors: [
        NSSortDescriptor(keyPath: \Record.date, ascending: true)
    ]) var records: FetchedResults

    @State var showingDrinkView = false
    
    var body: some View {
        NavigationView {
            List {
                ForEach(records, id: \.self) {record in
                    HStack {
                        Text("\(record.formattedDate)")
                        .padding()
                        Text("\(record.formattedVolume)")
                        
                    }
                }
                .onDelete(perform: deleteRecord)
            }
        .navigationBarTitle("Water Recorder")
        .navigationBarItems(leading: EditButton(),
            trailing: Button(action: {
            self.showingDrinkView = true
        }, label: {
            Image(systemName: "plus")
        }))
        }
        .sheet(isPresented: $showingDrinkView) {
            DrinkView()
        }
    }
    
    func deleteRecord(at offsets:IndexSet) {
        print("called")
        for offset in offsets {
            let record = records[offset]
            record.mr_deleteEntity()
        }
        MagicalRecord.save({ _ in })
    }
}

DeleteWaterRecord

まとめ:SwiftUI/CoreData/MagicRecordをまとめて使う

MagicRecordの良い点は、NSManagedObjectContextを内部で渡してくれていて、都度都度引数として渡す必要がない点だと思うのですが、SwiftUIと組み合わせて使うと、「SwiftUIがNSManagedObjectContextを共有しやすい仕組み(environment)を提供してくれている」、「FetchRequestの更新からViewの更新をしてくれる仕組みがある」という点で、MagicRecordの良い点が減ってしまっている気がします。

そうは言っても、おまじないのようなコードなしにEntityを追加出来たりするのは、良いですよね。

しばらく並行して使ってみます。

SwiftUI本

少しづつ慣れてきてはいますが、やはりViewやLayoutのための適切なmodifierを探すのが大変です。
”SwiftUI Views Mastery Bundle"という本がビジュアル的に確認して探せるので、超便利です。日本語版でないかなぁ・・・

SwiftUIViewsMastery

コメントを残す

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