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

7,777 阅读12分钟

项目起源

前段时间稀土掘金客户端上线了“闪念笔记”功能,作为尝鲜一族便稍微体验了一下。

闪念笔记界面简约操作容易上手,相比较印象笔记、有道云笔记等综合型笔记应用,当前闪念笔记当前专注于笔记本身,只作为一种知识储备的记录工具。

深入研究后发现了点意外之喜,以稀土掘金流量口结合的闪念笔记,与Web端应用、浏览器剪藏插件相融合可以构成闭环产品生态体系。

不难猜想,在笔记工具功能完善和使用率覆盖一定市场份额后,必将正式打造移动端和桌面版应用,切入笔记类工具领域杀出一条血路。

当然,以上仅仅只是猜想。

进入正题,本章我们来搭建第一个SwiftUI实战项目,从0到1完成一款iOS笔记App。

需求分析

一款最小MVP的笔记App是怎样的呢?及时记录自己的想法、念头,搜索自己曾经记录的笔记,这是一款笔记最基本的功能需求点。

我们先梳理产品架构图,如下图所示:

1.png

一款最简单的笔记软件,包含2个核心页面:

  • 首页界面:展示笔记列表、新建笔记按钮,若是做功能强化,可以加上搜索栏;
  • 新建按钮:点击按钮,打开“新建页面”弹窗,或者进入新的页面,编辑完成关闭/回到首页;

产品设计

下一步,我们来完成产品的原型设计,构建2个页面,并将产品架构中的元素信息转换为页面功能,如下图所示:

2.png

用户首次进入时,念想笔记将会展示一个空白页,空白页面由标题、缺省图、指引文字、新建笔记按钮组成,主要引导用户进行主要功能的使用。

当用户点击新建笔记按钮时,唤起“新建笔记”弹窗页面,弹窗页面相比较进入一个新的页面,用户思维不会被强行打断,这是一种很好的交互。

在“新建笔记”页面中,主要操作为“返回”按钮、“完成”按钮、标题输入框、内容输入框。

用户点击“返回”按钮,则向下关闭该弹窗,并清空该页面已输入内容;

当用户点击“完成”按钮时,需要判断“标题输入框”、“内容输入框”是否有键入的文字,且符合预设的规则(文字数量等);

当满足规则时,则吐司提示保存成功,并关闭该弹窗页面,且在笔记列表中插入一条新的笔记。若不满足规则(校验标题和内容是否为空),则吐司冒泡提示相关信息(标题不能为空、内容不能为空)。

为更好地指引用户填写,标题输入框需要有提示文字,当文字键入时,清空提示文字,内容输入框同理。

对于搜索功能,核心点在于根据内容关键字进行搜索,并实时进行反馈。当然通常由于性能问题,会考虑输入后点击“搜索”触发搜索功能的交互方式。

以上就是简单的需求文档的撰写。

UI设计

下面我们使用AdobeXD根据产品原型图绘制UI设计稿,UI设计稿需要包含所有的页面及其交互动作,如下图所示:

3.png

UI设计需要根据产品原型的元素和内容,结合当前市场上的常用交互逻辑和设计规范,输出高保真的设计稿。其中包含各项元素组件的尺寸规范、文字规范、交互规范等,旨在为前端开发人员提供很好的样式开发指引

UI设计师和产品经理的分工常常是产品经理提供产品原型DemoUI设计师确定产品的主体风格和设计规范,输出UI设计稿,再与产品经理或者与业务团队进行评审,通过后方可进行切图,移交给下一流程。

UI设计稿最为App最终呈现的效果产物,前端工程师需要根据UI设计稿达到一比一还原,后期也可以与UI设计稿进行对比进行App样式验收。

在这里科普一个概念,切图。

切图是UI设计师将已经完成好的静态页面中的元素使用工具进行切分,与静态页面分离,示例:图标按钮、插图。UI设计师将这些需要在实际开发过程中需要导入使用的素材从静态页面上“切”下来,便可以在下面的协同工具中导出不同尺寸的图片进行使用。

UI设计师切好图后,需要再借助一个工具交付给前端设计师,这里推荐的工具是PxCook像素大厨

安装完成,AdobeXD将会安装对应的插件,选择文件 > 导出 > PxCook。如下图所示:

4.png

PxCook将会被唤起,并创建一个新项目,输入“念头笔记”,选择类型为iOS,点击“创建本地项目”,如下图所示:

5.png

选择导入画板,PxCook会自动勾选由AdobeXD导出的所有面板,我们保持全选状态,点击“导入”,如下图所示:

6.png

如此,我们便将AdobeXD中的UI设计稿导入到PxCook中了。

在PxCook中,我们可以看到UI设计稿设计的元素的尺寸、元素之间的相对距离,方便于开发者根据UI设计稿进行前端静态页面的开发工作,如下图所示:

7.png

实战编程

接下来,我们正式进入到编程阶段,打开Xcode开发工具,点击Create a new Xcode project,将新项目命名为IdeaNote,如下弹窗所示:

8.png

首页-缺省图

点击视图工具栏的Assets.xcassets文件,拖入首页缺省图的图片,如下图所示:

9.png

回到ContentView文件,在用户初始进入时,映入眼帘的是一个初始的样式,它由图片文字组成,如下代码所示:

// 缺省图
func noDataView() -> some View {
    VStack(alignment: .center,spacing: 20) {
        Image("mainImage")
            .resizable()
            .scaledToFit()
            .frame(width: 240)
        Text("记录下这个世界的点滴")
            .font(.system(size: 17))
            .bold()
            .foregroundColor(.gray)
    }
}

上述代码中,我们创建了一个新的缺省视图noDataView

图片与文字使用VStackc垂直布局容器,容器内的元素alignment对齐方式为center居中对齐,元素的spacing间距为20。

图片引用之前导入的mainImage图片,为了保持图片在视图内的展示效果,使用resizable修饰符调整图片大小,使用scaledToFit修饰符保持其宽高比,防止图片变形,最后使用frame修饰符设置图片的大小为240。

文字部分内容设置为“记录下这个世界的点滴”,使用font修饰符设置文字大小为17,使用bold修饰符使得文字加粗,使用foregroundColor修饰符设置文字填充颜色。

运行预览效果如下图所示:

10.png

新建笔记按钮样式也可以按照上述创建视图的方式,如下代码所示:

// 新建笔记按钮
func newBtnView() -> some View {
    VStack {
        Spacer()
        HStack {
            Spacer()
            Button(action: {

            }) {
                Image(systemName: "plus.circle.fill")
                    .font(.system(size: 48))
                    .foregroundColor(.blue)
            }
        }
    }
    .padding(.bottom, 32)
    .padding(.trailing, 32)
}

上述代码中,我们创建了一个新的新建笔记按钮视图newBtnView

新建笔记按钮的核心是一个图标按钮,使用Image组件引用系统提供的图标“plus.circle.fill”,按钮的交互可以直接使用Button组件。

为了使按钮文字放置在屏幕右下角,我们使用VStack垂直容器和Spacer占位视图将按钮撑在底部,然后再使用HStack横向容器和Spacer占位视图再把按钮撑到右边,我们再使用padding在bottom底部和trailing右边都留出距离。

如此我们就完成新建笔记按钮的样式,最后我们在主视图中与noDataView缺省图使用ZStack层叠容器包裹,运行预览效果如下图所示:

11.png

还差点什么?标题。标题部分可以直接使用NavigationView导航视图与navigationTitle修饰符设置标题,如下代码所示:

NavigationView {
    ZStack {
        noDataView()
        newBtnView()
    }.navigationBarTitle("念头笔记", displayMode: .inline)
}

12.png

首页-列表页

当用户新建了笔记之后,首页便从原本的缺省视图转换为列表视图,列表视图中的主要两块视图为“搜索栏”、“列表栏”,如下图所示:

13.png

我们至上而下构建页面,首先是搜索栏。从设计稿中,我们可以知道搜索栏由一个搜索图标、搜索输入框、清除按钮组成。由于会使用输入框TextField,因此需要提前声明绑定的变量,如下代码所示:

@State var searchText = ""
// MARK: 搜索

func searchBarView() -> some View {
    TextField("搜索内容", text: $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 searchText != "" {
                    Button(action: {
                        self.searchText = ""
                    }) {
                        Image(systemName: "multiply.circle.fill")
                            .foregroundColor(.gray)
                            .padding(.trailing, 8)
                    }
                }
            }
        )
        .padding(.horizontal, 10)
}

上述代码中,我们创建了一个新的搜索栏视图searchBarView

搜索栏使用TextField输入框组件,内容text部分绑定声明好的变量searchText

样式部分使用padding修饰符撑开一段距离,使用background修饰符设置背景填充颜色为灰色,使用cornerRadius修饰符设置视图的圆角度数。

输入框右边有一个“搜索”的图标,这里使用overlay修饰符在输入框层叠一个“搜索”图标和一个“清除”图标按钮。

其中“清除”图标的交互逻辑是,判断输入框内输入的文字searchText是否为空,如果不为空,则展示“清除”按钮,当点击“清除”按钮的时候清空searchText内容。

运行预览效果如下图所示:

14.png

接下来是列表部分,依旧拆解下列表的元素,列表由记录时间、笔记标题、笔记内容、更多按钮组成,如下图所示:

15.png

拆解好元素后,由于列表不是固定写好的内容,而是由用户编辑输入的内容,因此我们需要构建数据模型。

在Xcode视图窗口右键,选择New File,创建一个新的Swift文件,名称为Model.swift。如下图所示:

16.png

创建完成后,录入以下代码:

import SwiftUI

class NoteItem: ObservableObject, Identifiable {
    var id = UUID()
    @Published var writeTime: String = ""
    @Published var title: String = ""
    @Published var content: String = ""

    // 实例化
    init(writeTime: String, title: String, content: String) {
        self.writeTime = writeTime
        self.title = title
        self.content = content
    }
}

17.png

上述代码中,我们创建了一个类NoteItem,遵循ObservableObject可被观察对象协议和Identifiable可被识别协议。

在NoteItem类里面有三个参数:writeTime录入时间、title标题、content内容。并且在ObservableObject协议需要使用@Published定义,这样才能在参数改变的时候检测到变化。由于使用Identifiable可被识别协议,因此需要声明一个idUUID()

定义好Model数据模型后,回到ContentView文件,我们来创建列表视图,如下代码所示:

// MARK: 列表内容

struct NoteListRow: View {
    @ObservedObject var noteItem: NoteItem

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

            Button(action: {

            }) {
                Image(systemName: "ellipsis")
                    .foregroundColor(.gray)
                    .font(.system(size: 23))
            }
        }
    }
}

上述代码中,我们先创建了一个单条列表视图NoteListRow,使用@ObservedObject引用监听实例对象的类NoteItem

在构建列表视图的样式上,和基础构建视图的方式一致,只是原本固定录入的参数,变成了来自于NoteItem类的参数,示例:标题,使用noteItem.title

三个文本使用VStack纵向布局容器,设置左对齐以及间距为10。最后使用HStack横向布局容器包裹三个文本和“更多”按钮。由于noteItem.content内容文字可能很长,我们只需要一行,因此可以使用lineLimit限制长度为1行省略。

如此,便构建完成了单条笔记的样式。

然后我们基于单条笔记的样式构建列表视图,如下代码所示:

// MARK: 列表

struct NoteListView: View {
    @State var noteItems: [NoteItem] = [NoteItem(writeTime: "2022.09.17", title: "第一条笔记", content: "快来使用念头笔记记录生活吧~快来使用念头笔记记录生活吧~")]

    var body: some View {
        List {
            ForEach(noteItems) { noteItem in
                NoteListRow(noteItem: noteItem)
            }
        }
        .listStyle(InsetListStyle())
    }
}

上述代码中,我们创建了一个NoteListView列表视图,使用@State声明一个数组noteItems,并赋予noteItems来源于NoteItem数组类并赋予内容。

在主体body部分,使用List列表组件和ForEach循环遍历noteItems数组的数据,并传递参数给NoteListRow

List列表样式部分,由于SwiftUI默认样式是圆角矩形分组的方式,这边还需要设置List列表样式为InsetListStyle

我们在ContentView的body中使用搜索栏视图和列表视图,如下代码所示:

NavigationView {
    ZStack {
        VStack {
            searchBarView()
            NoteListView()
        }
        newBtnView()
    }.navigationBarTitle("念头笔记", displayMode: .inline)
}

运行预览效果如下图所示:

18.png

首页-页面判断

上述编程过程中,我们完成了缺省页和列表页,它们之间的交互逻辑是:当笔记列表中没有笔记时,App将展示缺省页,当存在笔记时,展示列表页。

可以先引入NoteItem数组类,如下代码所示:

@State var noteItems: [NoteItem] = [NoteItem(writeTime: "2022.09.17", title: "第一条笔记", content: "快来使用念头笔记记录生活吧~快来使用念头笔记记录生活吧~")]

然后我们就可以根据noteItems数组的数量作为判断条件,如下代码所示:

NavigationView {
    ZStack {
        if noteItems.count == 0 {
            noDataView()
        } else {
            VStack {
                searchBarView()
                NoteListView()
            }
        }
        newBtnView()
    }.navigationBarTitle("念头笔记", displayMode: .inline)
}

上述代码中,当noteItems数组中的数量为0时,则展示noDataView缺省页,否则则展示搜索栏+笔记列表组成的列表页。

运行预览效果如下图所示:

19.png

本章小结

由于项目较长,这里将分成几个章节完成,请按耐住性子一步一步完成。

在本章中,我们从产品规划开始,通过需求分析、产品设计、UI设计、实战编程等阶段来从0到1完成一款iOS笔记App,其中涉及到各个阶段不同职业的工作细节,希望能给大家对一款App全生命周期过程有一个大概的认识。

快来动手试试吧~

版权声明

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