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

1,906 阅读7分钟

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

今日职言:外表干净是尊重别人,内心干净是尊重自己,言行干净是尊重灵魂。

承接上一章的内容,我们继续完成使用CoreData框架搭建一个简单的ToDo待办事项App

这一章节,我们完成一下NewToDoView新建事项页面。

1.png

我们先新建一个新页面,命名为NewToDoView

点击Xcode顶部导航栏,File文件,New新建,选择File创建文件,选择iOS中的SwiftUI File类型的文件,命名为NewToDoView.swift

2.png

页面UI设计

我们还是从上往下构建UI页面。

TopNavBar顶部导航栏视图

3.png

首先是TopNavBar顶部导航栏,名称不能和之前创建的重复,它由一个Text标题一个closeButton关闭按钮组成。

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

        HStack {
            Text("新建事项")
                .font(.system(.title))
                .bold()

            Spacer()

            Button(action: {

            }) {
                Image(systemName: "xmark.circle.fill")
                    .foregroundColor(.gray)
                    .font(.title)
            }
        }
    }
}

4.png

InputNameView输入框视图

然后是事项名称的输入框TextField

TextField输入框需要有两个参数绑定,一个是内容绑定,即我们TextField输入框需要记录什么内容。第二个是isEditing输入状态绑定,帮助我们检测它是否正在输入,后面我们会用到输入的状态的检测。

我们在NewToDoView视图中,使用@State声明两个变量。

@State var name: String
@State var isEditing = false

然后我们再构建InputNameView输入框视图的内容,再绑定参数。

//输入框
struct InputNameView: View {

    @Binding var name: String
    @Binding var isEditing: Bool

    var body: some View {

        TextField("请输入", text: $name, onEditingChanged: { (editingChanged) in

            self.isEditing = editingChanged

        })
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(8)
            .padding(.bottom)
    }
}

最后在NewToDoView视图中展示InputNameView输入框视图的内容,这里用VStack垂直排布将InputNameView输入框视图和TopNavBar顶部导航栏排在一起。

VStack {
    TopNavBar()
    InputNameView(name: $name, isEditing: $isEditing)
}

由于我们NewToDoView视图需要预览,因此要想在模拟器中看到效果,还需要在NewToDoView_Previews预览视图中添加参数。

运行一下,我们看下效果。

5.png

下面我们继续,接下来是事项的优先级选择,我们先完成UI的部分。

PrioritySelectView优先级选择视图

我们命名一个PrioritySelectView优先级选择视图,这里当然也可以用代码整合的方式减少下代码量,我们将相同的修饰符抽离出来,然后再在PrioritySelectView优先级选择视图展示内容。

// 选择优先级
struct PrioritySelectView: View {
    var body: some View {

        HStack {
            PrioritySelectRow(name: "高", color: Color.red)
            PrioritySelectRow(name: "中", color: Color.orange)
            PrioritySelectRow(name: "低", color: Color.green)
        }
    }
}

// 选择优先级
struct PrioritySelectRow: View {

    var name: String
    var color:Color

    var body: some View {
    
        Text(name)
            .frame(width: 80)
            .font(.system(.headline))
            .padding(10)
            .background(color)
            .foregroundColor(.white)
            .cornerRadius(8)
    }
}

我们把PrioritySelectView加到NewToDoView视图中看下效果。

VStack {
    TopNavBar()
    InputNameView(name: $name, isEditing: $isEditing)
    PrioritySelectView()
}

6.png

SaveButton保存按钮视图

接下来是SaveButton保存按钮的绘制,我们让按钮下面留点底边距。

我们也加进去NewToDoView视图看看效果。

// 保存按钮
struct SaveButton: View {
    var body: some View {

        Button(action: {

        }) {

            Text("保存")
                .font(.system(.headline))
                .frame(minWidth: 0, maxWidth: .infinity)
                .padding()
                .foregroundColor(.white)
                .background(Color.blue)
                .cornerRadius(8)
        }
        .padding([.top,.bottom])
    }
}

7.png

NewToDoView页面位置调整

然后,我们调整下位置,我们希望这个NewToDoView页面是从底部弹出来,然后内容也都在底部展示而不是居中,我们可以调整下整个NewToDoView页面的位置。

我们用VStackSpacerNewToDoView视图顶到底部,然后根据InputNameView输入框是否处于输入状态isEditing,来进行偏移,也就是当我们点击InputNameView输入框正在输入的时候,整个视图可以向上移动,这样我们的keyboard键盘输入就有位置正对应了,这是一个小技巧

然后整个NewToDoView页面页面我们再置底一点,使用.edgesIgnoringSafeArea安全区域空出底部区域,这样好看很多。

VStack {

    Spacer()

    VStack {
        TopNavBar()
        InputNameView(name: $name, isEditing: $isEditing)
        PrioritySelectView()
        SaveButton()
    }
    .padding()
    .background(Color.white)
    .cornerRadius(10, antialiased: true)
    .offset(y: isEditing ? -320 : 0)

}.edgesIgnoringSafeArea(.bottom)

8.png

交互逻辑设计

好了,我们完成了NewToDoView页面的绘制了,下面是逻辑部分。

PrioritySelectView优先级选择逻辑

首先是我们的PrioritySelectView优先级的选择,我们希望点击选择哪个优先级,哪个优先级就“亮起”,这样我们好知道选中的是哪一个。

9.png

同样,我们需要储存priority优先级状态,priority优先级是存储在NewToDoView新增事项页面里的,这里用@State状态。

//NewToDoView视图中定义
@State var priority: Priority

然后,我们完善下PrioritySelectView优先级的选择页面,根据选中状态展示背景颜色,如果没选中,我们就变成.systemGray4灰色。

// 选择优先级
struct PrioritySelectView: View {

    @Binding var priority: Priority

    var body: some View {

        HStack {

            PrioritySelectRow(name: "高", color: priority == .high ? Color.red : Color(.systemGray4))
                .onTapGesture { self.priority = .high }

            PrioritySelectRow(name: "中", color: priority == .normal ? Color.orange : Color(.systemGray4))
                .onTapGesture { self.priority = .normal }

            PrioritySelectRow(name: "低", color: priority == .low ? Color.green : Color(.systemGray4))
                .onTapGesture { self.priority = .low }
        }
    }
}

我们完善下NewToDoView视图的绑定关系,顺便给个示例数据预览下模拟器结果。

struct NewToDoView: View {

    @State var name: String
    @State var isEditing = false
    @State var priority: Priority

    var body: some View {

        VStack {

            Spacer()

            VStack {
                TopNavBar()
                InputNameView(name: $name, isEditing: $isEditing)
                PrioritySelectView(priority: $priority)
                SaveButton()
            }
            .padding()
            .background(Color.white)
            .cornerRadius(10, antialiased: true)
            .offset(y: isEditing ? -320 : 0)

        }.edgesIgnoringSafeArea(.bottom)
    }
}

struct NewToDoView_Previews: PreviewProvider {
    static var previews: some View {
        NewToDoView(name: "", todoItems: .constant([]), priority: .normal)
    }
}

10.png

页面弹出逻辑

让我们回到ContentView首页,我们将两个页面联动起来。

11.png

页面弹出的交互逻辑是,当我们点击ContentView首页右上角的添加按钮时,打开NewToDoView新增事项页面。

明白了逻辑之后,我们现在ContentView首页写逻辑,先声明一个变量showNewTask,表示我们是否打开了NewToDoView新增事项页面,默认是false

@State private var showNewTask = false
@State private var offset: CGFloat = .zero    //使用.animation防止报错,iOS15的特性

如果showNewTask状态为true时,我们显示NewToDoView新增事项页面,我们可以把NewToDoView新增事项页面放在ContentView首页的ZStack包裹着。

//点击添加时打开弹窗
if showNewTask {
    NewToDoView(name: "", priority: .normal)
        .transition(.move(edge: .bottom))
        .animation(.interpolatingSpring(stiffness: 200.0, damping: 25.0, initialVelocity: 10.0),value: offset)
    }

然后我们增加点击事件,当我们在ContentView首页点击添加按钮的时候,showNewTask状态变为为true

// 顶部导航栏
struct TopBarMenu: View {

    @Binding var showNewTask: Bool

    var body: some View {

        HStack {

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

            Spacer()

            Button(action: {

                //打开弹窗
                self.showNewTask = true

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

TopBarMenu(showNewTask: $showNewTask)

12.png

好像基本完成了效果,但由于我们是使用ZStack包裹的方式,而不是用ModelView模态弹窗或者 NavigationView导航栏进入新的页面,所以我们还需要做一个MaskView蒙层遮住背景,让它看起来像是弹窗的效果。

MaskView蒙层逻辑

//蒙层
struct MaskView : View {

    var bgColor: Color

    var body: some View {

        VStack {

            Spacer()

        }
        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
        .background(bgColor)
        .edgesIgnoringSafeArea(.all)
    }
}

然后把MaskView蒙层加到打开NewToDoView新增事项页面的逻辑里,同时,支持我们点击MaskView蒙层关闭弹窗。

//蒙层
MaskView(bgColor: .black)
    .opacity(0.5)
    .onTapGesture {
        self.showNewTask = false
    }

13.png

好!我们实现了怎么弹出NewToDoView新增事项页面,我们回到NewToDoView.swift文件,我们实现如何点击关闭弹窗。

页面关闭逻辑

NewToDoView新增事项页面关闭有两种,一种是点击关闭按钮关闭弹窗。

// 顶部导航栏
struct TopNavBar: View {

    @Binding var showNewTask: Bool

    var body: some View {

        HStack {
            Text("新建事项")
                .font(.system(.title))
                .bold()

            Spacer()

            Button(action: {

                //关闭弹窗
                self.showNewTask = false
            }) {

                Image(systemName: "xmark.circle.fill")
                    .foregroundColor(.gray)
                    .font(.title)
            }
        }
    }
}
//NewToDoView视图
struct NewToDoView: View {

    @State var name: String
    @State var isEditing = false
    @State var priority: Priority
    @Binding var showNewTask: Bool

    var body: some View {

        VStack {

            Spacer()

            VStack {
                TopNavBar(showNewTask: $showNewTask)
                InputNameView(name: $name, isEditing: $isEditing)
                PrioritySelectView(priority: $priority)
                SaveButton()
            }
            .padding()
            .background(Color.white)
            .cornerRadius(10, antialiased: true)
            .offset(y: isEditing ? -320 : 0)

        }.edgesIgnoringSafeArea(.bottom)
    }
}

14.png

我们发现系统报错了,这是因为我们使用@Binding绑定了是否展示页面showNewTask的布尔值,还需要在ContentView首页建立关联。

//ContentView视图

NewToDoView(name: "", priority: .normal, showNewTask: $showNewTask)

15.png

这样,我们就完成了第一种关闭弹窗的交互:点击关闭按钮关闭弹窗。

另一种关闭弹窗的交互是,我们新建一个事项,满足条件后(内容不为空),这是我们点击saveButton保存按钮,关闭弹窗

我们再回到NewToDoView.swift文件。首先我们保存要校验下InputNameView输入框内容是否为空为空的时候我们不关闭弹窗。当InputNameView输入框内容不为空的时候,我们才允许关闭弹窗

// 保存按钮
struct SaveButton: View {

    @Binding var name:String
    @Binding var showNewTask: Bool

    var body: some View {

        Button(action: {

            //判断输入框是否为空
            if self.name.trimmingCharacters(in: .whitespaces) == "" {
                return
            }

            //关闭弹窗
            self.showNewTask = false

        }) {

            Text("保存")
                .font(.system(.headline))
                .frame(minWidth: 0, maxWidth: .infinity)
                .padding()
                .foregroundColor(.white)
                .background(Color.blue)
                .cornerRadius(8)
        }
        .padding([.top,.bottom])
    }
}
//NewToDoView视图

SaveButton(name: $name, showNewTask: $showNewTask)

我们回到ContentView.swift文件中,运行模拟器体验下。

我们完成了基础的关闭弹窗操作,可以点击关闭按钮关闭,也可以输入新建事项后,点击保存关闭弹窗。

16.png

添加新事项逻辑

我们在NewToDoView添加完事项后,输入的内容和选择的优先级就会在ContentView首页List列表中创建一条数据,下面我们来完成添加新事项逻辑。

17.png

我们看回NewToDoView.swift文件,我们实现了有输入内容时,点击保存按钮关闭弹窗,但没有实现addTask新增数据,下面我们来实现它。

// 保存按钮
struct SaveButton: View {

    @Binding var name:String
    @Binding var showNewTask: Bool
    @Binding var todoItems: [ToDoItem]
    @Binding var priority:Priority

    var body: some View {

        Button(action: {

            //判断输入框是否为空
            if self.name.trimmingCharacters(in: .whitespaces) == "" {
                return
            }

            //添加一条新数据
            self.addTask(name: self.name, priority: self.priority)

            //关闭弹窗
            self.showNewTask = false

        }) {

            Text("保存")
                .font(.system(.headline))
                .frame(minWidth: 0, maxWidth: .infinity)
                .padding()
                .foregroundColor(.white)
                .background(Color.blue)
                .cornerRadius(8)
        }
        .padding([.top,.bottom])
    }

    //添加新事项方法
    private func addTask(name: String, priority: Priority, isCompleted: Bool = false) {

        let task = ToDoItem(name: name, priority: priority, isCompleted: isCompleted)
        todoItems.append(task)
    }
}

我们定义一个addTask添加事项的private私有方法,添加的参数是name内容、priority优先级、isCompleted是否完成,默认为否false。然后实例化它,调用方法的时候在 todoItems数组中增加一条数据。然后,我们点击SaveBotton保存成功时调用addTask添加新事项方法。

//NewToDoView视图

struct NewToDoView: View {

    @State var name: String
    @State var isEditing = false
    @State var priority: Priority
    @Binding var showNewTask: Bool
    @Binding var todoItems: [ToDoItem]

    var body: some View {

        VStack {

            Spacer()

            VStack {
                TopNavBar(showNewTask: $showNewTask)
                InputNameView(name: $name, isEditing: $isEditing)
                PrioritySelectView(priority: $priority)
                SaveButton(name: $name, showNewTask: $showNewTask, todoItems: $todoItems, priority: $priority)
            }
            .padding()
            .background(Color.white)
            .cornerRadius(10, antialiased: true)
            .offset(y: isEditing ? -320 : 0)

        }.edgesIgnoringSafeArea(.bottom)
    }
}

struct NewToDoView_Previews: PreviewProvider {
    static var previews: some View {
        NewToDoView(name: "", priority: .normal, showNewTask: .constant(true), todoItems: .constant([]))
    }
}

同时,我们在NewToDoView视图绑定关联关系,并在NewToDoView_Previews预览视图中也绑定好关系。

当然别忘了,还要在 ContentView首页视图绑定参数。

// ContentView视图

NewToDoView(name: "", priority: .normal, showNewTask: $showNewTask, todoItems: $todoItems)

恭喜你,我们就完成了ContentView首页视图和NewToDoView新建事项视图的全部交互逻辑!

未完待续

但还没有全部完成,我们只是完成了一个简单的ToDo待办事项的App,还没有实现CoreData数据持久化。

由于篇幅过长,上篇我们完成了ContentView首页视图的制作,中篇我们完成NewToDoView新建事项视图的制作,当然还有他们之间的交互

CoreData数据持久化框架的使用将再分出下篇,我们看看如何使用CoreData数据持久化框架,真正实现一个可以保存数据App

本章完整代码如下:

//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] = []
    @State private var showNewTask = false
    @State private var offset: CGFloat = .zero    //使用.animation防止报错,iOS15的特性

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

    var body: some View {

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

            //判断事项数量为0时展示缺省图
            if todoItems.count == 0 {
                NoDataView()
            }

            //点击添加时打开弹窗
            if showNewTask {

                //蒙层
                MaskView(bgColor: .black)
                    .opacity(0.5)
                    .onTapGesture {
                        self.showNewTask = false
                    }

                NewToDoView(name: "", priority: .normal, showNewTask: $showNewTask, todoItems: $todoItems)
                    .transition(.move(edge: .bottom))
                    .animation(.interpolatingSpring(stiffness: 200.0, damping: 25.0, initialVelocity: 10.0),value: offset)
                }
        }
    }
}

//蒙层
struct MaskView : View {

    var bgColor: Color

    var body: some View {

        VStack {

            Spacer()

        }
        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
        .background(bgColor)
        .edgesIgnoringSafeArea(.all)
    }
}

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

// 顶部导航栏
struct TopBarMenu: View {

    @Binding var showNewTask: Bool

    var body: some View {

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

            Spacer()

            Button(action: {

                //打开弹窗
                self.showNewTask = true
            }) {
            
                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 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)
                    .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
        }
    }
}
//  NewToDoView.swift

import SwiftUI

struct NewToDoView: View {

    @State var name: String
    @State var isEditing = false
    @State var priority: Priority
    @Binding var showNewTask: Bool
    @Binding var todoItems: [ToDoItem]

    var body: some View {

        VStack {

            Spacer()

            VStack {
                TopNavBar(showNewTask: $showNewTask)
                InputNameView(name: $name, isEditing: $isEditing)
                PrioritySelectView(priority: $priority)
                SaveButton(name: $name, showNewTask: $showNewTask, todoItems: $todoItems, priority: $priority)
            }
            .padding()
            .background(Color.white)
            .cornerRadius(10, antialiased: true)
            .offset(y: isEditing ? -320 : 0)
        }.edgesIgnoringSafeArea(.bottom)
    }
}

struct NewToDoView_Previews: PreviewProvider {
    static var previews: some View {
        NewToDoView(name: "", priority: .normal, showNewTask: .constant(true), todoItems: .constant([]))
    }
}

// 顶部导航栏
struct TopNavBar: View {

    @Binding var showNewTask: Bool

    var body: some View {

        HStack {
            Text("新建事项")
                .font(.system(.title))
                .bold()

            Spacer()

            Button(action: {

                //关闭弹窗
                self.showNewTask = false

            }) {
                Image(systemName: "xmark.circle.fill")
                    .foregroundColor(.gray)
                    .font(.title)
            }
        }
    }
}

//输入框
struct InputNameView: View {

    @Binding var name: String
    @Binding var isEditing: Bool

    var body: some View {
    
        TextField("请输入", text: $name, onEditingChanged: { (editingChanged) in

            self.isEditing = editingChanged

        })
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(8)
            .padding(.bottom)
    }
}

// 选择优先级
struct PrioritySelectView: View {

    @Binding var priority: Priority

    var body: some View {

        HStack {
            PrioritySelectRow(name: "高", color: priority == .high ? Color.red : Color(.systemGray4))
                .onTapGesture { self.priority = .high }

            PrioritySelectRow(name: "中", color: priority == .normal ? Color.orange : Color(.systemGray4))
                .onTapGesture { self.priority = .normal }
                
            PrioritySelectRow(name: "低", color: priority == .low ? Color.green : Color(.systemGray4))
                .onTapGesture { self.priority = .low }
        }
    }
}

// 选择优先级
struct PrioritySelectRow: View {

    var name: String
    var color:Color

    var body: some View {

        Text(name)
            .frame(width: 80)
            .font(.system(.headline))
            .padding(10)
            .background(color)
            .foregroundColor(.white)
            .cornerRadius(8)
    }
}

// 保存按钮
struct SaveButton: View {

    @Binding var name:String
    @Binding var showNewTask: Bool
    @Binding var todoItems: [ToDoItem]
    @Binding var priority:Priority

    var body: some View {

        Button(action: {

            //判断输入框是否为空
            if self.name.trimmingCharacters(in: .whitespaces) == "" {
                return
            }

            //添加一条新数据
            self.addTask(name: self.name, priority: self.priority)

            //关闭弹窗
            self.showNewTask = false

        }) {

            Text("保存")
                .font(.system(.headline))
                .frame(minWidth: 0, maxWidth: .infinity)
                .padding()
                .foregroundColor(.white)
                .background(Color.blue)
                .cornerRadius(8)
        }
        .padding([.top,.bottom])
    }

    //添加新事项方法
    private func addTask(name: String, priority: Priority, isCompleted: Bool = false) {

        let task = ToDoItem(name: name, priority: priority, isCompleted: isCompleted)
        todoItems.append(task)
    }
}

快来动手试试吧!

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