今日职言:能够享受最好的,也要能够接受最差的。在本章中,你将学会如何使用TextField输入框构建一个SearchBar搜索栏。在很多App中,我们都可以看到SearchBar搜索栏的身影,比如用来搜索某条资讯,搜索某个联系人,亦或者搜索某个功能……那么这一章节,我们就来学习下如何创建一个简答的SearchBar搜索栏。那么,我们开始吧。首先,创建一个新项目,命名为SwiftUISearchBar。UI部分我们看到SearchBar搜索栏的样式其实就是一个TextField输入框,我们在里面输入文字作为搜索条件,TextField输入框输入的内容再关联SearchBar组件,达到搜索的目的。我们构建一个简单的SearchBarView搜索栏视图。// SearchBarView搜索栏视图 struct SearchBarView: View { @Binding var text: String @State private var offset: CGFloat = .zero // 使用.animation防止报错,iOS15的特性 var body: some View { TextField("搜你想看的", text: $text) .padding(7) .padding(.horizontal, 25) .background(Color(.systemGray6)) .cornerRadius(8) .overlay( Image(systemName: "magnifyingglass") .foregroundColor(.gray) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading).padding(.leading, 8) ) .padding(.horizontal, 10) } } 逻辑部分然后,我们思考下SearchBarView搜索栏的逻辑。当我们SearchBarView搜索栏没有输入内容时,显示的是“搜你想看的”文字,那么当我们在TextField输入内容的时候, 应该要删除placehoder文字。我们可以定义一个TextField输入框是否正在编辑isEditing的状态,通过isEditing是否处于编辑状态来判断。@State private var isEditing = false当我们点击TextField输入框输入的时候,isEditing状态为true,同时TextField输入框内部右侧会显示一个清除图标,点击清除图标可以清空我们输入的内容。而且清除图标和TextField输入框是HStack横向排布,我们完成下交互逻辑。//点击时 .onTapGesture { self.isEditing = true }//编辑时显示清除按钮 if isEditing { Button(action: { self.text = "" }) { Image(systemName: "multiply.circle.fill") .foregroundColor(.gray) .padding(.trailing, 8) } }另外,当我们isEditing是否处于编辑状态时,SearchBarView搜索栏应该右边空出一个位置,显示搜索文字按钮,点击搜索时,isEditing恢复到初始false状态。搜索按钮和整个TextField输入框也是HStack横向排布。// 搜索按钮 if isEditing { Button(action: { self.isEditing = false self.text = "" }) { Text("搜索") } .padding(.trailing, 10) .transition(.move(edge: .trailing)) .animation(.default, value: offset) }我们在模拟器中预览效果。但这里我们发现一个问题,当我们点击“搜索”按钮的时候,虽然SearchBarView搜索栏恢复初始状态了,但是keyboard键盘没有自动收下去。我们需要额外添加一个收起keyboard键盘的方法,代码如下:// 收起键盘 UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)这样,我们就实现了点击“搜索”时,清空TextField输入框文字,恢复到初始状态,并且收起keyboard键盘。完整代码如下:import SwiftUI struct ContentView: View { @State var text: String = "" var body: some View { VStack { SearchBarView(text: $text) .padding() Spacer() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } // SearchBarView搜索栏视图 struct SearchBarView: View { @Binding var text: String @State private var isEditing = false @State private var offset: CGFloat = .zero //使用.animation防止报错,iOS15的特性 var body: some View { HStack { TextField("搜你想看的", text: $text) .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 isEditing { Button(action: { self.text = "" }) { Image(systemName: "multiply.circle.fill") .foregroundColor(.gray) .padding(.trailing, 8) } } } ) .padding(.horizontal, 10) // 点击时 .onTapGesture { self.isEditing = true } // 搜索按钮 if isEditing { Button(action: { self.isEditing = false self.text = "" // 收起键盘 UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) }) { Text("搜索") } .padding(.trailing, 10) .transition(.move(edge: .trailing)) .animation(.default, value: offset) } } } }快来动手试试吧!如果本专栏对你有帮助,不妨点赞、评论、关注~
前言:在学习的过程中,难免遇到让人短时间难以跨过的障碍,这时候难免让人很烦躁......按耐住烦躁和寂寞,通过理想的路总是会有那么多的曲折,看淡了,也就走稳了。今日职言:运气是搬运过来的东西。在本章中,你将学会如何使用State状态和Binding绑定,监听属性值的变化,和根据Binding绑定关系,改变一个属性值的同时影响另一个属性值的变化。举个例子:我们在听音乐或者看视频时,点击“播放”按钮,“播放”按钮会变成“暂停”按钮,同时视频开始播放。这就用到了@State属性包装器和@Binding属性包装器。我们尝试完成下面的设计稿。首先,先创建一个新的项目,命名为SwiftUIState。在ContentView.swift文件中,我们先创建一个简单的按钮。关于按钮的编程可以参考之前的文章。//创建按钮 Button(action: { }) { Image(systemName: "circle") .font(.system(size:150)) .foregroundColor(Color(red: 170/255, green: 170/255, blue: 170/255)) }在这里我们使用了系统自带的图标、大小、RGB颜色。好了,我们的到一个按钮的状态,那么我们希望接下来怎么操作?点击按钮(这个图片),切换状态变成“选中”的状态。也就是说,现在这个按钮有2个状态,一个状态是“未选中”,是灰色的圆形,另一个状态是“选中”,是填充了绿色的选中图形。那么,我们可以定义一个初始状态叫做“isSelected”,它的状态是“false”。@State var isSelected = false在这里,我们定义了一个叫做“isSelected”的Bool变量,它的值是“否”。当我们点击按钮的时候,我们可以切换这个Bool变量变成“是”。这样我们就可以通过点击按钮,实现状态来回切换了。我们来试试。科普一个知识点。定义的变量都需要放在结构体下面,也就是Struct ContentView: View下面,而要在body上面。这样我们才能在body中使用定义好的变量。下一步,我们回到设计稿中。当我们是“是”的状态时,图片变成了“选中”,而背景颜色也变成了绿色。这里,我们用到了状态判断语句。示例:Image(systemName: isSelected ? "checkmark.circle.fill" :"circle")我们预设了一个图片,它是systemName系统图片名称,它的取值通过判断取值,也就是isSelected。这里的格式是:【?XX : XX】isSelected ? "" :""简单来说,就是“如果isSelected为正确,那么它是什么,反之是什么”【?】后面跟随Bool值为true的取值,【:】后面跟随Bool值为false的取值。回归这句代码,也就是如果isSelected为true,那么它的图片是”checkmark.circle.fill”,如果是false,它的图片是”circle”。Image(systemName: isSelected ? "checkmark.circle.fill" :"circle")同理,我们完善下背景颜色的判断。Button(action: { }) { Image(systemName: isSelected ? "checkmark.circle.fill" :"circle") .font(.system(size:150)) .foregroundColor(isSelected ? Color(red: 112/255, green: 182/255, blue: 3/255) : Color(red: 170/255, green: 170/255, blue: 170/255) ) }接下来,我们需要在按钮操作加上执行动作。当我们每次点击按钮的时候,isSelected的状态都需要切换。我们可以用到下面的代码:self.isSelected.toggle()即点击的时候,isSelected的状态切换。toggle()是切换状态。struct ContentView: View { //定义变量 @State var isSelected = false var body: some View { Button(action: { self.isSelected.toggle() }) { Image(systemName: isSelected ? "checkmark.circle.fill" :"circle") .font(.system(size:150)) .foregroundColor(isSelected ? Color(red: 112/255, green: 182/255, blue: 3/255) : Color(red: 170/255, green: 170/255, blue: 170/255) ) } } }恭喜你,我们完成了@State属性包装器的学习!下面我们学习下Binding的应用。在我们实际编程中,会存在状态共享的场景,以上面的“单选按钮”为例,我们可以创建一个状态描述。比如,在按钮下面加一个状态文字。我们使用垂直排布的结构,将一个Text和按钮进行上下排布。下一步,我们把Text抽离出一个子视图,具体做法可以查看之前的文章。然后记得修改子视图的名称。我们这里修改为titleView,当然你可以使用任何你想要的名称。//文字 struct titleView: View { var body: some View { Text("未开启") .fontWeight(.bold) .font(.system(size: 17)) .padding() } }之后,我们希望完成一个交互,当我们按钮为勾选时,title文字变为“已开启”。而按钮为关闭状态时,title文字为“未开启”。我们尝试用isSlected状态关联Text的文字,这和我们的按钮状态一样。此处用到了下面的代码:isSelected ? "" :""但这个时候我们会遇到一个报错信息,点开后的内容如上图所示。不要着急,遇到报错信息千万不要着急和烦躁,写代码这是正常的。我们看一下里面的内容,上面写的“找不到isSelected”。这是因为titleView是一个视图,而这个视图里面没有定义”isSelected”,isSelected只在ContentView里面。因此,这里引入了Binding绑定的概念,Binding共享了State定义的状态,State状态改变时Binding绑定的参数会一起改变。struct titleView: View { //绑定状态 @Binding var isSelected: Bool var body: some View { Text(isSelected ? "已开启" :"未开启") .fontWeight(.bold) .font(.system(size: 17)) .padding() } }使用@Binding,我们只需要填写与需要关联的状态一样的参数,并注明它的参数类型。状态参数为isSelected,它的类型为Bool。这时候,我们子视图就不报错了。但我们主视图报错了,这是因为在titleView引用了一个Bool类型的参数,但在ContentView视图中,不知道它的值是哪里来的。我们点击红点,点击Fix。代码告诉我们,在titleView视图中,有一个Binding绑定的参数需要做关联。这正好对应着ContentView的isSelected。我们可以用$进行绑定,代码如下:titleView(isSelected: $isSelected)这样表明了我们titleView里面的isSelected参数,是关联我们ContentView里面的isSelected参数。当我们主视图的isSelected发生改变时,共享状态给子视图。这样我们完成了基础的状态共享啦,当我们按钮为开启状态时,文字为“已开启”,当按钮为关闭状态时,文字为“未开启”。完整代码如下import SwiftUI struct ContentView: View { // 定义变量 @State var isSelected = false var body: some View { VStack { Button(action: { self.isSelected.toggle() }) { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.system(size: 150)) .foregroundColor(isSelected ? Color(red: 112 / 255, green: 182 / 255, blue: 3 / 255) : Color(red: 170 / 255, green: 170 / 255, blue: 170 / 255)) } //文字 titleView(isSelected: $isSelected) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } struct titleView: View { //绑定状态 @Binding var isSelected: Bool var body: some View { Text(isSelected ? "已开启" :"未开启") .fontWeight(.bold) .font(.system(size: 17)) .padding() } }在实际开发过程中,会遇到很多像这样的情况。在第一个页面更改一个状态,进入第二个页面时,对应的状态也要同步成一个状态。这时候,就可以在第一个页面用@State创建一个变量,然后在第二个页面用@Binding建立关联。不过要记得要在第一个页面用$进行绑定关联才能使用。如果说一个变量在所有页面都用到了,一层层关联岂不是很麻烦?没事,在往后的学习中,我们会学习到全局关联的知识点。慢慢来,走好每一步。如果本专栏对你有帮助,不妨点赞、评论、关注~
前言:写这篇文章已经快接近12点了,写了快2个小时,总的来说,要想讲清楚一个知识点还是没那么简单,首先语言不能太复杂,不能有太多专有名词,如果有些转有名词避不开,也要想办法举个例子。果然,蜕变的路上总是艰难和孤独的。今日职言:不要试图说服读者,不要以讲道理的方式推进话题。在本章中,你将学会如何使用循环遍历的方式创建一个列表,并可自定义参数动态生成列表。如果你接触过UIKit的话,应该会用过tableView组件创建列表,我们生活中用的很多App基本都是列表的形式。例如掘金的信息列表、手机系统设置、音乐列表等等……基本上大多数App,特别是资讯类App都少不了列表的存在。而在SwiftUI中,我们用List代替了原有tableView,使得代码更加简洁易懂。由于List组件在不同场景下的应用不同,本章节分成4个部分讲解。1、简单文字列表;2、图片+文字列表;3、列表数据整合;4、Identifiable协议的使用;第一部分:简单文字列表首先,我们先创建一个新项目,命名为SwiftUIList。我们在ContentView.swift文件中,创建一个简单的列表。List的构造方式和之前学习的VStack很类似,将内容包裹在里面形成列表。struct ContentView: View { var body: some View { //简单的列表 List { Text(“第1页") Text(“第2页") Text(“第3页") Text(“第4页") } } }我们看到List里面都是Text文本,而且只是内容不同。这时候,我们可以使用ForEach的方式把代码抽离出来,也就不需要写那么多相似的代码。struct ContentView: View { var body: some View { // 简单的列表 List { ForEach(1 ... 4, id: \.self) { index in Text("第 \(index)页") } } } }在使用ForEach遍历创建视图时,需要用id来标识内容,当里面的内容发现变化时,ForEach就可以自动更新UI。简单来读一下代码内容:我们传递给ForEach一个范围的值,用来循环遍历生成列表。而它的id(标识符)被设置为值本身self,也就是前面设置的1、2、3、4。然后用index参数存储循环的值。我们在这里遍历了4次,每一次展示一个Text,Text里面的文字是“第”+{index}+“页”,index的参数值从1~4;这样,我们就得到了一个列表。当然,还有更简单的遍历方法。struct ContentView: View { var body: some View { // 简单的列表 List { ForEach(1 ... 4, id: \.self) { Text("第 \($0)页") } } } }在这里,我们省略索引参数index,而使用简化的$0,它引用闭包的第一个参数,直接将数据集合传递给List。这样,也可以达到列表的效果,而且使得代码更加简单。第二部分:图片+文字列表好,下面进阶一下,我们尝试完成下面的UI设计稿。首先分析下它的结构。一个列表里,有Image、Text,他们是横向HStack排布。我们先在Assets.xcassets导入我们所需的图片。并且我们已经提前给图片命好名了,方便我们接下来使用它们。我们回到ContentView.swift文件中,创建2个数组,存放我们的图片和文字。//定义数组,存放数据 var myImages = ["weixin","weibo","qq","phone","mail"] var myNames = ["这是微信","这是微博","这是QQ","这是电话","这是邮箱"]我们看到报错了,但又没有完全报错。这是因为我们定义了一个动态数组,但在代码中没有用到,所以系统告诉我们定义的数组名称没有被使用罢了。没事的,我们继续。我们在body里面创建我们需要的代码。struct ContentView: View { //定义数组,存放数据 var myImages = ["weixin","weibo","qq","phone","mail"] var myNames = ["这是微信","这是微博","这是QQ","这是电话","这是邮箱"] var body: some View { // 列表 List(myImages.indices, id: \.self) { index in HStack { Image(self.myImages[index]) .resizable() .frame(width: 40, height: 40) .cornerRadius(5) Text(self.myNames[index]) } } } }我们还是构建了一个列表,不过使用myImages作为目录,也就是myImages.indices。然后图片遍历用myImages数组,文字遍历用myNames数组。是不是很简单,比起以前用UIKit的时候,要给cell协议和声明,SwiftUI几行代码就搞定了。第三部分:列表数据整合下面,我们再进阶一下。上面的代码中,我们发现如果是图片+文字,那么我们创建了2个数组,如果是更加复杂的场景,我们岂不是要建立一堆的数组数据?不不不,这肯定不够优雅。最好的方式应该是,无论我们多少数组数据,我们都用一个数组包裹住。我们回到UI稿中。有没有办法,把Image和Text定义出来,然后Image和Text是一个数组?有的!这时候,我们需要创建一个结构体,叫做Message,并定义好里面的变量。代码如下:struct Message { var name: String var image: String }使用这个Message结构体,我们将原来的myImages、myNames数组组合成一个数组。我们定义了一个数组Messages,它里面的内容是Message结构体。代码如下:// 定义数组,存放数据 var Messages = [ Message(name: "这是微信", image: "weixin"), Message(name: "这是微博", image: "weibo"), Message(name: "这是QQ", image: "qq"), Message(name: "这是电话", image: "phone"), Message(name: "这是邮箱", image: "mail") ]构建好了以后,我们回到body里面,把里面的引用的参数值换成数组Messages,使用结构体Message遍历数据,List中使用image属性作为唯一的标识符。同时,要把里面Image的参数引用结构体Message的image参数,Text引用结构体Message的name参数。代码如下:// 列表 List(Messages, id: \.image) { Message in HStack { Image(Message.image) .resizable() .frame(width: 40, height: 40) .cornerRadius(5) Text(Message.name) } }那么,最终的结果和我们之前做的效果是一样的。唯一不一样的是,我们的代码看起来优雅多了。小结一下:我们创建了一个结构体Message,它定义了2个变量,1个是image图片,是Strring类型,另一个是name名称,也是Strring类型。然后我们定义个一个数组Messages(注意加了S),这个数组里面是结构体Message,并赋予了结构体里面2个变量的值;然后在在body主代码块List里面,用Messages数据作为引用值数据,然后用结构体Message遍历数据。最后把里面的Image和Text中的值换成结构体中变量的值。第四部分:Identifiable协议的使用完成第三部分以后,我们的代码List就完美了么?并不!因为这个:List(Messages, id: \.image)我们使用了image为Messages数组的唯一标识符,也就是List是通过image的唯一性找到对应数组的数据。这样,我们会面临一个问题,如果我有2个图片是一样的,但是它的name不一样。我们尝试把Messages数组的数据换成下面这样:// 定义数组,存放数据 var Messages = [ Message(name: "这是微信", image: "weixin"), Message(name: "这是第二个微信号", image: "weixin") ]我们发现,如果我们使用image作为id,也就是唯一标识符的话。如果我的数组里有2个image,如果它们的值相同,那么SwiftUI会认为这两个是同一个东西。也就是,我都是图片都是“微信”,但名称不一样,但计算机认为两个都是微信,而且值一样,这是因为image作为id是唯一的。这时候,我们该怎么办?我们期望的结果是,Messages数组里,每一个结构体的数据都是唯一的。那么我们就不能在body构建唯一的标识符,应该在传值之前就在Message结构体里构建唯一的id。struct Message { var id = UUID() var image: String var name: String }在代码中,我们添加了id属性,并用唯一标识符初始化它。UUID()函数用于生成一个全局惟一的随机标识符。UUID由128位数字组成,因此从理论上讲,拥有两个相同标识符的可能性几乎为零。然后,我们把body代码中的id,引用Message结构体的id。// 列表 List(Messages, id: \.id) { Message in HStack { Image(Message.image) .resizable() .frame(width: 40, height: 40) .cornerRadius(5) Text(Message.name) } }这样,我们就完成了List数据源的唯一。再科普一个知识点。我们还可以设置结构体Message遵循Identifiable协议。这样,遵循Identifiable的结构体就可以自动跟踪它的id作为唯一标识符,我们也就不需要在body中指定id了。完整代码如下:import SwiftUI struct ContentView: View { // 定义数组,存放数据 var Messages = [ Message(image: "weixin", name: "这是微信"), Message(image: "weixin", name: "我的第二个微信号") ] var body: some View { // 列表 List(Messages) { Message in HStack { Image(Message.image) .resizable() .frame(width: 40, height: 40) .cornerRadius(5) Text(Message.name) } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } struct Message: Identifiable { var id = UUID() var image: String var name: String }恭喜你!完成了List的学习!如果本专栏对你有帮助,不妨点赞、评论、关注~
前言:这个专题已经写了半个月了,累计更新15个章节,接近4W字+,在这里打个卡先。今日职言:永远不要说别人的坏话。在本章中,你将学会使用Form构建简单的表单,并同步学习Picker选择器、Toggle开关、Stepper步进器等简单组件的使用。在iOS应用中,特别是待办事项、笔记类App,B端App用户注册、账号登录等页面,我们常常可以看到需要用户填写的表单。在系统设置中,我们也可以看到由Form表单构建的页面:在之前的章节中,我们使用List构建了简单的列表,那么List构建列表和Form构建表单有什么异同点呢?相同点:List列表和Form表单都是SwiftUI对UITableView的一个封装,在实现的机理基本上是一样的。不同点:Form比较List,样式只有一,List 可以有几种样式,List上可以直接使用快捷数据源遍历,Form需要配合ForEach才能达到相同的效果。本章节将分成4个部分讲解。1、构建简答的Form表单;2、Toggle开关;3、Picker选择器;4、Stepper步进器;那么,让我们开始吧!第一部分:构建简答的Form表单首先,我们先创建一个新项目,命名为SwiftUIForm。我们以iPhone的系统设置页面为例,尝试完成下面的页面:首先我们先做一个标题,这里使用NavigationView,当然还有居中的标题。NavigationView { //内容 Text("Hello, world!") .padding() //导航栏标题 .navigationBarTitle("通用", displayMode: .inline) }Form表单的用法和List基本一致,用Form将需要展示的内容包裹起来。Form的基础用法:Form { Section() { //需要展示的内容 } }Section是分段的意思,一个Section代表一个段落。我们使用Form表单将文字内容包裹起来,看看效果。就这样,我们得到了一个有一个段落的表单内容。那么我们和UI稿一样,分成几个Section段落,然后也同步修改Text的文字内容。于是我们就得到了系统设置基本一致的页面。就这样,我们完成了一个基础Form的创建。是不是很简单。再科普一个知识点,像下面“自动更新”的页面,我们发现在整个Section段落后有“跟随”的文字。这个可不是用Text加到body外面,而是可以通过Section段落自己的参数设置的。Section段落可以设置它的表头文字,和表尾文字。设置的方法如下:Section(header: Text(“我是表头文字”),footer: Text(“我是表尾文字”)) { //需要展示的内容 }这里我们只用到了Section段落尾巴的文字。那可以只使用footer,删掉整个header内容。代码如下:// 表单 Form { Section(footer: Text("下载后在夜间自动安装软件更新。更新安装前您会收到通知。iPhone 必须为充电状态并接入 Wi-Fi以完成更新。")) { // 需要展示的内容 Text("下载iOS更新") Text("安装iOS更新") } }第二部分:Toggle开关承接上面第一部分的内容。在自动更新的页面,我们可以看到Text("下载iOS更新”)、Text("安装iOS更新")右边都有一个开关。Toggle开关的创建方法也很简单,首先我们需要初始化定义Toggle开关的状态,默认为关闭false。@State var isDownload = false //是否下载 @State var isInstall = false //是否安装然后是Section段落里每一个文字都有一个开关,我们将Toggle包裹住Text。同时要绑定开关的状态。Toggle(isOn: $isDownload) { Text("下载iOS更新") } Toggle(isOn: $isInstall) { Text("安装iOS更新") }于是乎,我们就完成了Toggle开关的创建。点击模拟器的Preview按钮,点击下开关,可以发现我们实现了开关的切换了。第三部分:Picker选择器在使用Form表单的时候,特别是在B端的App,经常会遇到一个业务场景。用户在设置的时候,需要修改一项配置。以下图iOS的隔空投送为例:我们先完成基础的页面样式。完整代码如下:NavigationView { // 表单 Form { Section { Text("隔空投送") } } // 导航栏标题 .navigationBarTitle("通用", displayMode: .inline) }下面,我们分析下内容:点击隔空投送,我们进入一个新页面,可以选择“接收关闭”、“仅限联系人”、“所有人”。这里,我们就可以使用Picker选择器来完成这个操作。首先,我们需要先定义好需要可选项的内容,我们用一个私有private的数组displayState来表示。private var displayState = [ "接收关闭", "仅限联系人", "所有人"]数组的名称可以自定义,这里用的displayState是“显示状态”的意思。当然,数组是开放使用public公开的,还是private私有的,根据实际业务来定。我们还需要声明一个状态变量来存储用户选择的选项。@State private var selectedNumber = 0这里的0,代表的是选中的第一个选项。如果displayState数组是三个可选项,那么它的选择对应的是0、1、2。接下来,我们尝试构建Picker选择器,代码如下:Picker(selection: $selectedNumber, label: Text("隔空投送")) { //选择器可选项内容 }点击模拟器的Preview按钮,我们可以体验到选择器的效果。点击“隔空投送”,会进入一个新页面。但我们看到页面里面是空的,这是因为Picker选择器选择的内容我们还没有构建。在之前我们已经定一个一个状态的数组displayState。private var displayState = [ "接收关闭", "仅限联系人", "所有人"]接下来我们需要在Picker选择器选择的内容里遍历出内容来。遍历数组内容的方法我们可以用之前学习过的ForEach循环的方法。遍历数组数据为0~displayState数组内数组的数量count,id使用数组本身,文字展示使用$绑定数组值;//选择器可选项内容 ForEach(0 ..< displayState.count, id: \.self) { Text(self.displayState[$0]) }运行模拟器看看,我们已经实现了Picker选择器的效果。完整代码如下:import SwiftUI struct ContentView: View { private var displayState = [ "接收关闭", "仅限联系人", "所有人"] @State private var selectedNumber = 0 var body: some View { NavigationView { // 表单 Form { Section { Picker(selection: $selectedNumber, label: Text("隔空投送")) { //选择器可选项内容 ForEach(0 ..< displayState.count, id: \.self) { Text(self.displayState[$0]) } } } } // 导航栏标题 .navigationBarTitle("通用", displayMode: .inline) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }第四部分:Stepper步进器下面我们再学习一种在Form表单中很常见使用的组件,叫做Stepper步进器。可能我们最常见到它应该是在一些电商平台上,或者外卖平台。我们选中一个商品加入购物车中,此时的商品数量为1件,我们通过点击增加或者减少按钮,来修改商品数量。这个点击增加和减少的操作,在swiftui中,就是Stepper步进器。首先,我们需要定义一个Stepper步进器初始值,比如从1开始。@State private var amount = 1然后承接上面Picker选择器的内容,我们再建立一个Section段落,用来放置Stepper步进器代码。//构建步进器 Stepper(onIncrement: { //点击+号时做什么操作 }, onDecrement: { //点击-号时做什么操作 }) { //步进器内容部分 } 我们简单给Stepper步进器写一些逻辑:Stepper步进器初始值为1; 当我们点击”+”时,amount数值+1; 然后设置最大值为99,超过99,就不能再加了; 当我们点击”-“时,amount数值-1; 然后设置最小值为1,小于1,就不能再减少了;实现代码如下://步进器 Stepper(onIncrement: { self.amount += 1 if self.amount > 99 { self.amount = 99 } }, onDecrement: { self.amount -= 1 if self.amount < 1 { self.amount = 1 } }) { //步进器文字 Text("\(amount)") } 恭喜你!完成了本章关于Form表单的学习!内容可能有点多,需要点时间吸收。快来动手试试吧!如果本专栏对你有帮助,不妨点赞、评论、关注~
一起养成写作习惯!前言:刚开始学习编程的时候,确实太苦了,官网教材例子也做了,但好像也做不出什么实际的东西。后来发现真正干一行是不一样的,每一个有每一行的规则和定律。我们能做的,就是学会它,遵守它,最终变成制定标准的人。今日职言:遇到困难时,我们要迎难而上,而不是畏畏缩缩。找到一个明确的目标,不在乎别人的眼光,努力去做就好了。承接上一章节的内容。我们看到3个定价方案是横向排列的,最简单的编程方式将定价方案包裹在HStack里,然后再在里面按照第一个定价方案复制2个。鼠标移动到定价方案VStack的位置,键盘按住command键,单击鼠标,选择Embed in HStack,那么我们在之前VStack外面会再包裹一层HStack。在HStack里面,我们复制多2个定价方案,修改文字、颜色。备注:记得要写注释哦~// 定价方案 HStack { //连续包月 VStack { Text("连续包月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥18") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) } .padding(20) .background(Color("faf7f3")) .overlay(RoundedRectangle(cornerRadius: 6) .stroke(Color(red: 202 / 255, green: 169 / 255, blue: 106 / 255), lineWidth: 2)) //1个月 VStack { Text("1个月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥30") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) } .padding(20) .background(Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)) .cornerRadius(10) //12个月 VStack { Text("12个月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥228") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) Text("¥19.00/月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) } .padding(20) .background(Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)) .cornerRadius(10) }编程后可以发现,三个定价方案的大小都不一样。这是因为我们的View取决于我们内部元素,视图的大小会自定义内部的内容。如果我们需要让三个定价方案同样的大小,我们需要引入.frame()修饰符。.frame()修饰符允许用户定义视图的大小,无论内部元素怎么变化,视图都可以维持固定的大小。// 定价方案 HStack { //连续包月 VStack { Text("连续包月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥18") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90) .padding(20) .background(Color("faf7f3")) .overlay(RoundedRectangle(cornerRadius: 6) .stroke(Color(red: 202 / 255, green: 169 / 255, blue: 106 / 255), lineWidth: 2)) //1个月 VStack { Text("1个月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥30") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90) .padding(20) .background(Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)) .cornerRadius(10) //12个月 VStack { Text("12个月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥228") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) Text("¥19.00/月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90) .padding(20) .background(Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)) .cornerRadius(10) }我们在每个视图后都加上了.frame(minWidth: 0, maxWidth: .infinity, minHeight: 90)修饰符。我们解释下这句代码的意思,最小宽度为0,最大宽度为自适应,最小高度为90。如果我们设置了最小的高度,那么它的最小高度是固定的,而最大宽度会跟随系统自适应。像我们有三个定价方案,最小高度固定都是90,最小宽度为0,最大宽度就按照屏幕宽度平均分了。我们看到三个定价方案两边紧贴着屏幕,需要留点空间,那么需要对整个HStack设置.padding。// 定价方案 HStack { //连续包月 VStack { Text("连续包月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥18") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90) .padding(20) .background(Color("faf7f3")) .overlay(RoundedRectangle(cornerRadius: 6) .stroke(Color(red: 202 / 255, green: 169 / 255, blue: 106 / 255), lineWidth: 2)) //1个月 VStack { Text("1个月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥30") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90) .padding(20) .background(Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)) .cornerRadius(10) //12个月 VStack { Text("12个月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥228") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) Text("¥19.00/月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90) .padding(20) .background(Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)) .cornerRadius(10) } .padding(.horizontal)根据上面内容,我们学习了VStack、HStack,我们看一个第一个定价方案。第一个定价方案的顶部有个“首月特惠”,它是覆盖在视图上面的,而且是覆盖了一半。这时候我们就需要用到ZStack。我们先找到第一个定价方案的视图。鼠标移动到定价方案VStack的位置,键盘按住command键,单击鼠标,选择Embed in ZStack,那么我们在之前VStack外面会再包裹一层ZStack。我们在两个视图分割点写上注释,便于我们区分代码内容。在连续包月的VStack下补充“首月特惠”的代码,其实就是简单的Text("首月特惠”),再加上一些修饰符美化。编码完可以看到Text视图将覆盖在定价视图上面。// 连续包月 ZStack { VStack { Text("连续包月") .fontWeight(.bold) .font(.system(size: 17)) .foregroundColor(Color(red: 190 / 255, green: 188 / 255, blue: 184 / 255)) Text("¥18") .fontWeight(.bold) .font(.system(size: 30)) .foregroundColor(Color(red: 239 / 255, green: 129 / 255, blue: 112 / 255)) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90) .padding(20) .background(Color("faf7f3")) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(red: 202 / 255, green: 169 / 255, blue: 106 / 255), lineWidth: 2)) // 首月特惠 Text("首月特惠") .font(.system(size: 14)) .fontWeight(.bold) .foregroundColor(.white) .padding(5) .background(Color(red: 202 / 255, green: 169 / 255, blue: 106 / 255)) .cornerRadius(4) }要调整文本的位置,我们可以使用.offset修饰符,它是设置偏移量的。简单来说,就是修饰被选定的视图,应该移动到哪里。X,Y分别对应坐标轴位置,X轴正数为右移,负数为左移,Y轴正数下移,负数上移。// 首月特惠 Text("首月特惠") .font(.system(size: 14)) .fontWeight(.bold) .foregroundColor(.white) .padding(5) .background(Color(red: 202 / 255, green: 169 / 255, blue: 106 / 255)) .cornerRadius(4) .offset(x: 0, y: -65)恭喜你!页面完成度有80%啦!我们看到标题“会员套餐”和“解锁高级功能”是左对齐,但标题和定价方案目前是还是居中对齐。我们希望整个文字和定价方案是左对齐,这时候我们引入一个新的参数,叫做Spacer()。Spacer()相当于空白视图,什么都没有,但可以在Stack中充当“挤开”其他视图的作用。我们可以看到标题部分和Spacer()是横向分布的关系,那么我们先把整个标题包裹在一个HStack里。鼠标移动到定价方案VStack的位置,键盘按住command键,单击鼠标,选择Embed in HStack,那么我们在之前标题VStack外面会再包裹一层HStack。我们在标题HStack内,标题VStack内增加Spacer(),得到的效果就是标题被挤开到左边了。// 标题 HStack { VStack(alignment: .leading, spacing: 10) { Text("会员套餐") .fontWeight(.bold) .font(.system(.title)) Text("解锁高级功能") .fontWeight(.bold) .font(.system(.title)) } //间隔符 Spacer() }最后,我们再在整个标题HStack外增加一个.padding,保持屏幕两边的边距,就得到了我们想要的效果。// 标题 HStack { VStack(alignment: .leading, spacing: 10) { Text("会员套餐") .fontWeight(.bold) .font(.system(.title)) Text("解锁高级功能") .fontWeight(.bold) .font(.system(.title)) } //间隔符 Spacer() } .padding如果最后我们还想把整个视图“挤”到上面一点的位置。不妨把标题和定价的组合外面再包裹一个VStack,再用Spacer()试试吧!
前言在本章中,你将学会构建Search搜索进行列表搜索和TabView底部导航。在上一章节中,我们完成了一个简单的ColourAtla色卡App,接下来我们继续完善App的相关内容。SearchBar搜索栏首先是SearchBar搜索栏,搜索栏的作用是根据列表的内容进行检索,找出我们需要的色卡。我们使用TextField输入框来构建SearchBar搜索栏,我们先声明一个变量来存储我们的搜索内容。示例:@State var search = ""然后构建SearchBar搜索栏的样式。示例:// MARK: 搜索 private var SearchBarView: some View { TextField("搜索颜色值", text: $search) .padding(7) .padding(.horizontal, 25) .background(Color(.systemGray6)) .cornerRadius(8) .overlay( Image(systemName: "magnifyingglass") .foregroundColor(.gray) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .padding(.leading, 8) ) .padding(.horizontal, 10) }上述代码中,我们构建了一个新的视图SearchBarView,使用TextField输入框作为搜索栏,然后绑定search内容,然后使用修饰符完善了SearchBar搜索栏的样式。我们先替换原有的CardTitleView看看效果:搜索方法为了实现搜索颜色名称的交互,我们需要提供一个方法,搜索列表中的色卡。示例:// MARK: 搜索颜色方法 func searchColor() { let query = search.lowercased() DispatchQueue.global(qos: .background).async { let filter = cardItems.filter { $0.cardColorRBG.lowercased().contains(query) } DispatchQueue.main.async { withAnimation(.spring()) { self.cardItems = filter } } } }上述代码中,我们创建了一个搜索颜色的方法searchColor,通过根据搜索文字内容search,查找cardItems色卡数组中的cardColorRBG卡片颜色值参数,如果匹配成功,就在cardItems色卡数组中找到并展示这个颜色。我们在SearchBarView视图中,当我们TextField输入框内容变化时调用这个方法。示例:.onChange(of: search) { _ in if search != "" { searchColor() } else { search = "" getColors() } }上述代码中,我们在TextField输入框搜索内容search变量改变时,且search输入内容不为空时调用searchColor搜索颜色的方法,当search输入内容为空时,又重新调用getColors获取颜色的方法,这样,我们就实现了搜索颜色值的交互。搜索栏切换接下来,我们来实现标题栏和搜索栏的切换。我们尝试在标题栏右侧加入一个搜索图标,点击搜索图标切换到搜索栏,在搜索栏输入框右侧也增加一个取消按钮,点击取消又回到标题栏中。先声明一个存储变量showSearchBar用于判断切换状态。@State var showSearchBar = false然后构建搜索按钮的样式和取消按钮的样式。示例:// MARK: 搜索icon private var SearchButtonView: some View { Button(action: { withAnimation(.easeOut) { showSearchBar.toggle() } }, label: { Image(systemName: "magnifyingglass") .font(.system(size: 20, weight: .bold)) .foregroundColor(.gray) }) } // MARK: 取消按钮 private var CloseButtonView: some View { Button(action: { withAnimation(.easeOut) { search = "" getColors() showSearchBar.toggle() } }, label: { Text("取消") .foregroundColor(.gray) }) }上述代码中,我们创建了两个视图SearchButtonView和CloseButtonView。在SearchButtonView视图中,我们使用系统搜索图标做一个搜索按钮操作,点击时更改showSearchBar的状态。在CloseButtonView视图中,我们使用文字按钮做一个取消按钮的操作,点击时清空输入框的内容search,并且更改showSearchBar的状态,同时调用getColors获取颜色的方法。完成这些后,我们进行样式的组装,示例:// MARK: 搜索切换 private var SwitchSearchBar: some View { HStack(spacing: 20) { if showSearchBar { SearchBarView CloseButtonView } else { CardTitleView Spacer() SearchButtonView } } .padding(.top, 20) .padding(.bottom, 10) .padding(.horizontal) .zIndex(1) }上述代码中,我们构建了一个新的视图SwitchSearchBar。在SwitchSearchBar视图中,我们根据showSearchBar的状态切换展示搜索栏还是标题栏的内容,并把SwitchSearchBar视图在ContentView主视图中呈现。我们预览下效果:TabView底部导航我们创建一个新的SwiftUI文件,命名为TabberView。struct TabberView: View { @State private var selectedTab = 0 var body: some View { TabView(selection: $selectedTab) { ContentView() .tabItem { if self.selectedTab == 0 { Image(systemName: "house") } else { Image(systemName: "house.fill") } Text("首页") } .tag(0) Text("我的") .tabItem { if self.selectedTab == 1 { Image(systemName: "person") } else { Image(systemName: "person.fill") } Text("我的") } .tag(1) } .accentColor(Color.Hex(0x409EFF)) } }上述代码中,我们使用TabView来构建底部导航。我们声明一个变量selectedTab来跟踪当前选中的是哪一个菜单,然后在TabView中绑定selectedTab的值。我们使用tabItem修饰符来显示当前菜单的内容,并且根据 selectedTab选中的状态修饰底部菜单的图标和文字。最后我们给选中的菜单加上accentColor选中颜色。我们预览下效果:我们把之前章节完成的MineView我的视图也换到TabView底部菜单中,预览看下效果:LoadingView加载动画加载样式之前我们使用ProgressView作为加载中的缺省动画,作为一个ColourAtla色卡App,使用ProgressView作为加载动画,这不够优雅。我们可以使用SwiftUI官方的rotationEffect修饰符构建一个加载动画,先创建一个新的SwiftUI文件,命名为LoadingView。首先声明一个变量存储旋转状态,然后构建一个旋转的图片icon。import SwiftUI struct LoadingView: View { @State var show: Bool = false var body: some View { Image(systemName: "sun.min.fill") .resizable() .foregroundColor(Color.Hex(0xFAD0C4)) .aspectRatio(contentMode: .fit) .frame(width: 60, height: 60) .rotationEffect(.degrees(show ? 360 : 0)) } }上述代码中,我们使用Image构建了一个加载的图标,然后使用rotationEffect修饰符根据show的状态进行旋转。加载方法为了让加载图标循环旋转,我们创建一个方法doAnimation让show的状态每1秒切换1次。func doAnimation() { withAnimation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)) { show.toggle() } }完成后,我们在Image展示时调用doAnimation的方法。最后,我们在ContentView用LoadingView替换原来的ProgressView,然后查看下效果:项目预览快来动手试试吧!如果本专栏对你有帮助,不妨点赞、评论、关注~我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。
今日职言:如果内容重要,就要重复强调重要的内容。在本章中,你将学会如何使用Gestures手势和Animations动画实现SwipeCard卡片滑动的效果。在很多交友类的App当中,我们可以看到有一个“向左向右滑动卡片”的交互,用户向右滑动可以给照片喜欢点赞,向左滑动可以给照片点不喜欢。那么在这章,我们来试试构建一个类似SwipeCard卡片滑动交互效果的简单应用。好了,我们开始吧!首先,创建一个新项目,命名为SwiftUISwipeCard。我们先在Assets.xcassets导入一批图片,作为素材使用。可以找一些风景图片,或者人像图片、食物图片,只要是一个系列的图片集就行。不要忘记给图片重新命名,以便于我们在代码更好地找到和引用图片。在实现滑动功能之前,让我们先创建主要的UI页面,我们将把主页面分成三个部分:1. TopBarMenu顶部导航栏CardView卡片视图3.BottomBarMenu底部菜单栏TopBarMenu顶部导航栏在这里我们创建一个新的结构体页面来展示TopBarMenu顶部导航栏视图,我们命名为TopBarMenu。代码如下://顶部导航栏 struct TopBarMenu: View { var body: some View { HStack { Image(systemName: "ellipsis.circle") .font(.system(size: 30)) Spacer() Image(systemName: "heart.circle") .font(.system(size: 30)) }.padding() } }这里我们没有使用.NavigationView顶部导航栏,是因为在很多时候,我们的导航栏都需要很多定制化的功能,而在.NavigationView顶部导航栏中可能很难支持到我们实际的业务,所以“成熟的”程序猿都喜欢自己写顶部导航栏样式。上面我们做的TopBarMenu顶部导航栏很简单,就2张图片,使用横向HStack排布。然后我们在CardView里引用TopBarMenu顶部导航栏视图,效果如下:CardView卡片视图接着,我们创建一个新的结构体页面来展示卡片视图,命名为CardView。代码如下://卡片视图 struct CardView: View { var body: some View { Image("image01") .resizable() .frame(minWidth: 0, maxWidth: .infinity) .cornerRadius(10) .padding(.horizontal, 15) .overlay( VStack { Text("图片01") .font(.system(.headline, design: .rounded)).fontWeight(.bold) .padding(.horizontal, 30) .padding(.vertical, 10) .background(Color.white) .cornerRadius(5) } .padding([.bottom], 20), alignment: .bottom ) } }CardView卡片视图也非常简单,我们放在一个Image图片,让将一个Text文字“悬浮”在图片底部。我们在CardView里引用CardView卡片视图,由于CardView卡片视图和TopBarMenu顶部导航栏是纵向排列,我们使用VStack包裹住。struct ContentView: View { var body: some View { VStack { TopBarMenu() CardView() } } }BottomBarMenu底部菜单栏底部导航栏也是如此,我们创建一个新的结构体页面叫做BottomBarMenu。代码如下:// 底部导航栏 struct BottomBarMenu: View { var body: some View { HStack { Image(systemName: "xmark") .font(.system(size: 30)) .foregroundColor(.black) Button(action: { }) { Text("立即选择") .font(.system(.subheadline, design: .rounded)).bold() .foregroundColor(.white) .padding(.horizontal, 35) .padding(.vertical, 15) .background(Color.black) .cornerRadius(10) }.padding(.horizontal, 20) Image(systemName: "heart") .font(.system(size: 30)) .foregroundColor(.black) } } }BottomBarMenu底部导航栏也是我们自己写的,使用3个元素,2个Image图片,1个Text文字按钮。然后也在ContentView主要页面中展示它,效果如下:我们进一步美化下样式,使用Spacer()分开CardView卡片视图和BottomBarMenu底部导航栏视图,我们保持最小20的区域,就得到了下面的效果。struct ContentView: View { var body: some View { VStack { TopBarMenu() CardView() Spacer(minLength: 20) BottomBarMenu() } } }好了,基础的样式我们做完了。交互逻辑分析接下来,可以实现SwipeCard卡片滑动的效果了。先解释一下SwipeCard卡片滑动的原理,你可以它想象成一组叠在一起的卡片,每张卡片都显示一张照片。我们将最上面的那张卡,即第一张图片,稍微向左或向右刷一下,就会打开下面的下一张卡片,也就是第二张图片。如果你放开卡片,卡片会回到原来的位置。但如果你用力滑动图片卡片,就可以将图片卡片“丢掉”,系统就会将把第二张图片向前拉变成最上面的图片展示。我们了解了原理后,我们先实现CardView卡片部分的内容。这里使用ZStack将一堆卡片“堆在”一起,而图片卡片的遍历方式之前的章节已经学过。//创建Album定义变量 struct Album: Identifiable { var id = UUID() var name: String var image: String } //创建演示数据 var album = [ Album(name: "图片01", image: "image01"), Album(name: "图片02", image: "image02"), Album(name: "图片03", image: "image03"), Album(name: "图片04", image: "image04"), Album(name: "图片05", image: "image05"), Album(name: "图片06", image: "image06"), Album(name: "图片07", image: "image07"), Album(name: "图片08", image: "image08"), Album(name: "图片09", image: "image09") ]由于我们之前定义的CardView卡片视图中使用的是Image图片和Text文字。这里我们定义两个常量替换它,这样我们就可以在ContentView主视图定义的值了。let name: String let image: String然后,我们在ContentView主视图使用ZStack包裹CardView卡片视图,再使用ForEach循环遍历album数组所有数据。//卡片视图 ZStack { ForEach(album) { album in CardView(name: album.name, image: album.image) } }我们发现,模拟器突然换了一张图片,这是因为我们定义的album图片数组,使用ForEach循环时是一张张遍历的,最后遍历完是album图片数组最后一张图片。在ForEach循环中,第一张图片放在了最底下。因此,最后一张图片也就成了最上面的照片。因此,虽然我们实现了album图片数组的遍历,但还是存在两个问题:1、本该是第一张图片,现在变成了最后一张。2、现在我们只有9张图片卡片,但如果之后我们有更多的图片卡片的时候,我们是否应该为每张图片创建一个卡片视图?album数组图片排序问题我们一个个解决,首先第一个问题,卡片顺序的问题。好在SwiftUI提供了zIndex修饰符来来确定ZStack中视图的顺序,zIndex值越高,视图层级也就越高。我们创建一个方法,来得到卡片视图的zIndex值。//获得图片zIndex值 func isTopCard(cardView: Album) -> Bool { guard let index = album.firstIndex(where: { $0.id == cardView.id }) else { return false } return index == 0 }上面的方法函数接受一个卡片视图,并找出它的索引,告诉我们这个卡片视图是不是最上面的那个。接下来,我们在CardView卡片视图引用这个方法。.zIndex(self.isTopCard(cardView: album) ? 1 : 0)我们为每个卡片视图添加了zIndex修饰符,最上面的卡片我们给它赋了一个更高的zIndex值。于是乎,我们得到了第一张图片作为顶部卡片展示。视图层级问题接下来,我们解决第二个问题。如果我们以后有无数个卡片,如果要创建无数个视图显然不现实,我们是不是可以想想其他方法?方法也很简单,其实想想,我们只需要2个卡片视图就行了,滑动一个,就显示另一个卡片视图,再滑动走一个,又回来我们第一个视图,只是展示的图片不一样就行了。这样不管多少图片,我们只需要2个卡片视图来回切换就可以完成我们想要的效果。说干就干。按照原理,我们就不需要初始化那么多图片结构,只需要前2个,当第一个卡片视图被丢掉,我们就添加第二个。//创建2个卡片视图 var albums: [Album] = { var views = [Album]() for index in 0..<2 { views.append(Album(name: album[index].name, image: album[index].image)) } return views }()由于我们定义了一个新的数组albums,别忘了在获得图片zIndex值的方法里,要把index参数读取的值要换成判断albums数组。这样,我们就只构建了2个视图,就完成了图片数组的遍历展示。完整代码如下:import SwiftUI //创建Album定义变量 struct Album: Identifiable { var id = UUID() var name: String var image: String } //创建演示数据 var album = [ Album(name: "图片01", image: "image01"), Album(name: "图片02", image: "image02"), Album(name: "图片03", image: "image03"), Album(name: "图片04", image: "image04"), Album(name: "图片05", image: "image05"), Album(name: "图片06", image: "image06"), Album(name: "图片07", image: "image07"), Album(name: "图片08", image: "image08"), Album(name: "图片09", image: "image09")] //创建2个卡片视图 var albums: [Album] = { var views = [Album]() for index in 0..<2 { views.append(Album(name: album[index].name, image: album[index].image)) } return views }() struct ContentView: View { var body: some View { VStack { //顶部导航栏 TopBarMenu() //卡片视图 ZStack { ForEach(albums) { album in CardView(name: album.name, image: album.image) .zIndex(self.isTopCard(cardView: album) ? 1 : 0) } } Spacer(minLength: 20) //底部导航栏 BottomBarMenu() } } //获得图片zIndex值 func isTopCard(cardView: Album) -> Bool { guard let index = albums.firstIndex(where: { $0.id == cardView.id }) else { return false } return index == 0 } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } // 顶部导航栏 struct TopBarMenu: View { var body: some View { HStack { Image(systemName: "ellipsis.circle") .font(.system(size: 30)) Spacer() Image(systemName: "heart.circle") .font(.system(size: 30)) }.padding() } } //卡片视图 struct CardView: View { let name: String let image: String var body: some View { Image(image) .resizable() .frame(minWidth: 0, maxWidth: .infinity) .cornerRadius(10) .padding(.horizontal, 15) .overlay( VStack { Text(name) .font(.system(.headline, design: .rounded)).fontWeight(.bold) .padding(.horizontal, 30) .padding(.vertical, 10) .background(Color.white) .cornerRadius(5) } .padding([.bottom], 20), alignment: .bottom ) } } // 底部导航栏 struct BottomBarMenu: View { var body: some View { HStack { Image(systemName: "xmark") .font(.system(size: 30)) .foregroundColor(.black) Button(action: { }) { Text("立即选择") .font(.system(.subheadline, design: .rounded)).bold() .foregroundColor(.white) .padding(.horizontal, 35) .padding(.vertical, 15) .background(Color.black) .cornerRadius(10) }.padding(.horizontal, 20) Image(systemName: "heart") .font(.system(size: 30)) .foregroundColor(.black) } } }未完待续由于SwipeCard卡片滑动效果涉及的内容太多,为帮助消化,这里分为上下两章来写。SwipeCard卡片滑动效果的使用(上)的部分就只完成了基础的样式和一些准备工作,涉及到的知识点很多,望花点时间消化。快来动手试试吧!如果本专栏对你有帮助,不妨点赞、评论、关注~
今日职言:每一次金融危机都是蓄谋已久的精确定向爆破,熠熠夺目的崭新金融大厦总是建筑在成千上万破产者的废墟之上。承接上一章的内容,我们继续实现下如何使用SwiftUI构建一个Banner轮播图。上一章,我们使用HStack横向视图和Gestures手势做一个Banner轮播图,完成了基础的交互,但还不算全部完成。这一章,我们将学习Banner轮播图的交互,包含移动Banner轮播图的动画,以及点击Banner轮播图进入详情页。那么,我们开始吧。Animation动画效果我们通过GeometryReader几何视图的outerView设置了CardView卡片的大小,但它是固定的。.frame(width: outerView.size.width, height: outerView.size.height)我们了解下Banner轮播图的展示逻辑,它是当前显示的CardView卡片会大一些,切换的时候,另外的会小一些,当我们将卡片滑动到中间展示时,它又会放大。我们要做的就是这个效果。.frame(width: outerView.size.width, height: self.currentIndex == index ? 250 : 200)我们可以尝试根据currentIndex当前索引位置来控制CardView卡片的高度,如果它在当前,那么height高度为250,如果不是,height高度为200。为了效果好看,我们还可以调整CardView卡片的透明度,不在当前展示的卡片,我们让它“模糊”一点,突出中间的卡片。.opacity(self.currentIndex == index ? 1.0 : 0.7)最后,我们把动画效果加到整个GeometryReader几何视图中。.animation(.interpolatingSpring(mass: 0.6, stiffness: 100, damping: 10, initialVelocity: 0.3),value: offset)我们开启了动画,动画呈现的方式为interpolatingSpring弹性旋转动画。我们运行下模拟器预览下效果。恭喜你,完成了Banner轮播图的动画效果!DatailView详情页下面,我们来完成下点击Banner轮播图进入DetailView详情页的交互。首先创建一个新的页面,我们命名为DetailView.swift。下面,我们完成下DetailView页面的设计,它由一个标题、内容和按钮组成。struct DetailView: View { let imageName: String var body: some View { GeometryReader { geometry in ScrollView { VStack(alignment: .leading, spacing: 5) { // 图片名称 Text(self.imageName) .font(.system(.title, design: .rounded)) .fontWeight(.heavy) .padding(.bottom, 30) // 描述文字 Text("要想在一个生活圈中生活下去,或者融入职场的氛围,首先你要学习这个圈子的文化和发展史,并尝试用这个圈子里面的“话术”和他们交流,这样才能顺利地融入这个圈子。") .padding(.bottom, 40) // 按钮 Button(action: { }) { Text("知道了") .font(.system(.headline, design: .rounded)) .fontWeight(.heavy) .foregroundColor(.white) .padding() .frame(minWidth: 0, maxWidth: .infinity) .background(Color.blue) .cornerRadius(8) } } .padding() .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) .background(Color.white) .cornerRadius(15) } } } }交互逻辑首先,我们先实现点击CardView打开DetailView详情页。我们使用GeometryReader几何视图和ScrollView滚动视图搭建了一个DetailView详情页。然后我们回到ContentView首页,创建一个点击状态。@State var isShowDetailView = false当我们点击CardView卡片时,进入到对应的详情页。和之前的章节一样我们在CardView卡片视图上添加点击事件,然后用ZStack层叠视图将DetailView详情页和ContentView首页叠加在一起。//详情页 if self.isShowDetailView { DetailView(imageName: imageModels[currentIndex].imageName) .offset(y: 200) .transition(.move(edge: .bottom)) .animation(.interpolatingSpring(mass: 0.5, stiffness: 100, damping: 10, initialVelocity: 0.3),value: offset) }当我们点击CardView卡片视图的时候,展示DetailView详情页。当然,还远远不够,我们希望展示的效果是,Banner图片轮播在展示详情的时候,背景部分可以看到原先Banner轮播的图片,我们可以根据isShowDetailView的状态再调整下样式。//如果点击就图片就移上去 .offset(y: self.isShowDetailView ? -innerView.size.height * 0.3 : 0) //如果点击图片两边就不留边距 .padding(.horizontal, self.isShowDetailView ? 0 : 20) //如果点击就图片调整大小 .frame(width: outerView.size.width, height: self.currentIndex == index ? (self .isShowDetailView ? outerView.size.height : 250) : 200)我们发现一个交互问题,现在我们尝试拖动Banner图片轮播,它也是可以拖动的,这不是我们想要的效果。我们可以按照上面的逻辑,再用isShowDetailView判断一下。//如果没有被点击 !self.isShowDetailView ? //代码块 :nil好了,这样,我们在展示DetailView详情页时,就不用担心Banner轮播图被拖动了。我们最后再加上一个关闭按钮,用于关闭DetailView详情页。//关闭按钮 Button(action: { self.isShowDetailView = false }) { Image(systemName: "xmark.circle.fill") .font(.system(size: 30)) .foregroundColor(.black) .opacity(0.7) .contentShape(Rectangle()) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topTrailing) .padding(.trailing)恭喜你,完成了所有的编程!我们回顾一下,上篇我们完成了ScrollView滚动视图创建Banner轮播图,后来我们发现不太可行。然后,中篇我们尝试使用HStack横向视图和Gestures手势做一个Banner轮播图,并完成了基本的交互。下篇我们继续完成了整个Banner轮播图的交互逻辑。真心不容易啊。ContentView完整代码struct ContentView: View { @State var currentIndex = 0 @GestureState var dragOffset: CGFloat = 0 @State private var offset: CGFloat = .zero @State var isShowDetailView = false var body: some View { ZStack { //首页轮播图 GeometryReader { outerView in HStack(spacing: 0) { ForEach(imageModels.indices, id: \.self) { index in GeometryReader { innerView in CardView(image: imageModels[index].image, imageName: imageModels[index].imageName) //如果点击就图片就移上去 .offset(y: self.isShowDetailView ? -innerView.size.height * 0.3 : 0) } //如果点击图片两边就不留边距 .padding(.horizontal, self.isShowDetailView ? 0 : 20) .opacity(self.currentIndex == index ? 1.0 : 0.7) //如果点击就图片调整大小 .frame(width: outerView.size.width, height: self.currentIndex == index ? (self .isShowDetailView ? outerView.size.height : 250) : 200) //点击进入详情页 .onTapGesture { self.isShowDetailView = true } } } .frame(width: outerView.size.width, height: outerView.size.height, alignment: .leading) .offset(x: -CGFloat(self.currentIndex) * outerView.size.width) .offset(x: self.dragOffset) // 拖动事件 .gesture( //如果没有被点击 !self.isShowDetailView ? DragGesture() .updating(self.$dragOffset, body: { value, state, transaction in state = value.translation.width }) .onEnded({ value in let threshold = outerView.size.width * 0.65 var newIndex = Int(-value.translation.width / threshold) + self.currentIndex newIndex = min(max(newIndex, 0), imageModels.count - 1) self.currentIndex = newIndex }) : nil ) } .animation(.interpolatingSpring(mass: 0.6, stiffness: 100, damping: 10, initialVelocity: 0.3),value: offset) //详情页 if self.isShowDetailView { DetailView(imageName: imageModels[currentIndex].imageName) .offset(y: 200) .transition(.move(edge: .bottom)) .animation(.interpolatingSpring(mass: 0.5, stiffness: 100, damping: 10, initialVelocity: 0.3),value: offset) //关闭按钮 Button(action: { self.isShowDetailView = false }) { Image(systemName: "xmark.circle.fill") .font(.system(size: 30)) .foregroundColor(.black) .opacity(0.7) .contentShape(Rectangle()) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topTrailing) .padding(.trailing) } } } }快来动手试试吧!如果本专栏对你有帮助,不妨点赞、评论、关注~
在本章中,你将学会ScrollViewReader滚动视图锚点的使用。在开发社交类型的App时,我们常常会遇到选择多张图片的场景,交互动作为选择多张图片,图片先放在“暂留区”,然后再提交发布。那么本章,我们就尝试使用ScrollViewReader滚动视图锚点来完成这个交互。项目搭建首先,创建一个新项目,命名为SwiftUIScrollViewReader 。素材准备我们先导入一批图片,作为待选择展示的网格存放的内容。Model准备然后,我们构建下Model。新建一个swift类型的文件,命名为Model.swift。import Foundation struct Model: Identifiable { var id = UUID() var imageName: String } let sampleModels = (1...9).map { Model(imageName: "image0\($0)") }构建图片网格的Model也比较简单,我们定义了一个Model结构体,它遵循Identifiable协议,使用id定位到结构体中的实例。使用var声明了图片网格需要的元素imageName,然后我们创建了一个sampleModels数组,创建了一个示例数据作为View视图中展示的内容。科普一个知识点我们在这里使用Map函数方法,它可以返回的是一个数组 。这里使用闭包表达式作为参数,集合中的每个元素调用一次该闭包函数,并返回该元素所映射的值。这样,我们就构建好了Model部分。网格视图接下来,我们来做图片网格视图的部分。首先,我们声明一个状态变量photoSet,这样我们就可以在sampleModels图片数组被选择的时候知道它。然后,我们使用LazyVGrid组件完成网格视图。struct ContentView: View { @State private var photoSet = sampleModels var body: some View { VStack { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) { ForEach(photoSet) { photo in Image(photo.imageName) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: 150) .cornerRadius(8) } } } } .padding() } }和之前的章节一样,我们使用自适应布局来完成网格图片集合的展示。接下来,我们也完成下选择图片后放置的“暂留区”的样式。ScrollView(.horizontal, showsIndicators: false) { } .frame(height: 100) .padding() .background(Color(.systemGray6)) .cornerRadius(8)当我们选中一张照片时,我们将从照片网格中删除它,并将它插入到底部的“暂留区”中。 为了处理照片选择,我们创建一个状态变量来保存选中的照片。另外因为photoSet中的每张照片都有自己的UUID类型的ID,我们要存储当前选中的照片,需要声明另一个UUID类型的状态变量。@State private var selectedPhotos: [Model] = [] @State private var selectedPhotoId: UUID?373接下来,我们设计点击事件,点击网格图片集的时候,我们知道是哪一张图片,并且将它从网格图片集中删除。.onTapGesture { selectedPhotos.append(photo) selectedPhotoId = photo.id if let index = photoSet.firstIndex(where: { $0.id == photo.id }) { photoSet.remove(at: index) } }上述代码中,我们将选中的照片添加到selectedPhotos数组中,并更新selectedPhotoId。 因为photoSet是一个状态变量,所以照片选中时,一旦从数组中移除,就会从网格中移除。图片从网格图片集删除后,我们要加到下面的“暂留区”中,操作方法和上面类型,只是上面删除,下面添加,下面删除,上面添加,构建一个循环。ScrollView(.horizontal, showsIndicators: false) { LazyHGrid(rows: [GridItem()]) { ForEach(selectedPhotos) { photo in Image(photo.imageName) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: 150) .cornerRadius(8) .onTapGesture { photoSet.append(photo) if let index = selectedPhotos.firstIndex(where: { $0.id == photo.id }) { selectedPhotos.remove(at: index) } } } } }看起来不错。交互优化但我们发现一个问题,就是我们选中图片很多的时候,图片加到“暂留区”中是按照添加的先后顺序加的,后面加的图片要滚动才能看到。这不是我们想要的效果。我们希望添加图片到“暂留区”中时,“暂留区”的滚动视图能自动定位到最新添加图片的位置。这时,我们就需要使用到ScrollViewReader滚动视图锚点组件,它可以让滚动视图移动到特定位置。ScrollViewReader { scrollProxy in ScrollView(.horizontal, showsIndicators: false) { LazyHGrid(rows: [GridItem()]) { ForEach(selectedPhotos) { photo in Image(photo.imageName) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: 150) .cornerRadius(8) .id(photo.id) .onTapGesture { photoSet.append(photo) if let index = selectedPhotos.firstIndex(where: { $0.id == photo.id }) { selectedPhotos.remove(at: index) } } } } } .frame(height: 100) .padding() .background(Color(.systemGray6)) .cornerRadius(8) .onChange(of: selectedPhotoId, perform: { id in guard id != nil else { return } scrollProxy.scrollTo(id) }) }上述代码中,因为每张照片已经有了它唯一的标识符,我们可以使用照片ID作为视图的标识符,我们给“暂留区”的图片添加id为photo的ID。我们使用onchange来监听selectedPhotoId的更新。每当照片ID被改变时,照片ID就可以调用scrollTo来滚动视图到那个特定的位置。我们预览下效果本章代码import SwiftUI struct ContentView: View { @State private var photoSet = sampleModels @State private var selectedPhotos: [Model] = [] @State private var selectedPhotoId: UUID? var body: some View { VStack { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) { ForEach(photoSet) { photo in Image(photo.imageName) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: 150) .cornerRadius(8) .onTapGesture { selectedPhotos.append(photo) selectedPhotoId = photo.id if let index = photoSet.firstIndex(where: { $0.id == photo.id }) { photoSet.remove(at: index) } } } } } ScrollViewReader { scrollProxy in ScrollView(.horizontal, showsIndicators: false) { LazyHGrid(rows: [GridItem()]) { ForEach(selectedPhotos) { photo in Image(photo.imageName) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: 150) .cornerRadius(8) .id(photo.id) .onTapGesture { photoSet.append(photo) if let index = selectedPhotos.firstIndex(where: { $0.id == photo.id }) { selectedPhotos.remove(at: index) } } } } } .frame(height: 100) .padding() .background(Color(.systemGray6)) .cornerRadius(8) .onChange(of: selectedPhotoId, perform: { id in guard id != nil else { return } scrollProxy.scrollTo(id) }) } } .padding() } }快来动手试试吧!如果本专栏对你有帮助,不妨点赞、评论、关注~
前言:当写这章的时候,发现以前还是关于这块内容的“高级用法”,但想了想,还是先不写那么深入,担心扩充起来内容太多,而且难以消化,就只罗列了简单的使用方法。之后的章节中,会考虑逐渐深入下去,当讲完了SwiftUI基础功能的使用方法,后面也会考虑出一些实战项目的案例,让SwiftUI爱好者能够真正地写项目。今日职言:努力工作,尽情玩乐。在本章中,你将学会使用模态弹窗完成页面跳转、自定义返回。在上一章中,我们学习了使用NavigationView导航栏进行页面的跳转,这是常见的页面跳转的一种方式。而另一种打开新页面的方式在iPhone中也很常见,就是从底部向上弹出一个新页面。我们可以看到,这个页面又不完全是“进入”了一个新页面,它允许用户向下滑动关闭页面,这无疑大大增强了用户体验。这时候给用户的感觉就是没有“打断”或者“干扰”用户的操作,并且完成了用户想做的事情。本章节将分成3个部分讲解。1、基于模态弹窗的页面跳转;2、模态弹窗的自定义返回;3、Alerts警告弹窗;好了,说了那么多,我们开始吧。第一部分:基于模态弹窗的页面跳转首先,我们先创建一个新项目,命名为SwiftUIModalView。我们尝试下简单的模态弹窗的跳转。比如,我们在第一个页面创建一个按钮,当我们点击这个按钮时,打开一个模态弹窗页面。如何创建按钮,可以了解下之前的文章。创建按钮的代码如下://按钮 Button(action: { // 点击按钮跳转打开模态弹窗 }) { // 按钮样式 Text("打开模态弹窗") .font(.system(size: 14)) .frame(minWidth: 0, maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(red: 51 / 255, green: 51 / 255, blue: 51 / 255)) .cornerRadius(5) .padding(.horizontal, 20) }然后,我们需要再创建一个新的页面,方便我们实现从第一个页面点击按钮,以模态弹窗的方式打开第二个页面。这里,我们基于上一章的内容,创建一个页面叫做DetailView。在DetailView我们就放个文字简单说明下。// 详情页 struct DetailView: View { var body: some View { Text("这是一个新页面") } }接下来,我们实现下使用模态弹窗的跳转方法。.sheet(isPresented: $showDetailView) { //要跳转的页面 }模态弹窗的方法很简单,使用.sheet修饰符。isPresented是模态弹窗的触发条件,需要用$绑定一个操作,我们通常定义一个布尔值,它的初始状态为false。@State var showDetailView = false当我们在第一个页面点击按钮时,按钮的操作就把这个布尔值变成true,那么就可以同时触发.sheet打开模态弹窗。这里我们定义了一个叫做showDetailView的参数,它是bool类型,而且初始值为false。当我们点击按钮时,showDetailView切换状态。self.showDetailView.toggle()我们点击模拟器上的Preview试下效果。点击按钮后,我们可以看到系统打开了我们定义好的模态弹窗页面DetailView了。完整代码如下:import SwiftUI struct ContentView: View { @State var showDetailView = false var body: some View { // 按钮 Button(action: { // 点击按钮跳转打开模态弹窗 self.showDetailView.toggle() }) { // 按钮样式 Text("打开模态弹窗") .font(.system(size: 14)) .frame(minWidth: 0, maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(red: 51 / 255, green: 51 / 255, blue: 51 / 255)) .cornerRadius(5) .padding(.horizontal, 20) } .sheet(isPresented: $showDetailView) { DetailView() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } // 详情页 struct DetailView: View { var body: some View { Text("这是一个新页面") } }第二部分:模态弹窗的自定义返回在DetailView页面里,我们可以通过向下拖动页面,实现关闭页面的效果。我们也可以尝试给DetailView加上一个关闭按钮,当我们点击关闭按钮时,也关闭这个页面。还记得上一章我们学习过的NavigationView导航栏吗?我们可以给DetailView内容加上导航,然后导航右边加一个按钮操作。// 详情页 struct DetailView: View { var body: some View { NavigationView { //主体内容 Text("这是一个新页面") .navigationBarItems(trailing: Button(action: { // 点击按钮关闭弹窗 }) { Image(systemName: "chevron.down.circle.fill") .foregroundColor(.gray) } ) } } }好了,我们看到在DetailView导航栏右上角创建了一个新按钮。那么接下来,我们要实现点击按钮,关闭弹窗。这里我们有两种方法。方法1:和上一章NavigationView导航栏一样,创建一个环境变量:@Environment(\.presentationMode) var presentationMode然后当我们点击按钮时,调用它的函数方法。self.presentationMode.wrappedValue.dismiss()点击模拟器的Preview,我们发现实现了点击操作,关闭当前的模态弹窗页面。完整代码如下:// 详情页 struct DetailView: View { //定义环境变量 @Environment(\.presentationMode) var presentationMode var body: some View { NavigationView { //主体内容 Text("这是一个新页面") .navigationBarItems(trailing: Button(action: { // 点击按钮关闭弹窗 self.presentationMode.wrappedValue.dismiss() }) { Image(systemName: "chevron.down.circle.fill") .foregroundColor(.gray) } ) } } }方法2:也是之前的章节学习过 State状态和Binding绑定的使用。我们可以在DetailView页面用@Binding绑定第一个页面创建的showDetailView布尔值。//绑定参数 @Binding var showDetailView: Bool然后在DetailView页面,点击按钮操作时,将showDetailView的值切换。self.showDetailView.toggle()最后在主页中.sheet跳转的目标页面绑定参数回来。.sheet(isPresented: $showDetailView) { DetailView(showDetailView: $showDetailView) }这样,我们也可以实现页面的返回操作。DetailView页面完整代码如下:// 详情页 struct DetailView: View { //绑定参数 @Binding var showDetailView: Bool var body: some View { NavigationView { //主体内容 Text("这是一个新页面") .navigationBarItems(trailing: Button(action: { // 点击按钮关闭弹窗 self.showDetailView.toggle() }) { Image(systemName: "chevron.down.circle.fill") .foregroundColor(.gray) } ) } } }那么两种方法有什么不同呢?第一种方法简单来说,是“撤销”原有的操作。而第二种绑定的方式,是反向传递参数值给到第一个页面。两种方式各有好处,第二种方法的好处是如果第二个页面返回的时候需要带参数值回来,那么我们可以通过绑定的方式将DetailView的值给回到第一个页面。第三部分:Alerts警告弹窗在模态弹窗中,还有一种类型叫做Alerts警告弹窗,也属于模态弹窗的一种。我们在App也经常见过它,当我们确定要付款时,或者触发到有风险的操作时,系统就会打开Alerts警告弹窗。Alerts警告弹窗一种二次确认的弹窗,常用于系统风险提醒、是否立即执行等场景。创建警告弹窗的方法和创建一般的模态弹窗方法一样,只是参数不一样。.alert(isPresented: $showAlert) { //Alerts结构体 }我们还是使用isPresented触发,我们定义一个变量showAlert的状态,初始状态是false。@State var showAlert = false与创建标准模态弹窗不同的是,.alert里面的内容是Alerts结构体,也就是标准的警告弹窗。我们使用Alert结构体来创建警告弹窗。Alert(title: Text("这是弹窗标题"), message: Text("这是弹窗的内容"), primaryButton: .default(Text("确定")), secondaryButton: .cancel(Text("取消")))基本了解了警告弹窗的用法,让我们实操试试吧。我们把DetailView页面的文字改成“打开警告弹窗”,然后把它变成按钮的形式。以便于我们点击文字按钮,实现打开警告弹窗的效果。//主体内容 Button(action: { // 点击按钮打开警告弹窗 }) { Text("打开警告弹窗") }然后我们将警告弹窗的方法写到DetailView页面里面,方法和主页中.sheet的方法类似。先定义一个变量showAlert,初始值为false,当按钮点击的时候,showAlert的状态切换。在Text按钮外边(注意位置),使用.alert的创建一个警告弹窗,然后在警告弹窗内容里填充Alerts结构体代码。这样就完成了Alerts警告弹窗的创建。DetailView完整代码如下:// 详情页 struct DetailView: View { // 绑定参数 @Binding var showDetailView: Bool @State var showAlert = false var body: some View { NavigationView { // 主体内容 Button(action: { // 点击按钮打开警告弹窗 self.showAlert.toggle() }) { Text("打开警告弹窗") } .alert(isPresented: $showAlert) { //Alerts结构体 Alert(title: Text("这是弹窗标题"), message: Text("这是弹窗的内容"), primaryButton: .default(Text("确定")), secondaryButton: .cancel(Text("取消"))) } .navigationBarItems(trailing: Button(action: { // 点击按钮关闭弹窗 self.showDetailView.toggle() }) { Image(systemName: "chevron.down.circle.fill") .foregroundColor(.gray) } ) } } }快来动手试试吧!如果本专栏对你有帮助,不妨点赞、评论、关注~