其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记
手势识别器
手势是用户在屏幕上执行的动作,如点击、滑动或捏合。这些手势很难识别,因为屏幕上只能返回手指的位置。为此,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:监测图片上的点击手势
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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:展开图片
1 2 3 4 5 6 7 8 9 10 |
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:监测长按手势
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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:禁用点击手势
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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:定义内容区
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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
识别器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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
识别器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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:通过枚举控制手势的状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
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。
结果和之前相同,但在处理复杂手势或在合并多个手势时使用枚举类型就非常必要了。
放大手势
放大手势常被称为捏合手势,因为常常在用户张开或捏合两个手指时进行识别。通常这个手势实现用于让用户放大或缩小图片。
发送给updating()
、onChanged()
和onEnded()
方法的值是一个CGFloat
,表示乘上当前比例的倍数,得到图片最终的比例,如下例所示。
示例12-9:定义一个MagnificationGesture
手势
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct ContentView: View { @GestureState private var magnification: CGFloat = 1 @State private var zoom: CGFloat = 1 var body: some View { Image(.spot1) .resizable() .scaledToFit() .frame(width: 160, height: 200) .scaleEffect(zoom * magnification) .gesture(MagnificationGesture() .updating($magnification) { value, state, transaction in state = value } .onEnded { value in zoom = zoom * value } ) } } |
示例12-9中的代码定义了两个状态,一个用于记录放大倍数,另一个用于存储最终值。这是为了允许用户多次放大或缩小。执行手势时,倍数值存储在magnification
属性中,但zoom
属性的值直到手势完成时才发生改变,因此在下次用户放大或缩小时,新的比例以上次为基准计算。
为设置图片为用户所选比例,我们对Image
视图应用scaleEffect()
修饰符,并通过将zoom
属性值(用户设置的上一个比例)乘上magnification
属性的值(手势所产生的倍数)计算新的比例。结果就是图片根据手指的移动放大或缩小。
✍️跟我一起做:使用示例12-9中的代码更新ContentView.swift
文件。捏合两个手指来放大或缩小。在模拟器或画面中运行应用时,点击键盘上的Option键激活手势。
示例12-9中的示例允许用户任意放在和缩小图片,但大部分情况下我们需要限制视图的比例为合理值或符合应用的目的。要设置这些限制,我们需要在两处控制比例:对视图应用scaleEffect()
修饰符时,以及手势结束最终的比例设置为zoom
属性时。
示例12-10:确定最小和最大比例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
struct ContentView: View { @GestureState private var magnification: CGFloat = 1 @State private var zoom: CGFloat = 1 var body: some View { Image(.spot1) .resizable() .scaledToFit() .frame(width: 160, height: 200) .scaleEffect(getCurrentZoom(magnification: magnification)) .gesture(MagnificationGesture() .updating($magnification) { value, state, transaction in state = value } .onEnded { value in zoom = getCurrentZoom(magnification: value) } ) } func getCurrentZoom(magnification: CGFloat) -> CGFloat { let minZoom: CGFloat = 1 let maxZoom: CGFloat = 2 var current = zoom * magnification current = max(min(current, maxZoom), minZoom) return current } } |
本例中,视图的缩放比例限定在最小为1,最大为2。因我们需要执行一些操作来限定比例为这些值,我们将处理移到一个方法getCurrentZoom()
中,在需要时进行调用。该方法定义了最大最小比例这两个常量,然后通过乘上zoom
属性和放大倍数来计算当前值,最后使用min()
和max()
函数来限定最小为1倍,最大为2倍的结果。min()
函数比较当前比例和最大允许比例,返回两者中的最小值(如果值大于2,返回2),而max()
函数比较结果和最小允许比例,返回两者中的最大值(如值小于1,返回1)。getCurrentZoom()
方法由scaleEffect()
修饰符调用,用于设置视图的比例,onEnded()
方法设置最终比例。因此用户可以放大及缩小图片,但最大为2倍,最小为1倍。
✍️跟我一起做:使用示例12-10中的代码更新ContentView
结构体。则可以按minZoom
和maxZoom
常量所设定的限制来设置视图大小。
旋转手势
旋转手势为用户用两根手势触摸屏幕并做环形移动。常用于旋转图片。就像前面的手势一样,如果希望用户多次执行手势,就需要存储两个状态,一个为当前旋转,另一个是最终旋转。手势所生成的值为Angle
类型的结构体。我们之前使用过这个结构体。它包含两个类型方法,一个用角度创建实例(degrees
(Double
)),另一个通过弧度radians
(Double
)),但在我们示例中将通过手势来旋转图片,因此对当前角度加上手势所产生的变化角度。
示例12-11:定义RotationGesture
识别器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct ContentView: View { @GestureState private var rotationAngle: Angle = Angle.zero @State private var rotation: Angle = Angle.zero var body: some View { Image(.spot1) .resizable() .scaledToFit() .frame(width: 160, height: 200) .rotationEffect(rotation + rotationAngle) .gesture(RotationGesture() .updating($rotationAngle) { value, state, transaction in state = value } .onEnded { value in rotation = rotation + value } ) } } |
本例中应用rotationEffect()
修饰符来旋转视图。角度通过计算两个状态属性而得。我们在手势结束时将当前旋转加上之前的旋转来保存当前状态,以妨用记希望从这个角度再次旋转图片。
图12-3:由用户旋转的图片
✍️跟我一起做:使用示例12-11中的代码更新ContentView
视图。此时可以像图12-3中那样旋转视图。在模拟器或画布中运行应用时,按住键盘上的Option键来启用该手势。
拖拽手势
拖拽操作可以将内容从一个应用移动到另一个应用,或是从同一个应用的一个区域移动到另一个区域。这个工具可在屏幕上能共享两个或多个窗口时使用,如iPad和Mac电脑。在Mac电脑端,流程非常简单。我们可以同时打开两个或多个窗口,使用鼠标将一个窗口的内容拖拽到另一个窗口上。在iPad上,我们需要进行分屏。为此,iPad顶部有三个小点的图标,可以点击它来将屏幕分享给其它应用。
图12-4:iPad上的分屏工具
在点击三点图标时,系统显示有三个选项的菜单(见图12-4右侧)。Full Screen选项将整个屏幕传给应用,Split View选项将屏幕一分为二,左侧显示当前应用,右侧显示另一个应用,Slide Over选项将应用移到显示在另一个应用之上的浮层窗口。如果选择第二或第三个选项,屏幕上会显示两个应用,可以在应用间拖拽内容。
要允许用户将内容拖出或拉入应用,我们需要告诉系统哪些应用可以拖出或接收拉入。为此SwiftUI中包含了如下的修饰符。
- draggable(Transferable, preview: Closure):该修饰符指定视图为拖拽操作源。第一个参数是一个遵循
Transferable
协议的值,表示会在处理过程中传输的数据。preview
参数提供的视图在用户执行拖拽手势时显示。 - dropDestination(for: Type, action: Closure):该修饰符指定拖拽操作的目标视图。
for
参数是我们希望视图接收值的数据类型的指针,action
参数传入的闭包处理手势所传输的数据。
拖拽手势在视图中执行,但传输的数据由代码来决定。这并不表示我们不能传输所希望传输的数据,只是数据必须以应用能够识别的方式呈现。为此,框架定义了Transferable
协议。该协议准备待发送的数据并处理在操作中接收的数据。虽然自定义数据类型可以遵循这一协议,但有些Swift数据类型和SwiftUI视图默认已进行了支持。例如在希望让用户将图片从一个应用拖到另一个应用时,我们可以使用Image
视图。
示例12-12:允许用户拖拽图片
1 2 3 4 5 6 7 8 9 10 11 12 |
struct ContentView: View { var body: some View { VStack { Image(.husky) .resizable() .scaledToFit() .frame(width: 300, height: 400) .draggable(Image(.husky)) Spacer() } } } |
这是一个简单的应用,使用Image
视图显示一张哈士奇的照片,但因为我们应用了draggable
修饰符,用户可以将照片拖到另一个应用中。为告知系统可在应用间分享什么数据,我们在修饰符中传入了同一张图片的另一个Image
视图。Image
视图符合Transferable
协议,因此系统知道如何传输数据,外部应用也知道如何进行处理。图12-5中展示了在iPad上运行该应用并与照片库共享屏幕。在将哈士奇插入右侧的相簿时,图片会加入该相簿。
图12-5:应用间的拖拽操作
✍️跟我一起做:创建一个多平台项目。下载husky.png并添加至资源目录。使用示例12-2中的代码更新ContentView
视图。在iPad模拟器上运行应用。点击屏幕上方的三个点,选择Split View选项(参见图12-4)。打开图片库。应该会看到如图12-5所示的界面。打开其中的一个相簿,将哈士奇拖入其中。图片就会加入到相簿之中。
系统通过所拖拽的视图创建图片,使用它对用户展示预览,但我们可以将其它的视图会给draggable()
修饰符,来创建一个自定义预览。例如,下例换成了一个SF图标。
示例12-13:为手势提供自定义预览
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ContentView: View { var body: some View { VStack { Image(.husky) .resizable() .scaledToFit() .frame(width: 300, height: 400) .draggable(Image(.husky), preview: { Image(systemName: "scope") .font(.system(size: 50)) }) Spacer() } } } |
图12-6:自定义预览
另一方面,如果我们希望允许用户将内容拖放到我们的应用中,就需要提供一个接收数据的视图。要将视图转换为接收拖拽操作的目标,必须应用dropDestination()
修饰符。该修饰符接收到的数据类型决定了视图可接收的数据类型以及处理的闭包。和之前一样,这一类型必须符合Transferable
协议。例如,可以使用Image
视图。
示例12-14:将图片拖入Image
视图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct ContentView: View { @State private var picture: Image = Image(.nopicture) var body: some View { VStack { picture .resizable() .scaledToFit() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: 400) .dropDestination(for: Image.self, action: { elements, location in if let image = elements.first { picture = image return true } return false }) Spacer() } } } |
赋值给action
参数的闭包接收两个值:包含用记拖入内容的列表,以及内容所拖入视图位置的CGPoint
结构体。因我们心Image
结构体处理数据,用户只能拖入图片或是自动转换为Image
视图的值,因此可以直接赋值给@State
属性在屏幕上显示。注意闭包必须返回布尔值来表示操作的结果。如果我们可以获取并处理这些值,返回true
,否则返回false
。
图12-7:将图片拖入应用
✍️跟我一起做:使用示例12-14中的代码更新ContentView
视图。下载nopicture.png并添加到资源目录。在iPad模拟器上运行应用,用分屏与图片库共享屏幕。将哈士奇照片拖回到应用中。nopicture.png图就会更换为哈士奇了。
dropDestination()
修饰符可使用绑定属性来告知应用由用户拖拽的内容何时进入或离开视图所占区域。例如,我们可以在上例中添加@State
属性,在内容进入或离开该区域时更改视图的颜色。
示例12-15:向用户提供反馈
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
struct ContentView: View { @State private var picture: Image = Image(.nopicture) @State private var didEnter: Bool = false var body: some View { VStack { picture .resizable() .scaledToFit() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: 400) .overlay(didEnter ? Color.green.opacity(0.2) : Color.clear) .dropDestination(for: Image.self, action: { elements, location in if let image = elements.first { picture = image return true } return false }, isTargeted: { value in didEnter = value }) Spacer() } } } |
didEnter
属性存储一个布尔值。如果内容进入视图区,dropDestination()
修饰符对该属性赋值true
,因此我们可以使用它来向用户提供反馈。本例中我们对Image
视图创建一个浮层。如果didEnter
属性为true
,显示绿色浮层,否则为透明色。
图12-8:拖拽操作反馈
✍️跟我一起做:使用示例12-15中的代码更新ContentView
视图。在iPad模拟器上运行应用,像之前一样进行分屏。在将图片拖到上面时拖放区会变成绿色。
至此我们使用的是Image
视图来传输数据。该数据遵循Transferable
协议,因此无需做其它配置直接通过拖拽传输数据。但也可以使用自定义数据类型,包括结构体和类。我们只需要让它们符合Transferable
协议即可。该协议只要求实现如下类型属性。
- transferRepresentation:该类型属性返回表示待传输数据的结构体。
该属性必须返回符合TransferRepresentation
协议的结构体。框架定义了多个结构创建这些表现。最常用的是发送和接收编解码数据的CodableRepresentation
、用于原始数据的DataRepresentation
、用于文件的FileRepresentation
以及用于预定义表现的ProxyRepresentation
。以下是可用于创建这些结构体的初始化方法。
- CodableRepresentation(for: Type, contentType: UTType):这个初始化方法创建展示可编码和解码数据的结构体。
for
参数是数据类型本身的指针,而contentType
参数决定允许用户拖拽值的类型。 - DataRepresentation(contentType: UTType, exporting: Closure, importing: Closure):这个初始化方法创建表示原始数据的结构体。
contentType
参数定义允许用户拖拽值的类开,exporting
参数提供在拖拽视图时传输的数据,importing
参数通过用户放入数据的数据类型创建实例。该结构体还包含另两个仅导入或导出数据的初始化方法:DataRepresentation(importedContentType: UTType, importing: Closure)
和DataRepresentation(exportedContentType: UTType, exporting: Closure)
。 - FileRepresentation(contentType: UTType, shouldAttemptToOpenInPlace: Bool, exporting: Closure, importing: Closure):此初始化方法创建一个表示文件的结构体。
contentType
参数指定允许用户拖拽的文件类型,shouldAttemptToOpenInPlace
参数表示接收者是否可以访问原始文件,exporting
参数拖拽操作中发送的文件,importing
参数通过放入操作所接到的信息创建文件。这个结构体还包含两个仅用于导入或导出文件的初始化方法:FileRepresentation(importedContentType: UTType, shouldAttemptToOpenInPlace: Bool, importing: Closure)
和FileRepresentation(exportedContentType: UTType, shouldAllowToOpenInPlace: Bool, exporting: Closure)
。 - ProxyRepresentation(exporting: Closure, importing: Closure):这一初始化方法创建使用已有适合该类型的传输表现来创建一个结构体。
exporting
参数提供拖拽元素时使用的传输表现的指针,importing
参数提供放下元素时使用的传输表现的指针。这个结构体还包含两个仅用于导入或导出数据的初始化方法ProxyRepresentation(importing: Closure)
和ProxyRepresentation(exporting: Closure)
。
这些结构体是为准备待发送数据,以及处理在拖拽操作中接收的数据。但不论使用哪个结构体,值都以Data
结构体进行传输。为了让应用知道如何处理这一数据,我们需要通过UTType
结构体声明内容类型。我们在第10章中已经介绍过这一结构体。稍后会学习我们可以定义自己的类型,但也可以使用由框架提供的标准类型。在下例中,在视图拖入其它应用时我们使用png
类型传输PNG图片。
示例12-16:拖拽自定义值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import SwiftUI struct ImageRepresentation: Transferable { let name: String let image: UIImage static var transferRepresentation: some TransferRepresentation { DataRepresentation(exportedContentType: .png, exporting: { value in return value.image.pngData()! }) } } struct ContentView: View { @State private var picture: UIImage = UIImage(named: "nopicture")! var body: some View { VStack { Image(uiImage: picture) .resizable() .scaledToFit() .draggable(ImageRepresentation(name: "My Picture", image: picture)) .dropDestination(for: Data.self, action: { elements, location in if let data = elements.first, let image = UIImage(data: data) { picture = image return true } return false }) Spacer() } } } |
这个应用中我们只希望传输图片,因此定义了一个结构体ImageRepresentation
,让它遵循Transferable
协议、实现transferRepresentation
属性,以及定义一个DataRepresentation
结构体获取图像数据并返回。
虽然我们传输的是表示图像的数据,但使用了自定义的数据类型来进行处理。在用户拖拽视图时,draggable()
修饰符创建了一个ImageRepresentation
结构体实例,将它发送给赋值给DataRepresentation
结构体的闭包,这样就可以在image
属性中获取到UIImage
对象并使用pngData()
方法转换数据并返回。外部应用接收这一数据,因UTType
结构体将其识别为PNG图片并照此处理。注意为了知道用户在拖哪张图片,我们将picture
属性的数据类型修改为了UIImage
对象,因此需要使用Image(uiImage:)
初始化方法来在屏幕上显示图片。因为使用UIImage
对象代替Image
视图来处理图片,我们还更新了dropDestination()
修饰符来处理这个值。过程很简单。我们告诉dropDestination()
修饰符所接收到的值是一个Data
结构体,然后使用Image(uiImage:)
初始化方法将值转换为图片(参见第10章中的图)。现在用户可以在视图和外部应用之间拖放图片了,数据和图片之间会自动完成相互转换。
✍️跟我一起做:使用示例12-16中的代码更新ContentView.swift
文件。在iPad模拟器上运行应用。打开图片库。此时就可以在应用音拖放图片了。
在上例中,我们使用DataRepresentation
结构体来准备拖拽操作(导出)中所发送的数据,但使用Data
类型接收由用户所拖拽的图片。这是因为我们对其它应用所发送的数据没有控制权。但只要应用知道如何处理我们就可以接收及发送自定义数据类型。例如,允许拖拽我们应用中的内容时,我们可以控制整个流程,因此可以传输任意我定义数据类型。唯一的要求是数据要进行编码。这很容易通过Codable
协议和CodableRepresentation
结构体来实现,但因为我们使用了自定义数据类型,我们还需要定义自定义的UTType
。以下是该结构体的初始化方法。
- UTType(exportedAs: String, conformingTo: UTType?):此初始化方法创建一个自定义的
UTType
,由赋值给exportedAs
参数的字符串标识。conformingTo
参数预定义的UTType
,自定义类型将其用作指针。
UTType
结构体需要一个标识符,必须通过设置来进行创建。我们需要进入项目设置(图5-4,6号图),打开Info面板,展开Exported Type Identifiers,点击+按钮插入值。
图12-9:自定义内容类型
需要的值有Description、Identifier和Conforms To。Description只是描述类型的文本,Identifier必须唯一,因此推荐使用反向域名,就像示例中寻样,Conforms To选项是一个紧密匹配我们的类型的预定义的UTType
。本例中我们使用public.data
类型,这样系统知道在传输的是原始数据。
建立好自定义内容类型后,我们需要扩展UTType
结构体,包含一个表示它的类型属性。下例中,我们创建一个管理数据的自定义结构体(一个图片和一个标识符),使用product
属性扩展UTType
结构体存储自定义义类型。
示例12-17:使用自定义内容类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import SwiftUI import Observation import UniformTypeIdentifiers struct PictureRepresentation: Identifiable, Codable, Transferable { var id = UUID() var image: Data static var transferRepresentation: some TransferRepresentation { CodableRepresentation(for: PictureRepresentation.self, contentType: .product) } } extension UTType { static var product = UTType(exportedAs: "org.alanhou.pictures") } @Observable class ApplicationData { var listPictures: [PictureRepresentation] init() { listPictures = [ PictureRepresentation(image: UIImage(named: "spot1")!.pngData()!), PictureRepresentation(image: UIImage(named: "spot2")!.pngData()!), PictureRepresentation(image: UIImage(named: "spot3")!.pngData()!) ] } } |
定义好了包含数据的PictureRepresentation
以及扩展了UTType
来包含内容类型,我们使用三个实例初始化模型,分别包含图片spot1、spot2和spot3。这个应用允许用户将这些图片从屏幕顶部拖到底部更大的视图中,我们会从列表中删除图片,所以包含了UUID
值来进行标识。界面中要用到ForEach
循环在顶部列出所有可用的图片,在底部有另一个视图供用户完成拖放。
示例12-18:拖拽自定义值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData @State private var currentPicture: UIImage = UIImage(named: "nopicture")! var body: some View { VStack { HStack(spacing: 10) { ForEach(appData.listPictures) { picture in Image(uiImage: UIImage(data: picture.image) ?? UIImage(named: "nopicture")!) .resizable() .frame(width: 80, height: 100) .draggable(picture) } }.frame(height: 120) Image(uiImage: currentPicture) .resizable() .scaledToFit() .dropDestination(for: PictureRepresentation.self, action: { elements, location in if let picture = elements.first { currentPicture = UIImage(data: picture.image) ?? UIImage(named: "nopicture")! appData.listPictures.removeAll(where: { $0.id == picture.id }) return true } return false }) Spacer() } } } #Preview { ContentView().environment(ApplicationData()) } |
draggable()
和dropDestination()
修饰符与PictureRepresentation
结构体相配合传输数据。在用户将图片播放到目标视图中时,数据会解码并创建一个PictureRepresentation
结构体实例,我们就可以处理这些值了。本例中,我们将图片赋值给Image
视图,然后从列表中移除原始图片。结果如下所示。
图12-10:拖放自定义数据
✍️跟我一起做:根据示例12-17中的模型创建一个Swift文件ApplicationData.swift
。再用示例12-18中的代码更新ContentView
视图。下载spot1.jpg、spot2.jpg和spot3.jpg并添加到资源目录。进入项目设置(图5-4,6号图),打开Info面板,展开Exported Type Identifiers,点击+按钮插入值,参见图12-9。在iPhone模拟器上运行应用。把图片拖放到nopicture.png上。原始图片会删除,效果见图12-10(右图)。
代码请见:GitHub仓库