实战编程·使用SwiftUI从0到1完成一款iOS笔记App(五)

8,310 阅读10分钟

前提回顾

上一章节发布之后,有不少开发的童鞋评论:

本来好好的,经过上一章节的调整后就各个页面开始报错了?

这很正常,刚开始学习SwiftUI的时候,有时候改了一个参数,或者少了一个花括号,愣是找不到哪里写错了。

后面写多了就基本知道哪里需要调整,而且遇到Bug不可怕,就怕的是明明没有报错,而且是跟着项目教程来的,项目运行后就是没效果,也不知道哪里出错了。

找不到自己的薄弱点,这才是最可怕的。

因此不用太过于担心,本章我们将继续基于完成的ModelViewModel,来完成View相关的内容。

实战编程-首页

单条笔记

我们回到View文件夹下的ContentView视图,先从NoteListRow单条笔记视图开始调整。

单条笔记涵盖哪些交互逻辑?一个是点击单条笔记的时候打开编辑笔记弹窗,二是点击笔记右侧的“更多”按钮,唤起二次确认弹窗,并可进行操作删除。如下图所示:

在上一章搭建ViewModel功能的时候,我们说到对单条笔记进行操作需要获得笔记的ID,然后基于单条数据的ID进行操作。那么这里需要先获得笔记的ID,如下代码所示:

	// 引用viewModel
    @EnvironmentObject var viewModel: ViewModel

    // 获得项目唯一ID
    var itemId: UUID

    // 从模型类中找ID
    var item: NoteModel? {
        return viewModel.getItemById(itemId: itemId)
    }

上述代码中,我们先使用@EnvironmentObject全局环境变量引入ViewModel类,并赋值给viewModel。

@EnvironmentObject是一个动态视图属性,为了无论任何时候可绑定对象发生改变时停用当前视图的属性。

紧接着,声明一个变量itemId,遵循UUID格式,作为要使用到的ID。之前我们使用@ObservedObject获得NoteItem模型类,这里我们使用ViewModel就可以弃用原来的这块内容了,直接声明一个变量item,并通过调用viewModel中的getItemById方法获得对应的笔记ID。

在获得笔记ID后,系统可能无法返回相关的数据内容,也就是参数为空的情况导致系统报错,因此我们使用“?”,当返回的参数值为空的时候,就可以使用默认值填充,避免系统奔溃。

说回正题,由于我们使用item替换了原来的noteItem,在下面视图对应的参数也需要调整,如下代码所示:

    var body: some View {
        HStack {
            HStack {
                VStack(alignment: .leading, spacing: 10) {
                    Text(item?.writeTime ?? "")
                        .font(.system(size: 14))
                        .foregroundColor(.gray)
                    Text(item?.title ?? "")
                        .font(.system(size: 17))
                        .foregroundColor(.black)
                    Text(item?.content ?? "")
                        .font(.system(size: 14))
                        .foregroundColor(.gray)
                        .lineLimit(1)
                        .multilineTextAlignment(.leading)
                }
            }

            Spacer()

            // 更多操作
            Button(action: {
                
            }) {
                Image(systemName: "ellipsis")
                    .foregroundColor(.gray)
                    .font(.system(size: 23))
            }
        }
	}

上述代码中,替换单条笔记绑定的参数,使用item替换noteItem,替换如下:

  • noteItem.writeTime 替换为 item?.writeTime ?? ""
  • noteItem.title 替换为 item?.title ?? ""
  • noteItem.content 替换为 item?.content ?? ""

替换后,我们来实现两个基本功能,一个是点击笔记的时候,打开编辑笔记弹窗,如下代码所示:

    var body: some View {
        HStack {
            HStack {
                VStack(alignment: .leading, spacing: 10) {
                    Text(item?.writeTime ?? "")
                        .font(.system(size: 14))
                        .foregroundColor(.gray)
                    Text(item?.title ?? "")
                        .font(.system(size: 17))
                        .foregroundColor(.black)
                    Text(item?.content ?? "")
                        .font(.system(size: 14))
                        .foregroundColor(.gray)
                        .lineLimit(1)
                        .multilineTextAlignment(.leading)
                }
            }
            //点击编辑
            .onTapGesture {
                self.viewModel.isAdd = false
                self.viewModel.showEditNoteView = true
            }

            Spacer()

            // 更多操作
            Button(action: {
                viewModel.showActionSheet = true
            }) {
                Image(systemName: "ellipsis")
                    .foregroundColor(.gray)
                    .font(.system(size: 23))
            }
        }
	}

上述代码中,我们在笔记内容的HStack横向容器中增加了onTapGesture,当点击笔记内容的时候,说明我们需要编辑笔记,这里需要更新isAdd是否新增笔记状态为false,然后更新showEditNoteView打开编辑笔记弹窗的触发条件为true。

当用户点击“更多”操作时,更新showActionSheet打开二次确认弹窗的触发条件为true。

然后我们完成打开编辑弹窗和打开删除的二次确认弹窗的操作,如下代码所示:

		// 编辑笔记
        .sheet(isPresented: $viewModel.showEditNoteView) {
            //编辑笔记弹窗
        }

        // 删除笔记
        .actionSheet(isPresented: self.$viewModel.showActionSheet) {
            ActionSheet(
                title: Text("你确定要删除此项吗?"),
                message: nil,
                buttons: [
                    .destructive(Text("删除"), action: {
                        self.viewModel.deleteItem(itemId: itemId)
                    }),
                    .cancel(Text("取消")),
                ])
        }

上述代码中,我们使用sheet方法,绑定showEditNoteView打开编辑弹窗的触发参数,编辑笔记页面后面我们会和新建笔记页面功用,这里还没有修改,就先放着。

删除笔记的方法我们使用actionSheet弹窗,绑定showActionSheet打开删除二次确认弹窗触发参数,在ActionSheet弹窗内,我们设置好标题,以及删除按钮的操作,当点击删除的时候,调用viewModel中的deleteItem方法,指定单条笔记的itemId找到对应的笔记进行删除。

完成后,我们单条笔记部分,除了打开编辑弹窗,其他内容已经修改完成。

笔记列表

回到ContentView视图,我们修改了单条笔记的内容,因此笔记列表noteListView视图也需要调整,首先引入ViewModel,如下代码所示:

	// 引用viewModel
    @EnvironmentObject var viewModel: ViewModel

紧接着,我们换一种方法实现笔记列表,如下代码所示:

	// MARK: 笔记列表

    func noteListView() -> some View {
        List {
            ForEach(viewModel.noteModels) { noteItem in
                NoteListRow(itemId: noteItem.id)
            }
        }
        .listStyle(InsetListStyle())
    }

上述代码中,我们换成了使用func方式声明视图,这是另一种创建视图的方法,这样创建视图的好处是,我们需要声明的参数可以放在ContentView视图中,就不需要在每一个视图中声明。

笔记列表唯一的改动就是NoteListRow单条笔记遍历循环的时候,数组来源于viewModel中的noteModels,然后NoteListRow中的ID为noteItem中的ID

顶部搜索栏

再往上是顶部搜索栏,如下代码所示:

    // MARK: 搜索栏

    func searchBarView() -> some View {
        TextField("搜索内容", text: $viewModel.searchText)
            .padding(7)
            .padding(.horizontal, 25)
            .background(Color(.systemGray6))
            .cornerRadius(8)
            .overlay(
                HStack {
                    Image(systemName: "magnifyingglass")
                        .foregroundColor(.gray)
                        .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                        .padding(.leading, 8)

                    // 编辑时显示清除按钮
                    if viewModel.searchText != "" {
                        Button(action: {
                            self.viewModel.searchText = ""
                            self.viewModel.loadItems()
                        }) {
                            Image(systemName: "multiply.circle.fill")
                                .foregroundColor(.gray)
                                .padding(.trailing, 8)
                        }
                    }
                }
            )
            .padding(.horizontal, 10)
            .onChange(of: viewModel.searchText) { _ in
                if viewModel.searchText != "" {
                    self.viewModel.isSearching = true
                    self.viewModel.searchContet()
                } else {
                    viewModel.searchText = ""
                    self.viewModel.isSearching = false
                    self.viewModel.loadItems()
                }
            }
    }

搜索栏改动的内容有三部分,首先是绑定的输入内容换成了viewModel中的searchText

然后是当搜索栏输入时,显示删除的按钮操作,关联的参数也换成viewModel中的searchText,当点击清除搜索内容时,需要将搜索栏输入的内容清空,然后再调用loadItems重新加载列表中的数据。

最后是搜索栏的搜索方法,当输入时,读取searchText输入的内容,如果输入内容不为空,则更新isSearching是否正在搜索的状态为true,然后调用searchContet搜索方法。如果输入的内容为空,那么更新isSearching是否正在搜索的状态为false,并调用loadItems重新加载列表数据。

新建笔记按钮

新建笔记按钮的操作是打开新建笔记弹窗,修改内容如下代码所示:

	// MARK: 新建笔记按钮

    func newBtnView() -> some View {
        VStack {
            Spacer()
            HStack {
                Spacer()
                Button(action: {
                    self.viewModel.isAdd = true
                    self.viewModel.writeTime = viewModel.getCurrentTime()
                    self.viewModel.title = ""
                    self.viewModel.content = ""
                    self.viewModel.showNewNoteView = true
                }) {
                    Image(systemName: "plus.circle.fill")
                        .font(.system(size: 48))
                        .foregroundColor(.blue)
                }
            }
        }
        .padding(.bottom, 32)
        .padding(.trailing, 32)
    }

新建笔记按钮修改点就只有点击时的交互动作,当点击新建笔记按钮时,需要更新viewModel中的是否新增状态isAddtrue,表明点击这个按钮是新增,而我们在单条笔记列表设置isAdd为false,表示当前是在编辑笔记。

当新增笔记的时候,调用getCurrentTime设置新建笔记的时间为当前时间,设置title标题、content内容为空,并且更新showNewNoteViewtrue,作为打开新建笔记弹窗的触发参数。

主视图

缺省图视图基本就不用改了,最后回到body部分,修改如下代码所示:

var body: some View {
        NavigationView {
            ZStack {
                if viewModel.isSearching == false && viewModel.noteModels.count == 0 {
                    noDataView()

                } else {
                    VStack {
                        searchBarView()
                        noteListView()
                    }
                }
                newBtnView()
            }
            .navigationBarTitle("念头笔记", displayMode: .inline)
        }
        .sheet(isPresented: $viewModel.showNewNoteView) {
            //打开新建笔记弹窗
        }
    }

上述代码中,我们通过判断isSearching当前是否处于搜索状态,以及noteModels数组是是否有数据,来判断当前应该展示缺省视图noDataView,还是展示searchBarView搜索栏和noteListView笔记列表。

已经在首页增加sheet绑定showNewNoteView触发打开新建笔记弹窗。

最后,我们还需要在视图预览的时候引用viewModel,如下代码所示:

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

好了,首页基本修改完毕了,是不是有点疲劳?回看下整个ContentView的代码,是不是简洁了很多,基本没有声明什么参数,因为需要用到的参数都放在了ViewModel里,而且用到的实现功能的方法也都放在ViewModel。

Model用来定义数据模型,View视图用来实现基础交互和页面样式,然后ViewModel用来做数据处理和功能实现,这就是MVVM开发模式

休息好了,我们就继续吧~

实战编程-新建笔记页

标题&内容输入

首先还是需要引用ViewModel,才能使用里面声明好的参数。如下代码所示:

	// 引用viewModel
    @EnvironmentObject var viewModel: ViewModel

引用viewModel后,其他声明的参数都可以删掉了。当我们在首页笔记列表点击单条笔记时,会打开新建笔记弹窗,并把内容传递过来,因此我们需要声明模型类参数,如下代码所示:

	// 引用viewModel
    @EnvironmentObject var viewModel: ViewModel

    // 声明参数
    @State var noteModel: NoteModel

    // 关闭弹窗
    @Environment(.presentationMode) var presentationMode

声明好需要的参数后,我们来到标题输入框和内容输入框的部分,这是需要绑定的参数就不是之前声明的参数了。当我们是新建笔记的时候,标题和内容应该是为空,也就是绑定在viewModel声明的titlecontent,而如果是编辑笔记,则我们需要绑定的是来自于点击的单条笔记的内容。如下代码所示:

    // MARK: 标题输入框

    func titleView() -> some View {
        TextField("请输入标题", text: viewModel.isAdd ? $viewModel.title : $noteModel.title)
            .padding()
    }

    // MARK: 内容输入框

    func contentView() -> some View {
        ZStack(alignment: .topLeading) {
            TextEditor(text: viewModel.isAdd ? $viewModel.content : $noteModel.content)
                .font(.system(size: 17))
                .padding()
            if viewModel.isAdd ? (viewModel.content.isEmpty) : (noteModel.content.isEmpty) {
                Text("请输入内容")
                    .foregroundColor(Color(UIColor.placeholderText))
                    .padding(20)
            }
        }
    }

完成按钮

完成按钮这块,回忆下我们前几章学习的内容,它应该包含几块内容:

一是判断条件,当我们标题或者内容输入为空的时候,应该提示输入。

二是点击完成操作的时候,也需要判断当前是新增操作还是编辑操作,如果是新增操作,则插入一条新笔记,如果是编辑操作,则需要更新笔记的内容。

我们修改代码如下所示:

    // MARK: 完成按钮

    func saveBtnView() -> some View {
        Button(action: {
            //判断当前是新增还是编辑
            if viewModel.isAdd {
                //判断标题是否为空
                if viewModel.isTextEmpty(text: viewModel.title) {
                    viewModel.showToast = true
                    viewModel.showToastMessage = "请输入标题"
                }
                
                //判断内容是否为空
                else if viewModel.isTextEmpty(text: viewModel.content) {
                    viewModel.showToast = true
                    viewModel.showToastMessage = "请输入内容"
                }
                
                //校验通过
                else {
                    // 新增一条笔记
                    self.viewModel.addItem(writeTime: viewModel.getCurrentTime(), title: viewModel.title, content: viewModel.content)
                    //关闭弹窗
                    self.presentationMode.wrappedValue.dismiss()
                }

            } else {
                
                //判断标题是否为空
                if viewModel.isTextEmpty(text: noteModel.title) {
                    viewModel.showToast = true
                    viewModel.showToastMessage = "标题不能为空"
                }
                
                //判断内容是否为空
                else if viewModel.isTextEmpty(text: noteModel.content) {
                    viewModel.showToast = true
                    viewModel.showToastMessage = "内容不能为空"
                }
                
                //校验通过
                else {
                    // 保存一条新笔记
                    self.viewModel.editItem(item: noteModel)
                    
                    //关闭弹窗
                    self.presentationMode.wrappedValue.dismiss()
                }
            }

        }) {
            Text("完成")
                .font(.system(size: 17))
        }
    }

代码好像很多的样子,其实不然,逻辑很简单。

当点击“完成”按钮时,首先需要isAdd状态判断当前是新增还是删除。

如果是新增,则判断viewModel中的输入的标题title和内容content是否为空,如果为空,则更改showToast打开提示信息,已经更新showToastMessage提示信息的内容。如果不为空时,则调用addItem方法新增一条笔记。

如果点击“完成”按钮时的操作为编辑操作,则和上面一样的判断,只是判断为空的参数变成了来源于noteModel的标题title和内容content,当为空判断通过后,则调用editItem编辑方法更新笔记内容。

最后都是调用presentationMode.wrappedValue.dismiss关闭弹窗,当然直接点击关闭按钮时也可以调用这个方法关闭弹窗。

主视图

最后来到新建笔记的body部分,修改部分就只有标题和toast绑定的参数,如下代码所示:

var body: some View {
        NavigationView {
            VStack {
                Divider()
                titleView()
                Divider()
                contentView()
            }
            .navigationBarTitle(viewModel.isAdd ? "新建笔记" : "编辑笔记", displayMode: .inline)
            .navigationBarItems(leading: closeBtnView(), trailing: saveBtnView())
            .toast(present: $viewModel.showToast, message: $viewModel.showToastMessage)
        }
    }

由于新建笔记页面使用了ViewModel和声明了noteModel模型类,因此我们如果需要预览该页面,则需要在预览的代码中设置默认值,如下代码所示:

struct NewNoteView_Previews: PreviewProvider {
    static var previews: some View {
        NewNoteView(noteModel: NoteModel(writeTime: "", title: "", content: "")).environmentObject(ViewModel())
    }
}

最后,新建笔记页面修改好后,需要回到ContentView首页,我们打开弹窗的路径还没有配置呢。

在新建笔记时,跳转的页面时NewNoteView,如下代码所示:

// 新增笔记
.sheet(isPresented: $viewModel.showNewNoteView) {
	NewNoteView(noteModel: NoteModel(writeTime: "", title: "", content: ""))
}

在编辑笔记时,跳转的页面也是NewNoteView,如下代码所示:

// 编辑笔记
.sheet(isPresented: $viewModel.showEditNoteView) {
	NewNoteView(noteModel: self.item ?? NoteModel(writeTime: "", title: "", content: ""))
}

项目预览

完成后,运行预览效果如下图所示:

QQ20220927-183555-HD.gif

本章小结

恭喜你,完成了使用SwiftUI从0到1完成一款笔记APP的全部内容!

在整个项目过程中,我们首先学习如何完成一个个单独的视图,再将一块块的代码组合成一个页面,最后再基础页面和交互的基础上使用Model-View-ViewModel的方式进行开发调整,最终完成整个项目。

回顾第一个项目的整个过程,我们会发现我们构建视图的方式都是自上而下构建,而实现交互功能、逻辑是自下而上搭建。这便是专栏开始之初提到的编程逻辑:

自顶向下逐步求精的模块化设计思想、面向对象的方法自底而上进行开发思想。

编程技巧固然重要,但更重要的是思维方式,方法很容易学会,但观念和习惯就没有那么容易改变了。

编程本就是一条没有那么有趣的路,不妨沉下心来,写好每一段代码,写好每一块业务。

看着最终成功运行的项目,感受着心底的喜悦喷涌而出~

接下来,我们将继续实现和完成其他项目,请保持期待吧~

版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!