SwiftUI极简教程20:CoreData数据持久化框架的使用(上)

5,585 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第20天,点击查看活动详情

今日职言:深入思考和坚持同等重要。

在本章中,你将学会使用CoreData数据持久化框架搭建一个简单的ToDo待办事项App

12.png

我们在之前的学习中构建过List列表和SwipeCard卡片,我们发现如果我们重新启动模拟器,它的数据会恢复原始数组的数据。这是因为每次打开App的时候,系统会根据数据源重新遍历数据,当用户关闭应用程序并重新启动时,所有数据都“消失”了。

那么本章我们学习一个新的框架,叫做CoreData,它一个管理数据对象的框架,可以将我们的数据保存起来,这样每当我们重新打开App的时候,App展示的就是我们上一次操作的数据。

值得注意的一点是,CoreData可不是数据库哦,它只是一个用于开发人员管理和存储数据持久化的交互框架,它的持久存储并不局限于数据库。

好了,说了那么多,让我们正式开始吧。

首先,创建一个新项目,命名为SwiftUICoreData,请注意,这里我们需要勾选使用CoreData

2.png

CoreData框架数据持久化实现原理

我们发现,和以往创建的App不同,这次多了几个文件。

一个是SwiftUICoreData.xcdatamodeld文件,它是管理整个项目生成的对象模型的,是定义实体与持久存储交互的文件。

另一个是Persistence.swift文件,它是数据保存到持久存储区的文件。

3.png

SwiftUI通过将管理对象上下文viewContext注入到环境中,来实现在任何视图都可以检索上下文,并且能够管理数据

我们再看一下SwiftUICoreDataApp.swift文件,可以看到它定义了一个常量persistenceController来保存PersistenceController的实例,并在ContentView主视图中将托管对象上下文viewContext注入到环境中。

4.png

上面我们看到已经在管理对象模型中创建实体了,并且定义一个继承自NSManagedObject的管理对象来与实体关联。

我们回到ContentView.swift文件,可以看到系统生成了一堆的示例代码,让我们解读一下。

首先使用了@Environment环境变量从环境中获取托管对象上下文viewContext

@Environment(\.managedObjectContext) var context

然后创建管理对象,并使用context上下文的save方法将对象添加到数据库中:

//示例代码
let task = ToDoItem(context: context)

task.id = UUID()
task.name = name
task.priority = priority
task.isCompleted = isCompleted

数据检索方面,我们引入了一个名为@FetchRequest的属性包装器,用于从持久存储中获取数据。它可以指定要检索的实体对象以及数据的排序方式,然后,CoreData框架就可以将使用@Environment环境的托管对象上下文context来获取数据。

@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],animation: .default)
    private var items: FetchedResults<Item>

好了,以上就是CoreData框架数据持久化实现的原理,我们可以预览下系统提供的例子。

5.png

下面,让我们进入正题。

ToDoItem类准备

首先,我们需要定义一个模型类,我们可以创建一个新的文件,点击Xcode顶部导航栏,File文件,New新建,选择File创建文件,选择iOS中的Swift File类型的文件,命名为ToDoItem.swift

然后我们构建需要App需要的参数。

我们先构建一个枚举类型Priority,来表示我们任务的优先级,分别是低、中、高、最高,用数值Int类型表示权重。

//任务紧急程度的枚举
enum Priority: Int {
    case low = 0
    case normal = 1
    case high = 2
}

然后定义一个类ToDoItem遵循ObservableObject可被观察对象协议和Identifiable可被识别协议,在ToDoItem类里面有三个参数:name名称、priority优先级、isCompleted是否完成。并且在ObservableObject协议需要使用@Published定义,这样才能在参数改变的时候检测到变化

至于遵循Identifiable协议就不用说了,我们定义id作为每一个任务项的唯一标识符,这样即便是相同名称、相同优先级的任务,系统也不会把它们作为同一个,这个我们之前的章节讲过。

//ToDoItem遵循ObservableObject协议
class ToDoItem: ObservableObject, Identifiable {
    var id = UUID()
    @Published var name: String = ""
    @Published var priority: Priority = .high
    @Published var isCompleted: Bool = false

    //实例化
    init(name: String, priority: Priority = .normal, isCompleted: Bool = false) {
        self.name = name
        self.priority = priority
        self.isCompleted = isCompleted
    }
}

我们回到ContentView.swift文件,我们看看需要做哪些东西。

TopBarMenu顶部导航栏

首先是TopBarMenu顶部导航栏,比较简单,在这里就不赘述了。

//顶部导航栏
struct TopBarMenu: View {
    var body: some View {

        HStack {
            Text("待办事项")
                .font(.system(size: 40, weight: .black))

            Spacer()

            Button(action: {

            }) {
                Image(systemName: "plus.circle.fill")
                    .font(.largeTitle).foregroundColor(.blue)
            }
        }
        .padding()
    }
}

6.png

中间的内容部分,我们可以看到有两种情况,一种是没有数据的时候,我们展示一张Image图片,另一种是有数据的时候,展示List数据列表。

NoDataView缺省页

我们导入一张图片,命名叫做image01,然后构建第一种空数据的情况,业务上常常叫做缺省页的图。

//缺省图
struct NoDataView: View {
    var body: some View {
    
        Image("image01")
            .resizable()
            .scaledToFit()
    }
}

7.png

如果List列表有数据的时候,我们需要展示列表数据,接下来,我们完成下List的创建。

ToDoListView列表页创建

之前的章节我们了解过List列表的创建方式,这里我们先构建单个任务项ToDoListRow视图的样式,然后使用List列表+ForrEach循环的方法构建整个列表ToDoListView

// 列表
struct ToDoListView: View {

    @Binding var todoItems: [ToDoItem]

    var body: some View {
    
        List {
            ForEach(todoItems) { todoItem in
                ToDoListRow(todoItem: todoItem)
            }
        }
    }
}

// 列表内容
struct ToDoListRow: View {

    @ObservedObject var todoItem: ToDoItem

    var body: some View {
    
        Toggle(isOn: self.$todoItem.isCompleted) {
            HStack {

                Text(self.todoItem.name)
                    .strikethrough(self.todoItem.isCompleted, color: .black)
                    .bold()
                    .animation(.default)

                Spacer()

                Circle()
                    .frame(width: 20, height: 20)
            }
        }
    }
}

8.png

我们在ToDoListView列表视图使用@Binding(图中有误)声明了一个todoItems状态,用来存储ToDoItem数组,当数据变化时就刷新页面。

//ContentView视图

VStack {
    TopBarMenu()
    ToDoListView(todoItems: $todoItems)
}

然后我们在ToDoListRow视图使用@ObservableObject声明了一个todoItem,用来引用定义好的实例化方法。

对于ToDoListRow单个任务项的视图,里面也比较简答,我们用了一个Toggle开关作为复选框,再加上一个Text文字作为待办事项的内容标题,最后我们还用了一个Circle圆形的形状,作为priority标识。

priority标识我们可以定义一个私有的颜色方法,当我们从Priority枚举类型中获得不同状态时,返回不同的颜色,比如优先级高显示红色一般优先级显示橘色低优先级显示绿色

// 根据优先级显示不同颜色
private func color(for priority: Priority) -> Color {

    switch priority {
        case .high:
            return .red
        case .normal:
            return .orange
        case .low:
            return .green
        }
    }

定义好方法后,我们将Circle圆形赋予背景颜色,颜色值调用priority定义颜色方法。

.foregroundColor(self.color(for: self.todoItem.priority))

然后对于Toggle开关,我们希望用的是checkbox复选框的样式,还记得之前的章节中我们用ButtonStyle修改Button按钮的样式么?

是的,Toggle开关也支持自定义样式的方式,我们可以用ToggleStyle开关样式把Toggle开关变成checkbox复选框。

// checkbox复选框样式
struct CheckboxStyle: ToggleStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        return HStack {
        
            Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle")
                .resizable()
                .frame(width: 24, height: 24)
                .foregroundColor(configuration.isOn ? .purple : .gray)
                .font(.system(size: 20, weight: .bold, design: .default))
                .onTapGesture {
                    configuration.isOn.toggle()
                }
            configuration.label
        }
    }
}

然后,我们给Toggle开关添加.toggleStyle开关样式修饰符就可以将自定义好的样式加到里面了。

.toggleStyle(CheckboxStyle())

9.png

以上,我们完成了空的列表NoDataView缺省页,还有有数据时的列表ToDoListView待办事项列表,当然现在ToDoListView待办事项列表还没有数据,别急,我们慢慢来。

页面展示逻辑判断

那么什么时候展示NoDataView缺省页视图,什么时候展示ToDoListView待办事项列表视图呢?

当然是todoItems没有数据的时候展示NoDataView缺省页视图,todoItems有数据的时候展示ToDoListView待办事项列表视图。

我们就可以把这个判断加到ContentView主视图里面。

if todoItems.count == 0 {
    NoDataView()
}

最后,在ContentView主视图布局部分,我们将TopBarMenu顶部导航栏、ToDoListView待办事项列表用VStack垂直排布在一起,然后使用ZStack层叠视图将NoDataView缺省页视图包裹在一起看看效果。

//主视图
struct ContentView: View {

    @State var todoItems: [ToDoItem] = []

    var body: some View {

        ZStack {
            VStack {
                TopBarMenu()
                ToDoListView()
            }

            if todoItems.count == 0 {
                NoDataView()
            }
        }
    }
}

10.png

嗯?为啥List列表会有背景颜色?这是iOS14的新特性,如果我们需要去掉这个颜色,需要再做一下处理,在视图加载的时候,将TableView列表和TableViewCell列表项的背景颜色变成无填充颜.clear

//去掉Listb背景颜色

init() {
    UITableView.appearance().backgroundColor = .clear
    UITableViewCell.appearance().backgroundColor = .clear
}

这样,我们就完成了列表展示页的制作。

11.png

由于章节篇幅太长,将分为上下两章来写,上半部分先完成主要页面的构建,下半部分我们再完成NewToDoView新增任务项页面和基于CoreData框架数据持久化的逻辑部分。

本章完整代码如下:

//ToDoItem.swift

import Foundation

enum Priority: Int {
    case low = 0
    case normal = 1
    case high = 2
}

class ToDoItem: ObservableObject, Identifiable {
    var id = UUID()
    @Published var name: String = ""
    @Published var priority: Priority = .high
    @Published var isCompleted: Bool = false

    init(name: String, priority: Priority = .normal, isCompleted: Bool = false) {
        self.name = name
        self.priority = priority
        self.isCompleted = isCompleted
    }
}
//ContentView.swift

import CoreData

import SwiftUI

struct ContentView: View {

    @State var todoItems: [ToDoItem] = []

    //去掉Listb背景颜色
    init() {
        UITableView.appearance().backgroundColor = .clear
        UITableViewCell.appearance().backgroundColor = .clear
    }

    var body: some View {

        ZStack {
            VStack {
                TopBarMenu()
                ToDoListView(todoItems: $todoItems)
            }

            if todoItems.count == 0 {
                NoDataView()
            }
        }
    }
}

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

// 顶部导航栏
struct TopBarMenu: View {
    var body: some View {

        HStack {
            Text("待办事项")
                .font(.system(size: 40, weight: .black))

            Spacer()

            Button(action: {

            }) {
                Image(systemName: "plus.circle.fill")
                    .font(.largeTitle).foregroundColor(.blue)
            }
        }
        .padding()
    }
}

// 缺省图
struct NoDataView: View {
    var body: some View {
        Image("image01")
            .resizable()
            .scaledToFit()
    }
}

// 列表
struct ToDoListView: View {

    @Binding **var** showNewTask: Bool

    var body: some View {

        List {
            ForEach(todoItems) { todoItem in
                ToDoListRow(todoItem: todoItem)
            }
        }
    }
}

// 列表内容
struct ToDoListRow: View {

    @ObservedObject var todoItem: ToDoItem

    var body: some View {

        Toggle(isOn: self.$todoItem.isCompleted) {
            HStack {

                Text(self.todoItem.name)
                    .strikethrough(self.todoItem.isCompleted, color: .black)
                    .bold()
                    .animation(.default)
                    
                Spacer()
                
                Circle()
                    .frame(width: 20, height: 20)
                    .foregroundColor(self.color(for: self.todoItem.priority))
            }
        }.toggleStyle(CheckboxStyle())
    }

    // 根据优先级显示不同颜色
    private func color(for priority: Priority) -> Color {
        switch priority {
        case .high:
            return .red
        case .normal:
            return .orange
        case .low:
            return .green
        }
    }
}

// checkbox复选框样式
struct CheckboxStyle: ToggleStyle {

    func makeBody(configuration: Self.Configuration) -> some View {
        return HStack {

            Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle")
                .resizable()
                .frame(width: 24, height: 24)
                .foregroundColor(configuration.isOn ? .purple : .gray)
                .font(.system(size: 20, weight: .bold, design: .default))
                .onTapGesture {
                    configuration.isOn.toggle()
                }
            configuration.label
        }
    }
}

快来动手试试吧!

如果本专栏对你有帮助,不妨点赞、评论、关注~