手势识别器
手势是用户在屏幕上执行的动作,如点击、滑动或捏合。这些手势很难识别,因为屏幕上只能返回手指的位置。为此,Apple提供了手势识别器。手势识别器完成所有识别手势所需的计算。所以我们不用处理众多的事件和值,只需在等待系统监测到复杂手势时发送通知并进行相应处理即可。
手势修饰符
手机设备上最常用的手势是点击,在用户手指触碰屏幕时得到识别。因这种手势使用频繁,SwiftUI定义了两个非常方便的修饰符来完成处理。
- onTapGesture(count: Int, perform: Closure):此修饰符识别一次或多次点击。
count
参数指定要多少次点击才能做手势识别(默认值为1),perform
参数是在监测到手势时执行的闭包。闭包接收一个表示视图坐标中点击位置的CGPoint
值。 - onLongPressGesture(minimumDuration: Double, maximumDistance: CGFloat, perform: Closure, onPressingChanged: Closure):此修饰符识别长按姿势(用户用手指在屏幕上长按)。
minimumDuration
参数是用户长按屏幕直到识别手势的秒数。maximumDistance
参数表示手指移动距原始位置不再识别手势的点数距离。perform
参数是在确认手势时执行的闭包。最后,onPressingChanged
参数是用户和结束按压视图时执行的闭包。闭包接收一个表示用户是否在按压的布尔值。
我们经常使用onTapGesture()
修饰符来监测点击并执行操作(参见示例7-36)。在之前的示例中我们没有使用点击时手指的位置。这通过闭包所接收的CGPoint
值实现,其中包含视图中手指的x和y坐标。在下例中,我们在点击图片时打开弹窗并展示如何访问其值。
示例12-1:监测图片上的点击手势
struct ContentView: View {
@State private var expand: Bool = false
var body: some View {
Image(.spot1)
.resizable()
.scaledToFit()
.frame(width: 160, height: 200)
.onTapGesture { location in
expand = true
print("Location: \(location)")
}
.sheet(isPresented: $expand) {
ShowImage()
}
}
}
在示例12-1的代码中,定义了160乘200点的Image
视图。将onTapGesture()
和sheet()
修饰符应用于视图来监测点击并展示弹窗。以下是由弹窗所打开的ShowImage
视图。
示例12-2:展开图片
import SwiftUI
struct ShowImage: View {
var body: some View {
Image(.spot1)
.resizable()
.scaledToFill()
.edgesIgnoringSafeArea(.all)
}
}
此视图创建一个Image
视图并展开图片填满弹窗,包含安全区。结果是界面在屏幕上展示一张小图,用户点击后,会以全尺寸在弹窗中显示图片。
图12-2:响应点击手势的图片
✍️跟我一起做:创建一个多平台项目。下载spot1.jpg,添加到资源目录中。使用示例12-1中的代码更新ContentView
视图。创建一个SwiftUI文件ShowImage.swift,使用示例12-2中的代码更新视图。此时会在界面看到图12-1(左)中所示的界面。点击图片打开弹窗,在控制台中会打印出点击的位置。
长按手势类似于点击手势,但系统会等待一段时间来确定该手势、执行任务。通过onLongPressGesture()
修饰符,我们可以设置等待时长、执行用户点击时的任务以及等手势完成,如下例所示。
示例12-3:监测长按手势
struct ContentView: View {
@State private var expand: Bool = false
@State private var pressing: Bool = false
var body: some View {
Image(.spot1)
.resizable()
.scaledToFit()
.frame(width: 160, height: 200)
.opacity(pressing ? 0 : 1)
.onLongPressGesture(minimumDuration: 1, maximumDistance: 10,perform: {
expand = true
}, onPressingChanged: { value in
withAnimation(.easeInOut(duration: 1.5)) {
pressing = value
}
})
.sheet(isPresented: $expand) {
ShowImage()
}
}
}
这还是前面的示例,但现在是对Image
视图进行长按,所以用户需要用手指按压一段时间才能打开弹窗。本例中,我们将等待时间设置为1秒,最大移动距离设置为10点,一旦用户手指移动距原始位置超过10点,手势就会取消。onPressingChanged
参数所指定的闭包在用户开始触碰图片时执行,离开时会再次执行。在闭包中,我们修改了@State
属性pressing
的值,这个值用于设置视图的透明度。在手势开始时,闭包接收到的值是true
,因此透明度设置为0。但在用户将手指移开、抬起手指或结束手势时,闭包接收到值false
,因此透明度设置为1。透明度的变化关联了easeInOut
动画,持续1.5秒,所以弹窗会在图片完全消失前打开,给用户一些必要的反馈来知晓他们要等待处理完成。
✍️跟我一起做:使用示例12-3中的代码更新ContentView
视图。在图像上按压手指(长按)。会看到图像逐渐隐去,并在1秒后打开弹窗。
命中测试
因视图有时会重叠,系统必须确定某个视图是处理手势还是将其传递给其它视图。这种查找用户交互的视图并确定是否响应手势的过程称为命中测试(hit testing)。View
协议定义了如下的修饰符来控制这一处理。
- allowsHitTesting(Bool):此修饰符决定是否对指定视图启用命中检测。
- contentShape(Shape, eoFill: Bool):此修饰符定义命中区的形状。第一个参数是确定用户可交互的形状视图,
eoFill
参数决定用于监控命中热点的算法。
allowsHitTesting()
修饰符可用于禁用某个手势。比如,我们可以对前例中的Image
视图启用或禁用点击手势。
示例12-4:禁用点击手势
struct ContentView: View {
@State private var expand: Bool = false
@State private var allowExpansion: Bool = false
var body: some View {
VStack(spacing: 20) {
Image(.spot1)
.resizable()
.scaledToFit()
.frame(width: 160, height: 200)
.onTapGesture {
expand = true
}
.allowsHitTesting(allowExpansion)
.sheet(isPresented: $expand) {
ShowImage()
}
Toggle("", isOn: $allowExpansion)
.labelsHidden()
}
}
}
示例12-4中的视图在图像下方添加了一个Toggle
视图,控制@State
属性的值。该属性决定是否允许对Image
视图添加命中测试。其初始值为false
,因此用户法通过点击图像打开弹窗,但在切换开关为打开时,就会将true
赋值给该属性,因此Image
视图就可以识别手势了。
✍️跟我一起做:使用示例12-4中的代码更新ContentView
视图。点击图像。什么都不会发生。打开图像下方的开关。此时点击图像时就会打开弹窗。
contentShape()
修饰符在对于手势识别也具有重要的作用。在对Image
视图或Text
视图应用手势识别器时,在用户触碰视图所占据的任意区域时会识别手势。但并非总是如此。容器视图,如VStack
和HStack
,仅对其内容所占据的区域执行手势识别。要确保视图的每个部分都能识别手势,我们需要强制内容占据整个区域。前面我们碰到过这种问题(示例7-36)。这时,我们需要使用Color
视图定义背景来定义识别点击手势的区域。这可以满足我们的要求,但它创建了界面中不需要有的内容。更好的方案时应用contentShape()
修饰符。这一修饰符允许我们定义了手势命中区,而又不要对视图添加真实的内容。
下例中,我们重建之前项目中的视图,创建一个行列表,但这次我们不使用Color
视图来响应手势,而是使用Rectangle
视图和contentShape()
修饰符来定义了内容行。这让用户可以点击行的任意区域来进行选中。
示例12-5:定义内容区
struct ContentView: View {
@State private var selected: Bool = false
var body: some View {
VStack {
HStack(alignment: .top) {
Image(.spot1)
.resizable()
.scaledToFit()
.frame(width: 80, height: 100)
.border(selected ? Color.yellow : Color.clear, width: 5)
VStack(alignment: .leading, spacing: 2) {
Text("Balmy Beach").bold()
Text("Toronto")
Text("2020").font(.caption)
Spacer()
}
Spacer()
}.frame(height: 100)
.padding(5)
.border(.gray, width: 1)
.contentShape(Rectangle())
.onTapGesture {
selected.toggle()
}
Spacer()
}
}
}
示例12-5中的视图中显示一行带位置的信息。用户点击行中任意位置时,手势识别器切换@State
属性selected
的值,我们使用它来定义图像边框的颜色。值为true
(选中)时边框为黄色,为false
(取消选中)时为透明。
图12-2:响应行
Gesture结构体
onTapGesture()
和onLongPressGesture()
修饰符所处理的手势由结构体定义,遵循Gesture
协议。以下是一些最常用。
- TapGesture(count: Int):这一初始化方法创建一个手势识别器监测点击手势。
count
参数决定识别手势所需要的点击次数。 - LongPressGesture(minimumDuration: Double, maximumDistance: CGFloat):此初始化方法创建一个手势识别器监测长按手势。
minimumDuration
参数用户手指按压屏幕被识别为手势的秒数。maximumDistance
参数是用户手指移动距原始位置的最大位移,超过被判定为不识别手势。 - MagnificationGesture(minimumScaleDelta: CGFloat):此初始化方法创建一个手势识别器监测放大手势。
minimumScaleDelta
参数为识别为手势所需的最小递增或增减比例。 - RotationGesture(minimumAngleDelta: Angle):该初始化方法创建一个手势识别器监测旋转手势。
minimumAngleDelta
参数是识别为手势所需的视图最小递增或递减角度。
这些初始化方法配置手势识别器,但要响应手势的不同状态,结构体需要实现如下方法。
- onChanged(Closure):该方法在手势状态发生改变时执行传入的闭包。闭包接收有关手势状态的信息值。
- onEnded(Closure):该方法在手势结束时执行传入的闭包。闭包接收有关手势状态的信息值。
- updating(GestureState, body: Closure):该方法在手势状态更新时执行传入的闭包,可能是由于值发生改变或是取消了手势。第一个参数是存储手势状态值的绑定属性,
body
参数是在每次状态发生更新时所执行的闭包。闭包接收有关手势状态的信息值、绑定属性的指针以及包含动画信息的Transaction
类型的值。
由于updating()
方法折调用频率,我们无法使用常规的@State
属性追踪手势的状态。在更新闭包中任何对状态的修改尝试都会导致错误。因此,SwiftUI定义了如下属性封装来配合该方法使用。
- @GestureState:这个属性封装存储了手势的状态并在手势结束将其重置为初始值。
获取到妥当的配置的手势识别器实例之后,我们必须将其应用于视图。为此View
协议定义了如下的修饰符。
- gesture(Gesture):该修饰符将手势识别器赋给视图,优先级低于已赋值给视图的手势识别器。
- highPriorityGesture(Gesture):该修饰符将手势识别器赋给视图,优先级高于已赋值给视图的手势识别器。
- simultaneousGesture(Gesture):该修饰符将手势识别器赋给视图,与已赋值给视图的手势识别器同时处理。
过程很简单。需要初始化Gesture
结构体来定义手势识别器,根据希望处理的内容来对结构体应用onChanged()
、onEnded()
或updating()
方法,并使用gesture()
等修饰符来将实例赋值给视图。应用哪个方法取决于手势和希望完成的任务,而这些方法所接收到值取决我们所使用的手势识别器的类型。因此有多种选项,稍后我们就会知道。
点击手势
因为点击手势的简单性,它和应用onTapGesture()
修饰符和实现TapGesture
结构体并没有多大的不同。结柳体和修饰符有同样的功能,并且能定义识别为手势的点击次数,因为没有即时的变化上报,仅能使用onEnded()
。以下示例重现了之前的项目,但这次我们用TapGesture
结构体定义了手势识别器。
示例12-6:定义一个TapGesture
识别器
struct ContentView: View {
@State private var expand: Bool = false
var body: some View {
Image(.spot1)
.resizable()
.scaledToFit()
.frame(width: 160, height: 200)
.gesture(
TapGesture(count: 1)
.onEnded {
expand = true
}
)
.sheet(isPresented: $expand) {
ShowImage()
}
}
}
TapGesture
结构体定义了手势识别器,但要将其关联到视图,我们必须应用gesture()
修饰符。结果和之前相同。在点击图片时,执行赋给onEnded()
方法的闭包,将true
赋值给expand
属性,打开弹窗。注意onEnded()
方法是TapGesture
结构体的一个方法,因此是在结构体的实例而不是在视图中调用。
c本例需要用到示例12-2中定义的ShowImage
视图。使用示例12-6中的代码更新ContentView
视图。会在屏幕上看到一张小图,点击该图会打开弹窗。
长按手势
类似TapGesture
结构体,LongPressGesture
结构体创建一个简单手势识别器,但它在执行手势时会有一些活动,因此除onEnded()
方法外,如果希望在按压视图时执行任务的话还可以实现updating()
方法。
在实现updating()
方法时,我们需要注意几点。第一,如前所述,该方法需要@GestureState
属性而不是@State
属性。@GestureState
属性存储当前状态,也会在手势结束时重置为其初始值,因此应确保初始值为属性应当具备的初始值。第二,我们需要通过传给方法的闭包自己更新该状态,但不是直接更新,而是通过方法所接收到指针(通过名为state
)。第三,因为我们是在updating()
方法内处理修改,系统无法添加处理的动画。为此,我们需要将Animation
结构体赋值给手势所创建的Transaction
结构体中的animation
属性,如下所示。
示例12-7:定义LongPressGesture
识别器
struct ContentView: View {
@GestureState private var pressing: Bool = false
@State private var expand: Bool = false
var body: some View {
Image(.spot1)
.resizable()
.scaledToFit()
.frame(width: 160, height: 200)
.opacity(pressing ? 0 : 1)
.gesture(
LongPressGesture(minimumDuration: 1)
.updating($pressing) { value, state, transaction in
state = value
transaction.animation = Animation.easeInOut(duration: 1.5)
}
.onEnded { value in
expand = true
}
)
.sheet(isPresented: $expand) {
ShowImage()
}
}
}
它和onLongPressGesture()
修饰符创建的是相同的应用(参见示例12-3)。在用户按住图片一秒时,透明度发生改变,到时间后会打开弹窗。值和之前的处理方法相同,但没有直接通过@State
属性进行处理,而是将闭包接收到值赋值给pressing
属性的指针。这里,我们通过value
和state
这两个名称标识值和指针,但这些名称可任选。一旦将新值赋给state
,pressing
属性的值会发生改变,透明度也发生相应的调整。一秒后,执行onEnded()
方法,true
值会赋给expand
属性,进而打开弹窗。
虽然我们可以你示例12-7中那样直接处理updating()
方法所生成的值,这个方法设计是用于通过枚举处理状态的。我们没有将方法所接收到值直接赋给@GestureState
属性,而是将枚举值赋给该属性,然后通过枚举获取该状态,如下例所示。
示例12-8:通过枚举控制手势的状态
import SwiftUI
enum PressingState {
case active
case inactive
var isActive: Bool {
switch self {
case .active:
return true
case .inactive:
return false
}
}
}
struct ContentView: View {
@GestureState private var pressingState = PressingState.inactive
@State private var expand: Bool = false
var body: some View {
Image(.spot1)
.resizable()
.scaledToFit()
.frame(width: 160, height: 200)
.opacity(pressingState.isActive ? 0 : 1)
.gesture(
LongPressGesture(minimumDuration: 1)
.updating($pressingState) { value, state, transaction in
state = value ? .active : .inactive
transaction.animation = Animation.easeInOut(duration: 1.5)
}
.onEnded { value in
expand = true
}
)
.sheet(isPresented: $expand) {
ShowImage()
}
}
}
示例功能和之前相同,但这里使用枚举来捕获手势状态。枚举名为PressingState
,包含两个分支,active
和inactive
,以及一个返回布尔值的计算属性,响应实例的当前值(true
为active
,false
为inactive
)。此时,不再定义Bool
类型的@GestureState
属性来存储updating()
方法所接收的值,我们可以定义一个PressingState
类型的属性来存储一个枚举值。我们调用这个属性pressingState
并将其赋值给updating()
方法。调用方法时,根据方法接收到值对这个属性赋值active
和inactive
。在读取opacity()
修饰符的状态时,我们通过将@GestureState
属性换成isActive
属性来获取布尔值。如果pressingState
属性的当前值为active
,isActive
属性返回true
,而opacity
被设置为0。否则返回false
,opacity
被设置为1。
结果和之前相同,但在处理复杂手势或在合并多个手势时使用枚举类型就非常必要了。
其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记
代码请见:GitHub仓库