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

放大手势

放大手势常被称为捏合手势,因为常常在用户张开或捏合两个手指时进行识别。通常这个手势实现用于让用户放大或缩小图片。

发送给updating()onChanged()onEnded()方法的值是一个CGFloat,表示乘上当前比例的倍数,得到图片最终的比例,如下例所示。

示例12-9 :定义一个MagnificationGesture手势

less 复制代码
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:确定最小和最大比例

swift 复制代码
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结构体。则可以按minZoommaxZoom常量所设定的限制来设置视图大小。

旋转手势

旋转手势为用户用两根手势触摸屏幕并做环形移动。常用于旋转图片。就像前面的手势一样,如果希望用户多次执行手势,就需要存储两个状态,一个为当前旋转,另一个是最终旋转。手势所生成的值为Angle类型的结构体。我们之前使用过这个结构体。它包含两个类型方法,一个用角度创建实例(degreesDouble)),另一个通过弧度radiansDouble)),但在我们示例中将通过手势来旋转图片,因此对当前角度加上手势所产生的变化角度。

示例12-11 :定义RotationGesture识别器

less 复制代码
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:允许用户拖拽图片

css 复制代码
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:为手势提供自定义预览

css 复制代码
truct 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视图

php 复制代码
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:向用户提供反馈

less 复制代码
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:拖拽自定义值

swift 复制代码
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:使用自定义内容类型

swift 复制代码
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:拖拽自定义值

less 复制代码
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(右图)。

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

代码请见:GitHub仓库

相关推荐
旧林84320 分钟前
第八章 利用CSS制作导航菜单
前端·css
yngsqq32 分钟前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing1 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风1 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟1 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾2 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧2 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm2 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7012 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm2 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架