第86天 项目 17 第一部分
当苹果推出iPhone X时,他们舍弃了自iPhone早期以来一直存在的一个部件:主屏幕按钮。这个简单的硬件部件从iPhone最初发布时就已存在,无论用户正在做什么、使用什么应用,它都能帮助用户返回主屏幕——这大大降低了设备的使用难度。
但随着我们逐渐习惯使用越来越大的全面屏,苹果开始更多地依赖手势操作:我们有了手势识别器、滑动关闭应用的功能、下拉和上拉调出系统功能菜单等。
而在iPhone X上,苹果真正将手势操作提升到了新的高度,因为没有了主屏幕按钮,几乎所有操作都变成了手势操作。苹果甚至在WWDC18(2018年苹果全球开发者大会)上专门做了一场演讲,鼓励开发者更多地思考手势设计,当时苹果人机界面设计团队的Chan Karunamuni曾就手势发表过一句非常重要的观点:“当手势体验非常好时,有时用户甚至会觉得它很自然,或者说很神奇。”
你想开发出操作自然的应用吗?当然想。我们即将开发的这款新应用将大量依赖手势操作,只需使用几秒钟,你就能熟练地快速使用这些手势。这正是我们的目标:让手势操作变得如此自然,以至于你很难想象还有其他操作方式。
今天你需要学习三个主题,从中你将了解手势操作、命中测试等相关知识。
- Flashzilla:项目介绍
- 如何在SwiftUI中使用手势
- 使用allowsHitTesting()禁用用户交互
Flashzilla:项目介绍
作者:Paul Hudson 2024年2月21日
在这个项目中,我们将开发一款帮助用户通过闪卡学习知识的应用——闪卡的一面会写有内容(例如“to buy”),另一面则会有对应的翻译或解释(例如“comprar”,西班牙语“购买”的意思)。当然,这是一款数字应用,所以我们无需考虑“另一面”的物理限制,只需让用户点击闪卡时显示详细内容即可。
这个项目的名字,其实是我开发的第一款iOS应用的名字——那款应用发布的时间非常早,当时还叫iPhoneOS(因为iPad尚未发布)。有趣的是,这款应用在App审核时曾被苹果拒绝,原因是产品名称中包含“Flash”一词,而当时苹果极力避免App Store中出现任何与“Flash”相关的内容!时代变化真是快啊……
言归正传,这个项目中有很多有趣的知识点需要学习,包括手势操作、计时器等。现在请你使用“App”模板创建一个新的iOS项目,并将其命名为“Flashzilla”。和往常一样,在正式开发项目核心功能之前,我们需要先学习一些相关技术,那就开始吧……
如何在SwiftUI中使用手势
作者:Paul Hudson 2024年2月21日
SwiftUI为视图提供了丰富的手势支持,并且极大地简化了复杂操作,让我们可以专注于核心功能的开发。在之前的项目中,我们已经使用过onTapGesture()(点击手势),但除此之外还有其他多种手势,而且将手势组合使用的方式也很值得尝试。
关于简单的onTapGesture(),由于之前已经介绍过,这里就不再赘述了。不过需要补充一点:你可以给onTapGesture()传递一个count参数,实现双击、三击等多击手势的识别,示例代码如下:
Text("Hello, World!")
.onTapGesture(count: 2) {
print("Double tapped!") // 双击触发
}好了,我们来看看比简单点击更有趣的手势。对于长按手势,可以使用onLongPressGesture(),示例代码如下:
Text("Hello, World!")
.onLongPressGesture {
print("Long pressed!") // 长按触发
}和点击手势一样,长按手势也支持自定义。例如,你可以指定长按的最短持续时间,只有当按压时间达到指定秒数时,才会触发对应的操作闭包。下面的示例代码中,只有长按2秒才会触发操作:
Text("Hello, World!")
.onLongPressGesture(minimumDuration: 2) {
print("Long pressed!") // 长按2秒后触发
}你甚至可以添加第二个闭包,用于监听手势状态的变化。这个闭包会接收一个布尔类型的参数,其工作机制如下:
- 当你按下手指时,该闭包会被调用,参数值为true;
- 如果你在手势被识别之前松开手指(例如,使用2秒识别的长按手势时,1秒后就松开),该闭包会被调用,参数值为false;
- 如果你按住手指直到手势被成功识别(达到指定时长),该闭包会被调用,参数值为false(因为手势已结束),同时你的完成闭包也会被调用。
你可以使用以下代码亲自尝试:
Text("Hello, World!")
.onLongPressGesture(minimumDuration: 1) {
print("Long pressed!") // 长按1秒后触发(完成闭包)
} onPressingChanged: { inProgress in
print("In progress: \(inProgress)!") // 手势状态变化时触发(状态闭包)
}对于更复杂的手势,你应该使用gesture()修饰符,并结合具体的手势结构体:DragGesture(拖动手势)、LongPressGesture(长按手势)、MagnifyGesture(缩放手势)、RotateGesture(旋转手势)和TapGesture(点击手势)。这些手势结构体都有专门的修饰符,通常包括onEnded()(手势结束时触发),很多还包含onChanged()(手势进行中触发),你可以通过这些修饰符在手势进行中(使用onChanged())或手势完成后(使用onEnded())执行相应操作。
例如,我们可以给一个视图添加缩放手势,通过捏合操作放大或缩小视图。实现方法是:创建两个@State属性来存储缩放比例,在scaleEffect()修饰符中使用这些属性,然后在手势中更新这些值,示例代码如下:
struct ContentView: View {
@State private var currentAmount = 0.0 // 当前缩放增量
@State private var finalAmount = 1.0 // 最终缩放比例
var body: some View {
Text("Hello, World!")
.scaleEffect(finalAmount + currentAmount) // 应用缩放效果
.gesture(
MagnifyGesture()
.onChanged { value in
// 手势进行中:更新当前缩放增量(缩放系数-1,使初始状态为无缩放)
currentAmount = value.magnification - 1
}
.onEnded { value in
// 手势结束:将当前缩放增量叠加到最终缩放比例上,并重置当前增量
finalAmount += currentAmount
currentAmount = 0
}
)
}
}使用RotateGesture(旋转手势)旋转视图的方法与此类似,只不过需要使用rotationEffect()修饰符,示例代码如下:
struct ContentView: View {
@State private var currentAmount = Angle.zero // 当前旋转角度增量
@State private var finalAmount = Angle.zero // 最终旋转角度
var body: some View {
Text("Hello, World!")
.rotationEffect(currentAmount + finalAmount) // 应用旋转效果
.gesture(
RotateGesture()
.onChanged { value in
// 手势进行中:更新当前旋转角度增量
currentAmount = value.rotation
}
.onEnded { value in
// 手势结束:将当前旋转角度增量叠加到最终旋转角度上,并重置当前增量
finalAmount += currentAmount
currentAmount = .zero
}
)
}
}当多个手势可能同时被识别时(例如,一个视图及其父视图都附加了相同的手势),情况会变得更有趣,这就是所谓的“手势冲突”。
例如,下面的代码给文本视图及其父视图(VStack)都附加了onTapGesture():
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, World!")
.onTapGesture {
print("Text tapped") // 文本视图被点击时打印
}
}
.onTapGesture {
print("VStack tapped") // VStack被点击时打印
}
}
}在这种情况下,SwiftUI始终会优先识别子视图的手势,因此当你点击上面的文本视图时,只会打印“Text tapped”。但如果你想改变这种优先级,可以使用highPriorityGesture()修饰符,强制优先识别父视图的手势,示例代码如下:
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, World!")
.onTapGesture {
print("Text tapped") // 文本视图被点击时打印
}
}
.highPriorityGesture(
TapGesture()
.onEnded {
print("VStack tapped") // 优先识别VStack的点击手势并打印
}
)
}
}此外,你还可以使用simultaneousGesture()修饰符,告诉SwiftUI你希望父视图和子视图的手势同时被触发,示例代码如下:
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, World!")
.onTapGesture {
print("Text tapped") // 文本视图被点击时打印
}
}
.simultaneousGesture(
TapGesture()
.onEnded {
print("VStack tapped") // VStack被点击时打印
}
)
}
}这样,点击文本视图时会同时打印“Text tapped”和“VStack tapped”。
最后,SwiftUI允许我们创建“手势序列”,即只有当一个手势成功识别后,另一个手势才会生效。实现这种功能需要多做一些思考,因为手势之间需要相互关联,不能直接附加到视图上。
下面是一个手势序列的示例:只有先长按圆形视图,才能拖动它。代码如下:
struct ContentView: View {
// 存储圆形视图的拖动偏移量
@State private var offset = CGSize.zero
// 标记圆形视图是否正在被拖动
@State private var isDragging = false
var body: some View {
// 拖动手势:在拖动过程中更新偏移量和拖动状态
let dragGesture = DragGesture()
.onChanged { value in offset = value.translation }
.onEnded { _ in
withAnimation {
offset = .zero // 拖动结束后,重置偏移量
isDragging = false // 标记为未拖动状态
}
}
// 长按手势:长按结束后启用拖动状态
let pressGesture = LongPressGesture()
.onEnded { value in
withAnimation {
isDragging = true // 标记为可拖动状态
}
}
// 组合手势:强制用户先长按,再拖动
let combined = pressGesture.sequenced(before: dragGesture)
// 64x64的圆形视图:拖动时放大1.5倍,应用偏移量,并使用组合手势
Circle()
.fill(.red)
.frame(width: 64, height: 64)
.scaleEffect(isDragging ? 1.5 : 1)
.offset(offset)
.gesture(combined)
}
}手势是打造流畅、有趣用户界面的绝佳方式,但请务必向用户展示手势的使用方法,否则这些手势可能会让用户感到困惑!
使用allowsHitTesting()禁用用户交互
作者:Paul Hudson 2024年2月21日
SwiftUI拥有一套先进的命中测试算法,该算法会同时考虑视图的框架和内容。例如,如果你给文本视图添加了点击手势,那么文本视图的所有区域都是可点击的——即使你恰好按在空格的位置,也无法“穿透”文本点击到后面的内容。另一方面,如果你给圆形视图添加同样的点击手势,SwiftUI会忽略圆形视图的透明区域(即圆形之外的区域)。
为了演示这一点,我们使用ZStack(层叠视图)让圆形视图与矩形视图重叠,并给两者都添加onTapGesture()修饰符,代码如下:
ZStack {
Rectangle()
.fill(.blue)
.frame(width: 300, height: 300)
.onTapGesture {
print("Rectangle tapped!") // 矩形被点击时打印
}
Circle()
.fill(.red)
.frame(width: 300, height: 300)
.onTapGesture {
print("Circle tapped!") // 圆形被点击时打印
}
}尝试运行这段代码,你会发现:点击圆形内部会打印“Circle tapped!”,而点击圆形之外、矩形之内的区域会打印“Rectangle tapped!”——尽管圆形视图和矩形视图的框架大小完全相同。
SwiftUI提供了两种实用的方式来控制用户交互,第一种是allowsHitTesting()修饰符。当给视图添加该修饰符并将参数设为false时,该视图将不再被视为可点击对象。但这并不意味着视图完全“失效”,只是它不会响应任何点击——点击会传递给视图后面的其他元素。
我们来给圆形视图添加allowsHitTesting()修饰符,代码如下:
Circle()
.fill(.red)
.frame(width: 300, height: 300)
.onTapGesture {
print("Circle tapped!") // 圆形被点击时打印(但此时不会触发)
}
.allowsHitTesting(false) // 禁用圆形视图的命中测试(即不响应点击)现在,无论你点击圆形的哪个位置,都会打印“Rectangle tapped!”,因为圆形视图会拒绝响应所有点击。
控制用户交互的另一种实用方式是使用contentShape()修饰符,它允许我们指定视图的可点击形状。默认情况下,圆形视图的可点击形状是与其大小相同的圆形,但你可以指定其他形状,示例代码如下:
Circle()
.fill(.red)
.frame(width: 300, height: 300)
.contentShape(.rect) // 将可点击形状指定为矩形
.onTapGesture {
print("Circle tapped!") // 点击圆形视图的框架范围内(矩形区域)都会触发
}contentShape()修饰符真正发挥作用的场景是:当你给包含间隔器(Spacer)的栈视图(如VStack、HStack)添加点击操作时。默认情况下,SwiftUI不会在点击栈视图的间隔器时触发操作。
你可以尝试以下示例代码:
VStack {
Text("Hello")
Spacer().frame(height: 100) // 高度为100的间隔器
Text("World")
}
.onTapGesture {
print("VStack tapped!") // VStack被点击时打印
}运行这段代码后,你会发现点击“Hello”标签和“World”标签时会触发打印,但点击两个标签之间的间隔器时不会触发。不过,如果我们给VStack添加contentShape(.rect)修饰符,那么整个VStack的区域(包括间隔器)都会变成可点击的,代码如下:
VStack {
Text("Hello")
Spacer().frame(height: 100) // 高度为100的间隔器
Text("World")
}
.contentShape(.rect) // 将VStack的可点击形状指定为矩形(覆盖整个框架)
.onTapGesture {
print("VStack tapped!") // 点击VStack的任何区域(包括间隔器)都会触发
}