Skip to content

第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参数,实现双击、三击等多击手势的识别,示例代码如下:

swift
Text("Hello, World!")
    .onTapGesture(count: 2) {
        print("Double tapped!") // 双击触发
    }

好了,我们来看看比简单点击更有趣的手势。对于长按手势,可以使用onLongPressGesture(),示例代码如下:

swift
Text("Hello, World!")
    .onLongPressGesture {
        print("Long pressed!") // 长按触发
    }

和点击手势一样,长按手势也支持自定义。例如,你可以指定长按的最短持续时间,只有当按压时间达到指定秒数时,才会触发对应的操作闭包。下面的示例代码中,只有长按2秒才会触发操作:

swift
Text("Hello, World!")
    .onLongPressGesture(minimumDuration: 2) {
        print("Long pressed!") // 长按2秒后触发
    }

你甚至可以添加第二个闭包,用于监听手势状态的变化。这个闭包会接收一个布尔类型的参数,其工作机制如下:

  1. 当你按下手指时,该闭包会被调用,参数值为true;
  2. 如果你在手势被识别之前松开手指(例如,使用2秒识别的长按手势时,1秒后就松开),该闭包会被调用,参数值为false;
  3. 如果你按住手指直到手势被成功识别(达到指定时长),该闭包会被调用,参数值为false(因为手势已结束),同时你的完成闭包也会被调用。

你可以使用以下代码亲自尝试:

swift
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()修饰符中使用这些属性,然后在手势中更新这些值,示例代码如下:

swift
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()修饰符,示例代码如下:

swift
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():

swift
struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, World!")
                .onTapGesture {
                    print("Text tapped") // 文本视图被点击时打印
                }
        }
        .onTapGesture {
            print("VStack tapped") // VStack被点击时打印
        }
    }
}

在这种情况下,SwiftUI始终会优先识别子视图的手势,因此当你点击上面的文本视图时,只会打印“Text tapped”。但如果你想改变这种优先级,可以使用highPriorityGesture()修饰符,强制优先识别父视图的手势,示例代码如下:

swift
struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, World!")
                .onTapGesture {
                    print("Text tapped") // 文本视图被点击时打印
                }
        }
        .highPriorityGesture(
            TapGesture()
                .onEnded {
                    print("VStack tapped") // 优先识别VStack的点击手势并打印
                }
        )
    }
}

此外,你还可以使用simultaneousGesture()修饰符,告诉SwiftUI你希望父视图和子视图的手势同时被触发,示例代码如下:

swift
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允许我们创建“手势序列”,即只有当一个手势成功识别后,另一个手势才会生效。实现这种功能需要多做一些思考,因为手势之间需要相互关联,不能直接附加到视图上。

下面是一个手势序列的示例:只有先长按圆形视图,才能拖动它。代码如下:

swift
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()修饰符,代码如下:

swift
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()修饰符,代码如下:

swift
Circle()
    .fill(.red)
    .frame(width: 300, height: 300)
    .onTapGesture {
        print("Circle tapped!") // 圆形被点击时打印(但此时不会触发)
    }
    .allowsHitTesting(false) // 禁用圆形视图的命中测试(即不响应点击)

现在,无论你点击圆形的哪个位置,都会打印“Rectangle tapped!”,因为圆形视图会拒绝响应所有点击。

控制用户交互的另一种实用方式是使用contentShape()修饰符,它允许我们指定视图的可点击形状。默认情况下,圆形视图的可点击形状是与其大小相同的圆形,但你可以指定其他形状,示例代码如下:

swift
Circle()
    .fill(.red)
    .frame(width: 300, height: 300)
    .contentShape(.rect) // 将可点击形状指定为矩形
    .onTapGesture {
        print("Circle tapped!") // 点击圆形视图的框架范围内(矩形区域)都会触发
    }

contentShape()修饰符真正发挥作用的场景是:当你给包含间隔器(Spacer)的栈视图(如VStack、HStack)添加点击操作时。默认情况下,SwiftUI不会在点击栈视图的间隔器时触发操作。

你可以尝试以下示例代码:

swift
VStack {
    Text("Hello")
    Spacer().frame(height: 100) // 高度为100的间隔器
    Text("World")
}
.onTapGesture {
    print("VStack tapped!") // VStack被点击时打印
}

运行这段代码后,你会发现点击“Hello”标签和“World”标签时会触发打印,但点击两个标签之间的间隔器时不会触发。不过,如果我们给VStack添加contentShape(.rect)修饰符,那么整个VStack的区域(包括间隔器)都会变成可点击的,代码如下:

swift
VStack {
    Text("Hello")
    Spacer().frame(height: 100) // 高度为100的间隔器
    Text("World")
}
.contentShape(.rect) // 将VStack的可点击形状指定为矩形(覆盖整个框架)
.onTapGesture {
    print("VStack tapped!") // 点击VStack的任何区域(包括间隔器)都会触发
}

本站使用 VitePress 制作