SwiftUI - CoreDataを使ってTodoアプリを作成してみる

1. はじめに

ここでは、CoreDataを使用して簡易的なTodoアプリを作成してみたので、まとめておこうと思います。ファイル構成と役割はざっくりとですが下記のようにしています。

  • AppNameApp.swift - デフォルトのまま
  • ContentView.swift - まとめて表示
  • AddView.swift - タスクの追加、モーダルビューで表示
  • RowView.swift - リストの1行、Booleanの切り替え

2. AppName.xcdatamodeldの設定

「Entity」

  • Task

「Attribute - Type」

  • id - UUID
  • name - String
  • isComplete - Boolean
  • timestamp - Date

3. Persistence.swift

xcdatamodeldの設定が完了したらプレビュー用の初期設定をします。デフォルトテンプレートのfor _ in 0..<10 {...}の部分を削除して下記のように変更します。

struct PersistenceController {
  static let shared = PersistenceController()
  
  static var preview: PersistenceController = {
    let result = PersistenceController(inMemory: true)
    let viewContext = result.container.viewContext
    
    //変更
    let newTask = Task(context: viewContext)
    newTask.id = UUID()
    newTask.name = "Hello Todo!"
    newTask.isComplete = false
    newTask.timestamp = Date()
    
    do {
      try viewContext.save()
    } catch {
      let nsError = error as NSError
      fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
    }
    return result
  }()

...

}

4. ContentView.swift

alt

ContentViewを下記ように記述したら、おそらくエラーが出ていると思うので、一度XCodeを再起動してクリーンビルドをします。そうするとエラーが消え、画像のような表示になると思います。

import SwiftUI
import CoreData

struct ContentView: View {
  @Environment(\.managedObjectContext) private var viewContext
  
  @FetchRequest(
    entity: Task.entity(),
    sortDescriptors: [NSSortDescriptor(keyPath: \Task.timestamp, ascending: true)]
  )private var tasks: FetchedResults<Task>
  
  var body: some View {
    VStack {
      NavigationView {
        List {
          ForEach(tasks){ task in
            Text(task.name ?? "")
          }
        }
        .padding(.vertical)
        .navigationTitle("Todo")
      }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
  }
}

xxx ?? yyy - nil合体演算子

5. RowView.swift

リストの1行を表示させるRowViewというファイルを作成し下記を記述します。このファイルは動的な部分でプレビューを表示できないのでPreviewProviderは削除します。ここでは@ObservedObjectTaskの呼び出しをして、リストの1行をクリックするとisCompleteの値を切り替えるということをしています。

import SwiftUI

struct RowView: View {
  @Environment(\.managedObjectContext) private var viewContext
  @ObservedObject var task: Task
  
  var body: some View {
    Button(action: {
      task.isComplete.toggle()
      do {
        try viewContext.save()
      } catch {
        print(error)
      }
    }) {
      HStack {
        Image(systemName: task.isComplete ? "largecircle.fill.circle" : "circle")
          .foregroundColor(.blue)
        Text(task.name ?? "")
          .strikethrough(task.isComplete ? true : false)
          .padding(.horizontal)
      }
    }
  }
}

.strikethrough() - テキスト取り消し線

alt

RowViewを作成したらContentViewを下記のように変更します。これでタスクの完了未完了の切り替えができるようになります。

struct ContentView: View {
  
  ...
  
  var body: some View {
    VStack {
      NavigationView {
        List {
          ForEach(tasks){ task in
            RowView(task: task) //<<変更
          }
        }
        .padding(.vertical)
        .navigationTitle("Todo")
      }
    }
  }
}

6. AddView.swift

AddViewというファイルを作成して下記を記述します。Todoリストに新しいタスクを追加できるようにTextFieldを作成し、そこで入力した値をSaveボタンを押すことでCoreDataに保存するようにしています。TextFieldが空の場合でも、タスクが追加できてしまうのでTextFieldが空でない場合のみSaveボタンを押せるようにしています。

import SwiftUI

struct AddView: View {
  @Environment(\.managedObjectContext) private var viewContext
  @State private var taskName = ""
  
  var body: some View {
    VStack {
      HStack {
        Spacer()
        //TextFieldが空のときは、追加できないようにする処理
        if (taskName.isEmpty) {
          Text("Save")
            .opacity(0.3)
        } else {
          Button("Save", action: {self.addTask()})
        }
      }
      TextField("Input", text: $taskName)
        .textFieldStyle(RoundedBorderTextFieldStyle())
    }
  }
  
  func addTask() {
    let newTask = Task(context: viewContext)
    newTask.id = UUID()
    newTask.name = taskName
    newTask.isComplete = false
    newTask.timestamp = Date()
    
    do {
      try viewContext.save()
    } catch {
      print(error)
    }
  }
}

struct AddView_Previews: PreviewProvider {
  static var previews: some View {
    AddView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
  }
}

7. モーダルビューでAddView.swiftを表示させる

alt

ContentViewからモーダルビューでAddViewを表示できるようAddViewに下記を追加します。モーダルビューを閉じるキャンセルボタンや保存したあとにモーダルビューを閉じる処理を加えています。

struct AddView: View {
  @Environment(\.managedObjectContext) private var viewContext
  @Environment(\.presentationMode) var presentationMode //<<追加
  @State private var taskName = ""
  
  var body: some View {
    VStack {
      HStack {
        Button("Cancel", action: {presentationMode.wrappedValue.dismiss()}) //<<追加
        Spacer()
        if (taskName.isEmpty) {
          Text("Save")
            .opacity(0.3)
        } else {
          Button("Save", action: {self.addTask()})
        }
      }
      TextField("Input", text: $taskName)
        .textFieldStyle(RoundedBorderTextFieldStyle())
    }
  }
  
  func addTask() {
    let newTask = Task(context: viewContext)
    newTask.id = UUID()
    newTask.name = taskName
    newTask.isComplete = false
    newTask.timestamp = Date()
    
    do {
      try viewContext.save()
      presentationMode.wrappedValue.dismiss() //<<追加
    } catch {
      print(error)
    }
  }
}

struct AddView_Previews: PreviewProvider {
  static var previews: some View {
    AddView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
  }
}

alt

次にContentViewに下記を追加してAddViewを表示できるようにします。これでモーダルビューの開閉とモーダルビューからタスクを追加できるようになります。

struct ContentView: View {
  
  ...
  
  @State var isSheetShow: Bool = false //<<追加
  
  var body: some View {
    VStack {
      NavigationView {
        List {
          ForEach(tasks){ task in
            RowView(task: task)
          }
        }
        .padding(.vertical)
        .navigationTitle("Todo")
      }
      //vv追加
      Button(action: {isSheetShow.toggle()}) {
        HStack {
          Image(systemName: "plus.circle.fill")
          Text("Add")
        }
      }
      .sheet(isPresented: $isSheetShow) {AddView()}
    }
  }
}

8. タスクを削除できるようにする

alt

最後に、タスクを削除できるようContentViewに下記を追加します。これで削除したいタスクを左にスワイプすると削除ボタンが表示され、削除できるようになります。Canvas上では問題なかったのですが、シュミレータでテストしたところタスクを削除するとアプリが落ちるというトラブルがあったので調べてみたところApple Developer Forumsに解決策が書いてありました。システムバグのようでNSManagedObjectContext.perform {...}を追加すると良いようです。


...

  var body: some View {
    VStack {
      NavigationView {
        List {
          ForEach(tasks){ task in
            RowView(task: task)
          }
          .onDelete(perform: delTask) //<<追加
        }
        .padding(.vertical)
        .navigationTitle("Todo")
      }
      Button(action: {isSheetShow.toggle()}) {
        HStack {
          Image(systemName: "plus.circle.fill")
          Text("Add")
        }
      }
      .sheet(isPresented: $isSheetShow) {AddView(isSheetShow: $isSheetShow)}
    }
  }
  //vv追加 NSManagedObjectContext.perform {} - システムバグ対応
  func delTask(offsets: IndexSet) {
    viewContext.perform {
      offsets.map { tasks[$0] }.forEach(viewContext.delete)
      do {
        try viewContext.save()
      } catch {
        print(error)
      }
    }
  }
}

How to make a task list using SwiftUI and Core Data - DEV Community
Optional | Apple Developer Documentation
strikethrough(_:color:) | Apple Developer Documentation