大师学SwiftUI第12章 - 手势 Part 1

手势识别器

手势是用户在屏幕上执行的动作,如点击、滑动或捏合。这些手势很难识别,因为屏幕上只能返回手指的位置。为此,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:监测图片上的点击手势

scss 复制代码
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:展开图片

scss 复制代码
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:监测长按手势

less 复制代码
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:禁用点击手势

less 复制代码
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视图应用手势识别器时,在用户触碰视图所占据的任意区域时会识别手势。但并非总是如此。容器视图,如VStackHStack,仅对其内容所占据的区域执行手势识别。要确保视图的每个部分都能识别手势,我们需要强制内容占据整个区域。前面我们碰到过这种问题(示例7-36 )。这时,我们需要使用Color视图定义背景来定义识别点击手势的区域。这可以满足我们的要求,但它创建了界面中不需要有的内容。更好的方案时应用contentShape()修饰符。这一修饰符允许我们定义了手势命中区,而又不要对视图添加真实的内容。

下例中,我们重建之前项目中的视图,创建一个行列表,但这次我们不使用Color视图来响应手势,而是使用Rectangle视图和contentShape()修饰符来定义了内容行。这让用户可以点击行的任意区域来进行选中。

示例12-5:定义内容区

scss 复制代码
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识别器

scss 复制代码
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结构体的一个方法,因此是在结构体的实例而不是在视图中调用。

✍️跟我一起做:本例需要用到示例12-2 中定义的ShowImage视图。使用示例12-6 中的代码更新ContentView视图。会在屏幕上看到一张小图,点击该图会打开弹窗。

长按手势

类似TapGesture结构体,LongPressGesture结构体创建一个简单手势识别器,但它在执行手势时会有一些活动,因此除onEnded()方法外,如果希望在按压视图时执行任务的话还可以实现updating()方法。

在实现updating()方法时,我们需要注意几点。第一,如前所述,该方法需要@GestureState属性而不是@State属性。@GestureState属性存储当前状态,也会在手势结束时重置为其初始值,因此应确保初始值为属性应当具备的初始值。第二,我们需要通过传给方法的闭包自己更新该状态,但不是直接更新,而是通过方法所接收到指针(通过名为state)。第三,因为我们是在updating()方法内处理修改,系统无法添加处理的动画。为此,我们需要将Animation结构体赋值给手势所创建的Transaction结构体中的animation属性,如下所示。

示例12-7 :定义LongPressGesture识别器

less 复制代码
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属性的指针。这里,我们通过valuestate这两个名称标识值和指针,但这些名称可任选。一旦将新值赋给statepressing属性的值会发生改变,透明度也发生相应的调整。一秒后,执行onEnded()方法,true值会赋给expand属性,进而打开弹窗。

虽然我们可以你示例12-7 中那样直接处理updating()方法所生成的值,这个方法设计是用于通过枚举处理状态的。我们没有将方法所接收到值直接赋给@GestureState属性,而是将枚举值赋给该属性,然后通过枚举获取该状态,如下例所示。

示例12-8:通过枚举控制手势的状态

php 复制代码
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,包含两个分支,activeinactive,以及一个返回布尔值的计算属性,响应实例的当前值(trueactivefalseinactive)。此时,不再定义Bool类型的@GestureState属性来存储updating()方法所接收的值,我们可以定义一个PressingState类型的属性来存储一个枚举值。我们调用这个属性pressingState并将其赋值给updating()方法。调用方法时,根据方法接收到值对这个属性赋值activeinactive。在读取opacity()修饰符的状态时,我们通过将@GestureState属性换成isActive属性来获取布尔值。如果pressingState属性的当前值为activeisActive属性返回true,而opacity被设置为0。否则返回falseopacity被设置为1。

结果和之前相同,但在处理复杂手势或在合并多个手势时使用枚举类型就非常必要了。

其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记

代码请见:GitHub仓库

相关推荐
Digitally2 小时前
如何在电脑上轻松访问 iPhone 文件
ios·电脑·iphone
安和昂2 小时前
【iOS】YYModel源码解析
ios
pop_xiaoli3 小时前
UI学习—cell的复用和自定义cell
学习·ui·ios
大熊猫侯佩3 小时前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩4 小时前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple
大熊猫侯佩4 小时前
使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用
数据库·swift·apple
大熊猫侯佩4 小时前
SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决
swiftui·swift·apple
Daniel_Coder5 小时前
Xcode 16.4 + iOS 18 系统运行时崩溃:___cxa_current_primary_exception 符号丢失的原因与解决方案
ios·xcode·ios 18·dyld·libc++abi
烈焰晴天7 小时前
使用ReactNative加载Svga动画支持三端【Android/IOS/Harmony】
android·react native·ios
sg_knight8 小时前
Flutter嵌入式开发实战 ——从树莓派到智能家居控制面板,打造工业级交互终端
android·前端·flutter·ios·智能家居·跨平台