控件是交互工具,用户通过交互修改界面状态、选取选项或插入、修改或删除信息。我们实现过其中的一部分,如前例中的Button
视图以及TextField
视图。要定义一个有用的接口,需要学习有关视图的更多知识以及其它由SwiftUI所提供的控制视图。
我们已经学到,Button
视图创建一个简单的控件,在点击时执行操作。以下是该结构体部分初始化方法。
Button
视图。第一个参数是定义按钮标签的字符串,action
参数是在点击按钮时执行的代码的闭包。Button
视图。action
参数是在点击按钮时执行的代码的闭包,label
参数是返回用于创建标签的视图的闭包。Button
视图。第一个参数是定义按钮标签的字符串。role
参数一个结构体,包含描述按钮目的的类型属性。有两个属性:cancel
和destructive
。action
参数是在点击按钮时执行的代码的闭包。我们已经实现过第二个初始化方法创建按钮,但如果仅需对标签使用字符中,可以简化代码使用第一个初始方法加后置的用于操作的闭包。
示例6-10:实现Button
视图
struct ContentView: View {
@State private var colorActive: Bool = false
var body: some View {
VStack(spacing: 10) {
Text("Default Title")
.padding()
.background(colorActive ? Color.green : Color.clear)
Button("Change Color") {
colorActive.toggle()
}
Spacer()
}.padding()
}
}
上例在VStack
中包仿一个Text
视图和一个Button
视图。Text
视图展示固定的文本,背景色用colorActive
属性定义。如果属性值是true
,我们将green
色赋值给背景,否则颜色为clear
(透明)。在按下按钮时,会切换这一属性的值,再次运算body
属性的值,文本的背景修改为下一个颜色。
图6-4:按钮视图
✍️跟我一起做:创建一个多平台项目。使用示例6-10的代码更新ContentView
视图。点击Change Color按钮。会看到文本背景色的变化(参见图6-4右图)。
如果希望将视图与控件执行的操作进行分离,可以将相关语句移到函数中。例如,可以上在ContentView
结构体中添加一个函数,用于切换colorActive
属性的值,然后在按钮的操作中调用这个函数。应用的功能的相同,但代码更有条理。
示例6-11:使用函数来组织代码
struct ContentView: View {
@State private var colorActive: Bool = false
var body: some View {
VStack(spacing: 10) {
Text("Default Title")
.padding()
.background(colorActive ? Color.green : Color.clear)
Button("Change Color") {
changeColor()
}
Spacer()
}.padding()
}
func changeColor() {
colorActive.toggle()
}
}
如果按钮唯一的操作就是调用方法,可以简化视图的定义为声明action
参数并指定所要执行操作的方法名。如下所示。
示例6-12:引用方法
Button("Change Color", action: changeColor)
声明方法名称带括号会马上执行方法,但仅声明名称会提供一个方法的引用供系统稍后执行。
✍️跟我一起做:使用示例6-11中的代码更新ContentView
视图。应用功能和之前相同。使用示例6-12中的Button
视图更新Button
视图。点击按钮确定所执行的操作。
上例中,我们使用了三元运算符来根据colorActive
属性的值选取background()
修饰符的值。这是推荐的做法,这样SwiftUI可以识别视图并有效管理状态的转换,但我们也可以使用if else
语句来响应修改。例如,有时会用按钮这类控件在界面中显示或隐藏视图。
示例6-13:在界面中添加及删除视图
struct ContentView: View {
@State private var showInfo = false
var body: some View {
VStack(spacing: 10) {
Button("Show Information") {
showInfo.toggle()
}.padding()
if showInfo {
Text("This is the information")
}
Spacer()
}
}
}
本例中的按钮切换@State
属性showInfo
的值。在按钮下方,可查看到该属性的当前值。若其值为true
,显示 Text
视图,否则什么也不显示。因此,在按下按钮时,showInfo
的值发生改变,body
属性的内容会重新绘制,Text
视图根据showInfo
的当前值出现或消失。
图6-5:动态界面
if else
语句可用于选择是否执行按钮的操作,但SwiftUI提供了如下修饰符来在要做禁用操作时禁用按钮。
下例使用了该修饰符在点击后禁用按钮,因此用户只能执行一次操作。
示例6-14:禁用按钮
struct ContentView: View {
@State private var color = Color.clear
@State private var buttonDisabled = false
var body: some View {
VStack(spacing: 10) {
Text("Default Title")
.padding()
.background(color)
Button("Change Color") {
color = Color.green
buttonDisabled = true
}
.disabled(buttonDisabled)
Spacer()
}.padding()
}
}
这个视图包含两个@State
属性,一个用于追踪颜色,另一个表示按钮是否处于禁用状态。在点击按钮时,操作中将true
赋值给buttonDisabled
属性,按钮就停止运作了,这样用户只能点一次。
图6-6:按钮禁用
和之前一样,Button
视图的初始化方法可以包含一个label
参数来定义所需视图的标签。这个参数非常灵活,可以包含像Text
视图和Image
视图的视图。按钮中的图片以原始渲染模式显示 ,也就是说以原始颜色显示,但还有一种模式可以创建带图片的蒙版,以应用的着重色或赋值给控件的前景色显示。为选取渲染模式,Image
视图包含如下修饰符。
Image
视图定义了渲染械。参数是包含original
和template
值的枚举。下例定义了一个带图片和文本的按钮。将renderingMode()
修饰符应用于Image
视图来以模板显示图片。
示例6-15:定义带图按钮的标签
struct ContentView: View {
@State private var expanded: Bool = false
var body: some View {
VStack(spacing: 10) {
Text("Default Title")
.frame(minWidth: 0, maxWidth: expanded ? .infinity : 150, maxHeight: 50)
.background(Color.yellow)
Button(action: {
expanded.toggle()
}, label: {
VStack {
Image(expanded ? .contract : .expand)
.renderingMode(.template)
Text(expanded ? "Contract" : "Expand")
}
})
Spacer()
}.padding()
}
}
示例6-15中的视图包含一个@State
属性expanded
,用于控制Text
视图的宽度。如该属性的值为true
,我们使用infinity
值让宽度为最宽,否则,宽度为150点。每当用户点击按钮时,expanded
属性的值通过toggle()
方法进行切换,Text
视图的宽度随之发生变化。
图6-7:带模板图片的按钮
✍️跟我一起做:下载expand.png和contract.png并添加至资源目录。使用示例6-15中的代码更新ContentView
视图,点击Expand按钮。此时会看到图6-7中的界面。删除renderingMode()
修饰符。我们应当会看到原色图。
可以通过如下修饰符对按钮赋标准样式。
ButtonStyle
协议的一个结构体。large
、mini
、regular
和small
。SwiftUI框架自带有PrimitiveButtonStyle
协议提供标准样式。为此,该协议定义了类型属性automatic
、bordered
、borderedProminent
、borderless
和plain
。这些样式满足不同目的。例如,bordered
样式创建一个灰色背景的按钮,表示二级操作,borderedProminent
样式创建一个应用着重色的按钮,表示主操作,比如用于保存或提交数据。例如以下视图包含两个按钮,一个取消处理,另一个将信息发送给服务端。
示例6-16:按钮样式
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
HStack {
Button("Cancel") {
print("Cancel Action")
}.buttonStyle(.bordered)
Spacer()
Button("Send") {
print("Send Information")
}.buttonStyle(.borderedProminent)
}
Spacer()
}.padding()
}
}
突出按钮应仅用于表示主操作。本例中,Cancel按钮加了边框,告诉用户这是一个二级操作,重要级为次级,但Send按钮为突出的,表示在点击该按钮时执行重要操作。
图6-8:标准样式按钮
按钮用于取消处理(如上例)或删除某一项时,我们可以通过Button
的初始化方法为其赋一个特定的角色。这样系统可以根据角色在应用运行的设备上对按钮添加样式。例如,在移动设备上,destructive
角色的按钮以红色显示。
示例6-17:赋予角色
Button("Delete", role: .destructive) {
print("Delete Action")
}.buttonStyle(.bordered)
图6-9:销毁按钮
✍️跟我一起做:使用示例6-16的代码更新ContentView
视图。会看到如图6-8中所示的按钮。将Cancel按钮替换为示例6-17中的Button
视图。会看到如图6-9中所示的删除按钮。
这些样式对SF图标进行了美化。SF图标替换普通图片的优势是它们会按对按钮添加的字体大小进行缩放。这配合对按钮自身进行缩放的controlSize()
修饰符,使得我们可以创建不同大小的按钮。
示例6-18:缩放按钮
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
Button(action: {
print("Send information")
}, label: {
HStack {
Image(systemName: "mail")
.imageScale(.large)
Text("Send")
}
})
.buttonStyle(.borderedProminent)
.font(.largeTitle)
.controlSize(.large)
Spacer()
}.padding()
}
}
本例中,我们应用了imageScale()
修饰符来缩放SF图标,font()
修饰符对按钮添加了大字体,controlSize()
修饰符对按钮进行缩放。结果如下。
图6-10:自定义大小的按钮
如果希望定义一个样式与系统自带的进行区分,则需要创建自己的ButtonStyle
结构体。该协议只要求实现如下方法。
configuration
参数为包含按钮信息的Configuration
类型的值。该方法接收一个Configuration
类型的值,是ButtonStyleConfiguration
的类型别名,包含返回按钮相关信息的属性。以下是其中的属性。
以下示例定义在点击时会放大的示例。样式包含一个内边距和绿色边框。要应用这些样式,必须创建一个符合ButtonStyle
协议的结构体,即实现makeBody()
方法并通过该方法返回希望赋值给按钮体的视图。
组成按钮体的视图由Configuration
结构体的label
属性提供,因此我们可以读取并修改这一属性值来应用新的样式,如下所示。
示例6-19:为按钮添加自定义样式
struct MyStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
let pressed = configuration.isPressed
return configuration.label
.padding()
.border(Color.green, width: 5)
.scaleEffect(pressed ? 1.2 : 1.0)
}
}
struct ContentView: View {
@State private var color = Color.gray
var body: some View {
VStack(spacing: 10) {
Text("Default title")
.padding().foregroundColor(color)
Button("Change Color") {
color = Color.green
}.buttonStyle(MyStyle())
Spacer()
}.padding()
}
}
在示例6-19中,我们定义了一个结构体MyStyle
并实现了所要求的makeBody()
方法。该方法通过类型属性获取到了按钮的当前配置,进而修改并返回标签。首先,我们读取isPressed
属性的值来了解按钮是否被按下,然后对label
属性应用新的样式。这个属性返回创建按钮当前标签的一个视图拷贝,然后通过修改其值我们也就修改了标签。本例中,我们应用了一个内边距、一个边框,然后根据isPressed
属性的值赋了一个缩放比例。如果该值为true
,也就是按钮被按下了,我们将比例设为1.2进行放大,但在值为false
时,比例又回到了1。
在这一视图中,我们创建了该结构体的实例并通过buttonStyle()
修饰符将其值赋值给Button
视图。如果如下所示。
图6-11:带自定义样式的按钮
✍️跟我一起做:使用示例6-19中的代码更新ContentView.swift
文件。点击按钮。会看到按钮如图6-11右图那样放大了。SwiftUI自动对按钮添加了动画。我们会在第11章中学习自定义动画以及如何创建。
TextField
又是一个我们之前介绍过的控件。该视图创建一个输入框,用户可进行交互并插入值(单行文本)。以下是结构体中所包含的一个初始化方法。
text
参数是用于存储由用户所插入值的绑定属性,axis
参数定义在文本超出视图边界时沿哪条轴进行滚动。这是一个枚举,值有horizontal
和vertical
。框架为TextField
视图定义了几个修饰符。以下是最常用的一些。
TextFieldStyle
协议的结构体。框架自带了几个提供标准样式的结构体。这些结构体定义了类型属性automatic
、plain
、roundedBorder
和squareBorder
。true
(禁用状态)。characters
、never
、, sentences (默认值)
和words
。default
、asciiCapable
、numbersAndPunctuation
、URL
、numberPad
、phonePad
、namePhonePad
、emailAddress
、decimalPad
、twitter
、webSearch
、asciiCapableNumberPad
和alphabet
。我们已经学习如何包含一个简单的TextField
视图来获取用户的输入,但只使用了少数几个修饰符。下例展示了如何对视图添加样式让单词变成大写。
示例6-20:配置文本框
struct ContentView: View {
@State private var title: String = "Default Title"
@State private var titleInput: String = ""
var body: some View {
VStack(spacing: 15) {
Text(title)
.lineLimit(1)
.padding()
.background(Color.yellow)
TextField("Insert Title", text: $titleInput)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.words)
Button("Save") {
title = titleInput
titleInput = ""
}
Spacer()
}.padding()
}
}
示例6-20中对TextField
视图应用的样式为roundedBorder
。它为输入框添加一个边框,让视图占据的区域变得可见,如下所示。
图6-12:带圆角边框的文本框
✍️跟我一起做:使用示例6-20中的代码更新ContentView
视图。在输入框中插入文本并按下Save按钮。会看到如图6-12所示的效果。
除了按钮,用户通过期望能够通过点击键盘上的Done按钮保存数据。为此框架提供了如下的修饰符。
of
参数是指定修饰符所响应的触发条件类型的结构体。结构体中包含search
和text
(默认)属性。第二个参数是希望执行的闭包。continue
、done
、go
、join
、next
、return
、route
、search
和send
。赋值给onSubmit()
修饰符的闭包在聚焦于视图(例如用户编辑输入框)时执行。如果应用于TextField
视图,可省略of
参数,如下例如下。
示例6-21:响应Done按钮
struct ContentView: View {
@State private var title: String = "Default Title"
@State private var titleInput: String = ""
var body: some View {
VStack(spacing: 15) {
Text(title)
.lineLimit(1)
.padding()
.background(Color.yellow)
TextField("Insert Title", text: $titleInput)
.textFieldStyle(.roundedBorder)
.submitLabel(.continue)
.onSubmit {
assignTitle()
}
HStack {
Spacer()
Button("Save") {
assignTitle()
}
}
Spacer()
}.padding()
}
func assignTitle() {
title = titleInput
titleInput = ""
}
}
示例6-21中的代码实现了submitLabel()
修饰符来修改Done按钮的标题为Continue,然后向结构体添加一个名为assignTitle()
的方法,执行和之前同样的操作。该方法在两处有调用,赋值给onSubmit()
修饰符的闭包和Button
视图操作,因此在按下界面的按钮或点击键盘上的Done/Return按钮时执行该操作。不管用户决定执行什么操作,插入文本框的值总是存储于title
属性中。
✍️跟我一起做:使用示例6-21中的代码更新ContentView
结构体,并在iPhone模拟器上运行应用。点击输入框,插入文本并在键盘上点击Continue按钮。(若要在模拟器上启用虚拟键盘,打开I/O菜单,点击Keyword,选择Toggle Software Keyboard选项。)文本会像此前一样赋值给标题。
在视图可接收输入或处理用户选定的反馈时,我们就说视图聚焦了。SwiftUI包含了一些处理这种状态的工具。可以在视图获得焦点时处理某一任务、知道视图是否获得焦点或是从视图移除焦点。为此有两个属性包装器:@FocusState
和@FocusedBinding
。@FocusState
存储表明焦点当前存储在哪里的值,@FocusedBinding
用于将状态传递给其它视图。为管理状态,框架内置了如下 修饰符。
@FocusState
属性的引用,equals
参数是用于标识视图的可哈希值。为追踪视图的状态,我们需要一个可哈希数据类型的@FocusState
属性,提供用于标识视图的值。下例中,属性通过枚举值进行创建。定义了两个值name
和surname
,用于追踪两个输入框的聚焦状态,并在用户输入时修改背景色。
示例6-22:响应焦点中的变化
enum FocusName: Hashable {
case name
case surname
}
struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
@FocusState var focusName: FocusName?
@State private var title: String = "Default Name"
@State private var nameInput: String = ""
@State private var surnameInput: String = ""
var body: some View {
let color: Color = colorScheme == .dark ? .black : .white
VStack(spacing: 10) {
Text(title)
.lineLimit(1)
.padding()
.background(Color.yellow)
TextField("Insert Name", text: $nameInput)
.textFieldStyle(.roundedBorder)
.padding(4)
.background(focusName == .name ? Color(white: 0.9) : color)
.focused($focusName, equals: .name)
TextField("Insert Surname", text: $surnameInput)
.textFieldStyle(.roundedBorder)
.padding(4)
.background(focusName == .surname ? Color(white: 0.9) : color)
.focused($focusName, equals: .surname)
HStack {
Spacer()
Button("Save") {
title = nameInput + " " + surnameInput
}
}
Spacer()
}.padding()
}
}
@FocusState
属性的初始值是nil
,表示未聚焦于任何视图。在用户点击文本框时,焦点移至该视图,标识视图的值会被赋值给该属性。通过将该值与枚举中的值进行比较,我们就知道是哪个TextField
视图于聚焦状态,相应地修改背景色。注意roundedBorder
样式对文本框添加了一个边框和白色背景,所以本例中只有边距的背景可见。
图6-13:聚焦
在移动设备中,在可处理输入的视图(如TextField
视图)获取到焦点时会打开虚拟键盘。只要焦点还在该视图上键盘就保持打开状态。也就是说要关闭键盘,我们必须移除该视图的焦点。在SwiftUI中通过对@FocusState
属性的赋值nil
来实现,如下所示。
示例6-23:关闭键盘
Button("Save") {
title = nameInput + " " + surnameInput
focusName = nil
}
示例6-22中的Button
视图换成了示例6-23中的Button
视图。现在,每当点击Save按钮时,会对值进行处理并关闭键盘。
✍️跟我一起做:使用示例6-22中的代码更新ContentView.swift
文件并在iPhone模拟器上运行应用。点击输入框。背景会像图6-13那样变成灰色。使用示例6-23中的视图替换原Button
视图。再次运行应用。在两个文本框中插入值并点击Save按钮。此时标题会被赋上新值,虚拟键盘关闭。
上例中,我们没有检测用户是否插入了值,但通常应用必须防止用户保存无效值或空值。有几种控制方式。一种是在存储之前就检测值。我们允许用户输入任意值,但仅保存应用所接受的值。
示例6-24:在存储前检测值
Button("Save") {
let tempName = nameInput.trimmingCharacters(in: .whitespaces)
let tempSurname = surnameInput.trimmingCharacters(in: .whitespaces)
if !tempName.isEmpty && !tempSurname.isEmpty {
title = nameInput + " " + surnameInput
focusName = nil
}
}
本例中,我们首先对nameInput
和surnameInput
进行修剪去除其首尾的空格(参数第4章字符串一节),然后在将它们赋值给title
属性之前检测结果值是否为空。Save按钮仍保持为激活状态,但仅在用户对两个字段都插入值时才执行保存。
✍️跟我一起做:使用示例6-24中的代码更新ContentView
视图中的Button
视图。此时必须同时对名和姓两个字段插入值才能修改标题。
另一种方式是在用户插入的为非应用预期值时通过disabled()
修饰符禁用按钮。
示例6-25:禁用按钮
Button("Save") {
let tempName = nameInput.trimmingCharacters(in: .whitespaces)
let tempSurname = surnameInput.trimmingCharacters(in: .whitespaces)
if !tempName.isEmpty && !tempSurname.isEmpty {
title = nameInput + " " + surnameInput
focusName = nil
}
}
}.disabled(nameInput.isEmpty || surnameInput.isEmpty)
本例中,我们使用了前面介绍的disabled()
修饰符来在用户在两个字段中输入文本前禁用按钮。如果其中一个或两个字段为空,按钮就无法使用。
✍️跟我一起做:使用示例6-25中的代码更新Button
视图。只有同时插入名和姓时才能按下Save按钮。
除了可检测属性是否包含有效值,我们还能限定用户在字段中输入的内容。例如,我们可以只接受数字或指定数量的字符。这时,我们需要在每次视图状态发生改变时检测用户插入的值是否有效。框架为此内置了如下的修饰符。
of
参数是存储待检测值的属性,initial
参数为指定在视图出现时是否还执行检测的布尔值,最后一个参数是在系统报出值发生改变时执行的闭包。闭包可接收两个值,一个表示属性的老值,另一个表示新值。该修饰符只能检测一个状态,因此我们应对所有希望进行控制的视图应用该修饰符。例如,我们可以在示例中对那两个TextField
视图使用它来限定允许用户输入的字符数。如果超出,会移除掉多余的字符将结果赋回属性,如下所示。
示例6-26:控制用户的输入
Text(title)
.lineLimit(1)
.padding()
.background(Color.yellow)
TextField("Insert Name", text: $nameInput)
.textFieldStyle(.roundedBorder)
.padding(4)
.background(focusName == .name ? Color(white: 0.9) : color)
.focused($focusName, equals: .name)
.onChange(of: nameInput, initial: false) { old, value in
if value.count > 10 {
nameInput = String(value.prefix(10))
}
}
TextField("Insert Surname", text: $surnameInput)
.textFieldStyle(.roundedBorder)
.padding(4)
.background(focusName == .surname ? Color(white: 0.9) : color)
.focused($focusName, equals: .surname)
.onChange(of: surnameInput, initial: false) { old, value in
if value.count > 15 {
surnameInput = String(value.prefix(15))
}
}
在示例6-26的代码中,我们检测存储文本框状态的属性的变化。在用户输入或删除字符时,相应的属性值发生改变,执行赋值给onChange()
修饰符的闭包。闭包接收属性的值。使用该值,我们检测用户插入的文本是否有效并进行相应的响应。在示例中,我们计算字符串中的字符数,如果值超出上限,我们使用prefix()
方法从文本的开头进行截取,并将结果赋回给属性,这会更新视图并删除文本框中多余的字符。结果 是在字符数超出上限时,用户就无法输入更多的字符了。
✍️跟我一起做:使用示例6-26中的代码更新项目中的TextField
视图。在iPhone模拟器中运行应用。插入名和姓。在名超过10个字符、姓超过15个字符时都无法再添加更多的字符。
当然,我们可以指定字符数外的其它条件。下例创建了一个仅接收整数数字的小应用。
示例6-27:仅接收整数数字
struct ContentView: View {
@State private var title: String = "Default Name"
@State private var numberInput = ""
var body: some View {
VStack(spacing: 10) {
Text(title)
.padding()
.background(Color.yellow)
TextField("Insert Number", text: $numberInput)
.textFieldStyle(.roundedBorder)
.padding(4)
.keyboardType(.numbersAndPunctuation)
.onChange(of: numberInput, initial: false) { old, value in
if !value.isEmpty && Int(value) == nil {
numberInput = old
}
}
HStack {
Spacer()
Button("Save") {
title = numberInput
numberInput = ""
}
}
Spacer()
}.padding()
}
}
和之前一样,视图中包含一个带有onChange()
修饰符的TextField
。不同之处于在于如何对输入有进行有效性检测。本例中,我们需要确保文本框不为空,然后查看是否可以将其转化为整数,这表示用户只输入了数字。如果不能,就将闭包接收到的旧值赋值给numberInput
属性,文本框回复到之前的状态。
注意我们还实现了keyboardType()
修饰符来显示适配我们预期用户输入内容(本例为数字)的键盘。
✍️跟我一起做:使用示例6-27中的代码更新ContentView.swift
文件。在iPhone模拟器上运行应用。此时只能输入数字。
默认,TextField
视图只显示一行文本,但我们可以使用lineLimit()
修饰符来允许视图进行扩展来包含更多的文本。(此前展开Text
视图实现的同一个修饰符)。除了应用修饰符来设置我们所需的行数,我们还要告诉视图在纵轴上滚动内容,如下所示。
示例6-28:定义多行文本框
struct ContentView: View {
@State private var text: String = ""
var body: some View {
TextField("Insert Text", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.padding(20)
.lineLimit(5)
}
}
本例中,TextView
视图会进行扩展,直至到5行的高度时,然后会在垂直方向上滚动来允许用户持续输入。如若要对视图设置最小和最大尺寸,可以使用区间来声明修饰符,如lineLimit(3...5)
。
图6-14:多行文本框
其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记