网络知识 娱乐 SwiftUI实战教程 第三章 土豆List

SwiftUI实战教程 第三章 土豆List

代码库

教程中的项目代码都保存在这里:
https://github.com/NDFour/swiftui01

前言

在这一章节中,我们会使用List控件做一个土豆List,实现了列表填充、增加记录、删除记录以及列表记录重排序。

当你点击列表中的todo记录时将会跳转到详情页,详情页包含todo标题的放大版以及图标的放大版。

新建项目

怎么新建一个项目我们在第一章中介绍过,这里就不再赘述了,新建好的项目长这个样子:

请添加图片描述

为了在List中展示todo记录,我们在ContentView文件中添加如下代码:

struct ContentView: View {
    var body: some View {
        List {
            Text("吃饭")
            Text("睡觉")
            Text("看看书")
            Text("打打红警")
        }
    }
}

效果图:
在这里插入图片描述
现在List中的每一行数据还很单调,只有文字。

我想要在每一行之中除了文字之外还要展示一个分类的图标,表示该条todo记录属于什么类别,所以使用HStatck包裹我们要在一行展示的数据:

struct ContentView: View {
    var body: some View {
        List {
            HStack {
                Image(systemName: "desktopcomputer")
                    .resizable()
                    .frame(width: 20, height: 20)
                Text("Coding...")
            }
             HStack {
                Image(systemName: "house")
                    .resizable()
                    .frame(width: 20, height: 20)
                Text("健身")
            }
            HStack {
                Image(systemName: "theatermasks")
                    .resizable()
                    .frame(width: 20, height: 20)
                Text("相亲")
            }
        }
    }
}

效果图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dGMp0zc0-1642076770406)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-3.jpg)]

如果 Image中使用的图片是自己从网上找的,大小可能不一致,这就可能导致图片超过了屏幕的大小,整个屏幕只显示了图片的一角。

这时就可以使用resizeable让图片自适应大小。

Image(systemName: "desktopcomputer").resizable().frame(width: 50, height: 50)

使用frame来限定图片的大小。

使用数组内容填充List

目前为止,我们通过写死代码的方式在List中展示了几条数据,接下来我们要使其能够动态变化。

新建一个 Todo 结构体,保存 todo 数据

ContentView.swift代码中增加一个结构体:

struct Todo {
    let name: String // todo 标题
    let category: String // todo 分类
}

新建一个 @State 修饰的数组

ContentView.swift中增加数组:

struct ContentView: View {
    @State private var todos = [
        Todo(name: "coding", category: "desktopcomputer"),
        Todo(name: "健身", category: "house"),
        Todo(name: "相亲", category: "theatermasks")
    ]
    ...
}

我们创建了一个数组todos,并且用@State修饰,这样List中的数据条目就可以动态更新了。

声明数组的同时,我们还新建了3条 todo 结构体放到了数组中。

填充数组内容到 List

var body: some View {
    List {
        ForEach(todos, id:.name) { (todo) in 
            HStack {
                Image(systemName: todo.category)
                	.resizable()
                	.frame(width: 20, height: 20)
                Text(todo.name)
            }
        }
    }
}

在 List 中我们使用ForEach来取出数组中的所有记录并展示。Image 中展示分类图片,Text 中展示 todo 的 name。

在 ForEach 中我们使用 .name来唯一标记一条 todo 记录(不应该这么做,只是临时方案),这里我们假设 todo 的 name 属性是唯一不会重复的。

你现在运行app的话,看到的效果跟之前把 todo 数据写死在代码里时是一样的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Men21mC-1642076770407)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-4.jpg)]

在 NavigationView 中展示 List

接下来我们来实现点击一条 todo 记录后跳转到详情页,实现这个功能需要将 List 包裹在 NavigationView 中:

var body: some View {
    NavigationView {
        List {
            ForEach(todos, id:.name) {(todo) in
                ...
            }
        }.navigationBarTitle("土豆List")
    }
}

我们还通过 navigationBarTitle 为当前页面设置了一个标题。
在这里插入图片描述

注意: 我们的 navigationBarTitle 是放在了 NavigationView中的 List上。之所以这么做是因为在点击了 List 中的某条记录后, NavigationView 将会展示一个新的页面,而不是现在的 List,每一个页面都应该有一个不同的标题,如果把 navigationBarTitle 放到 NavigationView 上,那么切换页面时标题就固定死了,不会变化的。

让 List 中的记录可以点击

将 List 包裹在 NavigationView 中可以让 List 在被点击后跳转到新的页面。

要使 List 中的 todo 记录被点击后跳转到详情页,修改代码中的 ForEach如下:

ForEach(todos, id:.name) { (todo) in
    NavigationLink(destination: {
        VStack {
            Text(todo.name)
            Image(systemName: todo.category)
                .resizable()
                .frame(width: 200, height: 200)
        }
    }) {
        HStack {
            Image(systemName: todo.category)
                .resizable()
                .frame(width: 20, height: 20)
            Text(todo.name)
        }
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nFbtQpJA-1642076770408)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-6.jpg)]

我们在 NavigationLink中提供了一个参数表示详情页的 UI。

NavigationLink(destination: 
    VStack {
        Text(todo.name)
        Image(systemName: todo.category)
            .resizable()
            .frame(width: 200, height: 200)
    })

当你运行app并且点击 List 中的某个 todo 记录后,app 将会跳转到一个新的详情页,新详情页的上方还会显示一个返回按钮。
在这里插入图片描述

删除一条 todo 记录

在 iOS 中我们通常会通过向左滑动来显示删除按钮或者直接删除。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tyyPerhN-1642076770409)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-8.jpg)]

为了使我们 app 中的 List 也具有这个功能,我们需要在 ForEach 的结尾处添加 onDelete 修饰符。

var body: some View {
    NavigationView {
        List {
            ForEach(todos, id: .name) { (todo) in
                NavigationLink(destination:
                    ...
                }
            } .onDelete(perform: {indexSet in
                todos.remove(asOffsets: indexSet)})
        }.navigationBarTitle("土豆List")
    }
}

onDelete会产生一个变量indexSet,里面包含了所有要删除的 todo记录 的索引位置,我们将这个参数indexSet传入todos.remove方法实现移除数组中某些元素。

重排序列表中的记录

可以通过在ForEach的结尾处添加onMove()修饰符来实现改变List中记录顺序的效果:

var body: some View {
    NavigationView {
        List {
            ForEach(todos, id:.name) {(todo) in
                NavigationLink(destination:
                    VStack {
                        Text(todo.name)
                        Image(...)
                    }
                ){
                    HStack {
                        Image(...)
                        Text(todo.name)
                    }
                }
            }
            .onDelete(perform: {indexSet in
                todos.remove(atOffsets: indexSet)
            })
            .onMove(perform: {indices, newOffset in
                todos.move(fromOffsets: indices, toOffset: newOffset)
            })
        }.navigationBarTitle("土豆List")
        .navigationBarItems(trailing: EditButton())
    }
}

onMove提供了indicesnewOffset两个变量,indices包含了所有要移动的todo记录的旧位置索引,newOffset包含了要移动到的新位置索引。

需要注意的是,只有进入了编辑模式后才可以移动todo记录,所以我在导航栏(Navigation Bar)中添加了一个 Edit 按钮,当点击了 Edit 按钮后就会进入编辑模式,这时候就可以移动 todo记录的位置了。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RcLjsn4Z-1642076770410)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-9.jpg)]

当点击 Edit 按钮进入编辑模式后,在每一个 todo记录 的左侧还会出现一个红色的删除按钮,这是编辑模式自带的效果。

为todo记录增加唯一标识

  • 为todo记录增加唯一标识

前面我们假设每一条 todo记录的标题都是唯一不重复的,所以使用 name 属性来唯一标记某条todo。

ForEach(todos, id:.name)

那如果出现多个重名的 todo记录怎么办呢??

如果有多个重名的todo记录的话,当我们删除记录的时候就会出现问题,因为它不知道到底应该删除哪条记录。为了解决这个问题,我们需要给 Todo结构体增加一个唯一标识符

struct Todo: Identifiable { // 遵守 Identifiable 协议
    let id = UUID() // 新加一个 id 属性
    let name: String
    let category: String
}

我们做了两处修改:

  1. 遵守Identifiable协议
  2. 增加一个id属性

遵守Identifiable协议就需要我们增加一个 id属性,同时也意味着这个结构体是可以被唯一标识的。使用 UUID()函数生成一个唯一的标识符赋给每个新建的 Todo 结构体。

所以之前的代码就可以删除id:.name,改完后代码如下:

ForEach(todos) {
    ...
}

List 会自动使用 todo.id 来唯一标识某条记录,不需要我们额外指明 id: .id

增加一条 todo记录

想要增加一条 todo记录的话,就要新建一个 Todo 并加入到 todos数组中,同时我们在app导航栏的左侧增加一个按钮,点击后实现增加 todo记录。

var body: some View {
    NavigationView {
        List {
            ...
        }
        .navigationBarTitle("土豆List")
        .navigationBarItems(
            leading: Button(action: {}, label: {
            	Text("+1")
        	}),
            trailing: EditButton()
        )
    }
}

我们为+1按钮增加一个点击事件处理函数 addTodo

NavigationView {
    List {
        ...
    }
    .navigationBarTitle("土豆List")
    .navigationBarItems(
    	leading: Button(action: addTodo,
                       label: {
                           Text("+1")
                       }),
    	trailing: EditButton()
    )
}

addTodo函数的代码:

func addTodo() {
    todos.append(Todo(name: "新的Todo", category: "desktopcomputer"))
}

修改完后运行app,点击 +1按钮后你将会看到屏幕上多出了一条记录:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XpRHIHZH-1642076770410)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-10.jpg)]

使用用户输入数据新建 todo记录

目前我们新增的 todo记录的 name和 category 都是写死在代码里的,这样显然不符合常理,接下来我们就新建一个页面,根据用户输入的 name 和 category 新建 todo记录。

ContentView.swift 文件中增加以下代码:

struct AddTodoView: View {
    var body: some View {
        Text("这是增加 todo记录的界面")
    }
}

同时在 ContentView 中增加一个 @State 变量,根据这个变量的值来判断是否需要跳转到 AddTodoView 来新建 todo记录:

@State private var showAddTodoView = false // 默认为 false,不跳转AddTodoView` 

接下来修改 +1按钮的点击处理逻辑如下:

{
    ...
}
.navigationBarItems(
	leading: Button(action: {
        		// 反转 showAddTodoView 的值,false => true
        		self.showAddTodoView.toggle()
    		}, label: {
        		Text("+1")
    		})
			.sheet(isPresented: $showAddTodoView) {
        		AddTodoView() // 我们刚才新建的新界面 struct
    		},
	trailing: EditButton()
)

我们在 +1按钮后增加了一个 sheet修饰符用于自底向上弹出一个新界面,我们在新界面输入 name和 category来新建一个 todo记录。

sheet中的isPresented参数绑定了我们自定义的 showAddTodoView变量,当showAddTodoView的值为true时,弹出 sheet,否则隐藏 sheet。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1Wxr6l2D-1642076770411)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-11.jpg)]

可以手动向下拖拽 sheet 来隐藏 sheet。

这里我们还需要在 sheet界面里添加一个输入框获取用户输入,一个按钮用户点击后自动隐藏 sheet:

struct AddTodoView: View {
    // @Binding 的作用下面马上会解释
    @Binding var showAddTodoView: Bool
    
    var body: some View {
        Text("添加一个 todo")
        
        Button(action: {
            self.showAddTodoView = false // 变为false后sheet自动隐藏
        }, label: {
            Text("完成")
        })
    }
}

回到之前的 ContentView修改代码如下:

{
    ...
}
.navigationBarTitle(...)
.navigationBarItems(
	leading:
		Button(action: {
            self.showAddTodoView.toggle()
        }, label: {
            Text("+1")
        })
		.sheet(isPresented: $showAddTodoView) {
            AddTodoView(showAddTodoView: self.$showAddTodoView)
        }
)
...

@Binding 这个变量会从任何地方传进来,并且这个变量的值会在当前位置和传此值进来的代码间共享。

在此代码中 showAddTodoView的值在 ContentView 和 AddTodoView 间共享,因为该变量是从 ContentView 中的 sheet里传进来的:

.sheet(isPresented: $showAddTodoView) {
         AddTodoView(showAddTodoView: self.$showAddTodoView)
}

因此,当 AddTodoView 中的 showAddTodoView 变量发生变化时,ContentView 中的 showAddTodoView 也会发生变化,在 AddTodoView 中将 showAddTodoView 设为 false,那么 sheet就会自动隐藏。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ja1UX3cm-1642076770411)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-12.jpg)]

下面我们在 AddTodoView 中增加一个输入框用于用户输入 Todo的 name,一个选择器用于选择 Todo的 category:

@State private var name: String = ""
// 用户选择了 categoryTypes中的某一项后,该变量为其索引值
@State private var selectedCategory = 0
// 存放预先定义的玄功选择的 category,展示在选择器 picker中
var categoryTypes = ["house", "theatermasks", "desktopcomputer"]

var body: some View {
    VStack {
        Text("增加 todo").font(.largeTitle)
        TextField("name", text: $name)
        	.textFieldStyle(RoundedBorderTextFieldStyle())
        	.border(Color.black)
        	.padding()
        
        Text("选择 category")
        Picker("", selection: $selectedCategory) {
            ForEach(0 ..< categoryTypes.count) {
                // $0 表示取第一个参数
                Text(self.categoryTypes[$0])
            }
        }
        .pickerStyle(SegmentedPickerStyle())
    }.padding()
}

Picker 控件常用于用户从指定的列表中选择一个值,在 ForEach中我们遍历所有 categoryTypes中的元素展示到 Picker供用户选择。

Picker最后我们添加了一句pickerStyle(SegmentedPickerStyle()),你可以尝试去掉这一句代码,看看会产生什么影响😆。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T5nPt2Cs-1642076770412)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-13.jpg)]

接下来我们开始编写根据用户输入内容新建 Todo的代码,增加一条 todo记录的话除了要新建一个 Todo还要将添加到 ContentView中的 todos数组中去。

为此,我们要修改 ContentView中的+1按钮后添加的sheet代码:

.sheet(isPresented: $showAddTodoView) {
    AddTodoView(
        showAddTodoView: self.$showAddTodoView,
        todos: self.$todos // 增加了一个 todos参数
    )
}

同时还要修改 AddTodoView中的代码,添加一行@Binding标记的代码用于接收从 ContentView传入的 todos参数:

@Binding var todos: [Todo] // Todo类型的数组,用于接收其它地方传入参数

使用 @Binding标记后的 todos就可以同时在 ContentViewAddTodoView两个页面里共享状态了。

最后,在 AddTodoView中增加一个按钮,用户点击后获取输入的 name和选择的 category新建一个 Todo,然后添加到 todos数组中。

Button(action: {
    self.showAddTodoView = false // 隐藏 sheet
    // 新建一个 Todo并添加到 todos数组中
    todos.append(Todo(name: name,
                     category: categoryTypes[selectedCategory]))
}, label: {
    Text("点击新建")
})

运行一下代码看看效果吧!!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JiYQV8Vc-1642076770412)(/Users/mac/Desktop/swift01/swift01/pics/ch3/todo-list-14.jpg)]

真棒👍🏻!!我们已经得到了一个自己开发的 土豆List !!
在这里插入图片描述

在上面的代码中,我们添加新建的 Todo到 todos数组用的是 append,这会把新数据添加到数组的末尾,反映到app界面上也就是新添加的记录会展示的屏幕最下方。为了“修复”这个问题,你可以试试 insert !!

小结

在这一章节里我们自己完成了一款 土豆List app,涉及到了 List的数据填充、点击、移动、删除等,还使用了一个新控件:Picker!!

目前还有一个问题就是数据的持久化,我们的 todo记录目前都是保存在手机的内存中的,app关闭之后再打开数据就丢失了。

下一章节我们就来看看怎么使用 CoreData来持久化数据,将数据保存到数据库中。