其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记
图片
如今,个人设备主要用于处理图片、视频和声音,苹果的设备也不例外。SwiftUI可以通过Image
视图显示图片,但需要其它框架的支持来处理图片、在屏幕上展示视频或是播放声音。本章中我们将展示Apple所提供的这类工具。
图片选择器
SwiftUI内置了一个PhotosPicker
结构体用于生成一个视图,允许用户从图片库中选择一张或多张照片。以下为该视图的初始化方法。
- PhotosPicker(selection: Binding, maxSelectionCount: Int?, selectionBehavior: PhotosPickerSelectionBehavior, matching: PHPickerFilter?, preferredItemEncoding: EncodingDisambiguationPolicy, photoLibrary: PHPhotoLibrary, label: Closure):这一初始化方法通过由参数所指定的配置创建一个
PhotosPicker
视图。selection
参数是一个存储所选项指针的绑定属性。maxSelectionCount
参数是我们希望用户选取的最大图片数。selectionBehavior
参数指定如何进行选取。该结构体具有类型属性default
(复选框选取)、ordered
(数字选取)、continous
(实时选取)和continousAndOrdered
(实时数字选择)。matching
参数指定视图所包含的资源类型。这个结构体的类型属性有bursts
、cinematicVideos
、depthEffectPhotos
、images
、livePhotos
、panoramas
、screenRecordings
、screenshots
、slomoVideos
、timelapseVideos
和videos
。preferredItemEncoding
参数指定用于处理资源的编码。这个结构体包含类型属性automatic
(默认值)、current
和compatible
。photoLibrary
参数提供对图片库的访问。该结构体带有类型方法shared()
。label
参数是一个闭包,提供视图所生成按钮的标签。
因获取资源会耗费时间,选择器并不直接返回图片和视频,而是一个稍后可供我们提取的资源指针。框架为此定义了PhotosPickerItem
结构体。该结构体包含如下访问媒体资源的属性和方法。
- itemIdentifier:该属性返回资源标识符的字符串。
- loadTransferable(type: Type):这一异步方法加载资源并将其赋值给由
type
参数指定数据类型的实例。这个参数的数据类型必须遵循Transferable
协议。
要访问ç
结构体,我们必须导入PhotosUI框架。此外,视图需要一个@State
属性用于存储所选资源。要启用多选,该属性必须存储PhotosPickerItem
结构体的数组,而对于单选,该属性只需要存储一个可选的PhotosPickerItem
值。如下所示。
示例18-1:创建一个图片选择器
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 |
import SwiftUI import PhotosUI struct ContentView: View { @State private var selected: PhotosPickerItem? @State private var picture: UIImage? var body: some View { NavigationStack { VStack { Image(uiImage: picture ?? UIImage(named: "nopicture")!) .resizable() .scaledToFit() Spacer() PhotosPicker(selection: $selected, matching: .images, photoLibrary: .shared()) { Text("Select a photo") .padding() .buttonStyle(.borderedProminent) } .onChange(of: selected, initial: false) { old, item in Task(priority: .background) { if let data = try? await item?.loadTransferable(type: Data.self) { picture = UIImage(data: data) } } } } } } } |
PhotosPicker
初始化方法中的大部分参数都是可选的。本例中,我们只需要告诉选择在哪里存储所选资源的指针,需要对用户显示哪种资源(图片),以及从哪里获取(共享库)。
PhotosPicker
结构体创建了一个打开选取资源视图的按钮。在视图中选中资源后,指针会存储到@State
属性中。这意味着我们可以通过onChange()
修饰符监控属性的变化。在选中新图片后,我们开启一个异步任务对所选资源调用loadTransferable()
方法。该方法加载图片,将其转换成一个Data
结构体返回。如果成功,我们使用这个数据初始化一个UIImage
对象,并将其赋值给picture
属性显示到屏幕上。
图18-1:图片库界面(中间图)
✍️跟我一起做:创建一个多平台项目。使用示例18-1中的代码更新ContentView
视图。下载nopicture.png
并将其添加到Assets中。点击Select a photo按钮。点击选中图片,图片会被赋给Image
视图并显示到屏幕上,如图18-1所示(右图)。
注意:本例中,我们使用了
Data
结构体通过loadTransferable()
方法传输值。我们大可以使用Image
视图,但它只能接收PNG图片。更多有关Transferable
协议的信息,请阅读第12章拖放手势一节。
默认PhotosPicker
视图创建一个在应用顶部打开视图的按钮,但我们也可以使用如下修饰符将视图嵌套到界面中。
- photosPickerStyle(PhotosPickerStyle):这一修饰符指定视图的展现样式。参数是一个具有
compact
、inline
和presentation
(默认值)属性的结构体。
presentation
样式以弹窗展示视图,上例正是如此。如果希望将视图嵌套到界面中,可以使用compact
和inline
样式。这两个样式很相似,但inline
样式提供了更多的选项并且易于访问内容,如下例所示。
示例18-2:在界面中嵌套图片选择器
1 2 3 4 5 6 |
PhotosPicker(selection: $selected, matching: .images, photoLibrary: .shared()) { Text("Select a photo") .buttonStyle(.borderedProminent) .photosPickerStyle(.inline) .frame(height: 300) } |
使用compact
和inline
样式展示的图片选择器大小由可用空间决定。也就是说图片选择器在界面大小发生改变时会对新的空间进行适配。但我们可以使用frame()
修饰符来设置固定大小,本例就是这么做的。结果如下所示。
图18-2:内联图片选择器
除frame()
修饰符之外,我们也可变使用框架所提供的如下修饰符来配置视图。
- photosPickerDisabledCapabilities(PHPickerCapabilities):这一修饰符指定对视图排除哪些能力。参数是用于表示能力的一个(或一组)结构体。该结构体包含的属性有
collectionNavigation
、selectionActions
、search
、sensitivityAnalysisIntervention
和stagingArea
。如果希望包含所有能力可以删除这一修饰符或是指定一个空集合。 - photosPickerAccessoryVisibility(Visibility, edges: Edge):该修饰符指定是否显示控件。第一个参数指定可见性。它是一个值为的
automatic
、visible
和hidden
的枚举。edges
参数是一组Edge
值,用于指定应删除图片选择器哪一边的控件。Edge
枚举的值有top
、bottom
、leading
和trailing
。
这些修饰符让我们可以选择希望包含或隐藏的控件。下例中我们删除了顶部的导航按钮。
示例18-3:隐藏控件
1 2 3 4 5 6 |
PhotosPicker(selection: $selected, matching: .images, photoLibrary: .shared()) { Text("Select a photo")} .buttonStyle(.borderedProminent) .photosPickerStyle(.inline) .frame(height: 300) .photosPickerDisabledCapabilities([.collectionNavigation]) |
图18-3:自定义控件的图片选择器
在上例中,用户一次仅能选择一张图片。通过将@State
属性定义为PhotosPickerItem
结构体数组,可以让用户选择多张图片。虽然我们启用多图选择只要这么做,但必须考虑在用户取消选择时如何从列表中删除图片。我们可以清空数组重新载入每张图片,但有些图片的加载可能要花上一些时间。另一个选择是将图片存在单独的数组中,比较它们值,这样只删除取消选择的,而保留其它的。下例中我们采用的正是这种方法。为此,我们需要一个带结构体的模型来存储图片及其ID。
示例18-4:定义用于多选的模型
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 |
import SwiftUI import Observation import PhotosUI struct ItemsData: Identifiable { var id: String var image: UIImage } @Observable class ApplicationData { var listPictures: [ItemsData] = [] var selected: [PhotosPickerItem] = [] func removeDeselectedItems() { listPictures = listPictures.filter{ value in if selected.contains(where: { $0.itemIdentifier == value.id }) { return true } else { return false } } } func addSelectedItems() { for item in selected { Task(priority: .background) { if let data = try? await item.loadTransferable(type: Data.self) { if let id = item.itemIdentifier, let image = UIImage(data: data) { if !listPictures.contains(where: { $0.id == id}) { let newPicture = ItemsData(id: id, image: image) await MainActor.run { listPictures.append(newPicture) } } } } } } } } |
以下模型包含两个observable属性,一个用于存储ItemsData
结构体数组,将当前选中的图片发送给视图,另一个PhotosPickerItem
结构体数组用于为PhotosPicker
视图存储选中图片的指针。
模型中还有两个方法:removeDeselectedItems()
和addSelectedItems()
。两者都在用户修改选项时执行(即每当selected
属性值发生改变时)。removeDeselectedItems()
方法迭代listPictures
数组中的各项,检查哪些是用户选中的图片,所以用户取消选择的图片就不再位于列表中。而addSelectedItems()
方法将用户选中的图片添加到listPictures
数组中。现在视图可以使用listPictures
数组来显示在屏幕上选择的图片,在每次选项发生更改时调用这两个方法。
示例18-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 30 31 32 33 34 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData let guides = [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()) ] var body: some View { VStack { ScrollView { LazyVGrid(columns: guides) { ForEach(appData.listPictures) { item in Image(uiImage: item.image) .resizable() .scaledToFit() } } } .padding() Spacer() PhotosPicker(selection: Bindable(appData).selected, maxSelectionCount: 4, selectionBehavior: .continuous, matching: .images, photoLibrary: .shared()) { Text("Select Photos") } .photosPickerStyle(.inline) .photosPickerDisabledCapabilities(.selectionActions) } .onChange(of: appData.selected, initial: false) { old, items in appData.removeDeselectedItems() appData.addSelectedItems() } } } |
本例中,我们配置了最多允许选择4张图片,但这么做没什么必要。如果不设置上限,用户可以选择希望添加的所有图片。注意因为图片选择器内嵌在了界面中,并不需要使用选择按钮,选择行为设置为了continous
,这样选取的图片会实时更新(用户无需按添加按钮)。
图18-4:图片多选
✍️跟我一起做:使用示例18-4中的代码创建一个ApplicationData.swift
文件。再用示例18-5中的代码更新ContentView
视图。不要忘记把ApplicationData
注入到应用的环境和预览中(参见第7章示例7-4)。选择多张图片,会看到选中的图片实时更新,如图18-4所示。
相机
移动设备最常见的用途之一是拍摄、存储照片,因此现在设备都有带摄像头。因为应用访问相机和管理图片都是很常规的操作。UIKit内置了控制器为用户提供所有拍摄照片和视频所需的工具。用于创建这一控制器的类是UIImagePickerController
。以下是用于配置该类的一些属性。
- sourceType:该属性设置或返回希望用于获取图片出处的类型。它是
UIImagePickerController
类一个SourceType
枚举。当前,可用值仅有camera
。 - mediaTypes:该属性设置或返回我们希望处理的媒体类型。它接收一个字符串数组,值表示希望使用的所有媒体。最常见的为用于图片的
public.image
和用于视频的public.movie
。(这些值可通过常量kUTTypeImage
和kUTTypeMovie
进行表示。) - cameraCaptureMode:该属性设置或返回相机使用的捕获模式。它是
UIImagePickerController
类中的CameraCaptureMode
枚举。可用值有photo
和video
。 - cameraFlashMode:该属性设置或返回相机的闪光灯模式。它是
UIImagePickerController
类中的CameraFlashMode
枚举。可用的值有on
、off
和auto
。 - allowsEditting:该属性设置或返回是否允许用户编辑图片的布尔值。
- videoQuality:该属性设置或返回录制视频品质的值。这是
UIImagePickerController
类中的QualityType
枚举。可用的值有typeHigh
、typeMedium
、typeLow
、type640x480
、typeIFrame960x540
以及typeIFrame1280x720
。
UIImagePickerController
类还提供了如下类型方法用于检测可用数据源以及其可管理的媒体类型。
- isSourceTypeAvailable(SourceType):该类型方法返回一个表明设备是否支持所指定数据源的布尔值。其中的参数是
UIImagePickerController
类中的SourceType
枚举。当前可用值仅有camera
。 - availableMediaTypes(for: SourceType):该类型方法返回参数指定数据源所支持媒体类型的字符串数组。其中的参数是
UIImagePickerController
类中的SourceType
枚举。当前可用值仅有camera
。 - isCameraDeviceAvailable(CameraDevice):该类型方法返回一个表明参数所指定摄像头是否可用的布尔值。其中的参数是
UIImagePickerController
类中的CameraDevice
枚举。可用值有rear
和front
。
UIImagePickerController
类创建一个用户可拍照或录制视频的视图。在创建完成图片或视频创建后,必须要释放该视图以及处理媒体资料。代码访问媒体资料以及知晓何时释放视图是借助于一个遵循UIImagePickerControllerDelegate
协议的代理。
该协议包含如下方法。
- imagePickerController(UIImagePickerController, didFinishPickingMediaWithInfo: Dictionary):该方法在用户完成拍照或录制视频后由代理调用。第二个参数包含一个有关媒体信息的字典。字典中的值通过
UIImagePickerController
类中的InfoKey
结构体的属性进行标识。可用的属性有cropRect
、editImage
、imageURL
、livePhoto
、mediaMetadata
、mediaType
、mediaURL
和originalImage
。 - imagePickerControllerDidCancel(UIImagePickerController):该方法在用户取消处理后由代理调用。
图片选择器放在弹窗中,但如果我们希望视图点满整个屏幕,可将其嵌套在NavigationStack
视图中,通过NavigationLink
进行打开。这正是我们在下面示例中采取的方法。界面中包含一个打开图片选择器的按钮以及一个显示用户所拍照片的Image
视图。
图18-5:使用相机的界面
注意:访问相机必须要获得用户的授权。这个过程是自动的,但需要在应用配置的Info面板中添加
Privacy - Camera Usage Description
选项,设置向用户展示的信息(第5章,图5-34)。
图片选择控制器是一个UIKit视图控制器,因此通过representable视图控制器在SwiftUI界面中显示。为处理相机所捕获的图片,我们需要添加一个coordinator
并实现代理方法。这个coordinator
必须遵循两个协议:UINavigationControllerDelegate
和UIImagePickerControllerDelegate
,如下例所示。
示例18-6:创建图片选择控制器拍摄照片
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 42 |
import SwiftUI struct ImagePicker: UIViewControllerRepresentable { @Binding var path: NavigationPath @Binding var picture: UIImage? func makeUIViewController(context: Context) -> UIImagePickerController { let mediaPicker = UIImagePickerController() mediaPicker.delegate = context.coordinator if UIImagePickerController.isSourceTypeAvailable(.camera) { mediaPicker.sourceType = .camera mediaPicker.mediaTypes = ["public.image"] mediaPicker.allowsEditing = false mediaPicker.cameraCaptureMode = .photo } else { print("The media is not available") } return mediaPicker } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} func makeCoordinator() -> ImagePickerCoordinator { ImagePickerCoordinator(path: $path, picture: $picture) } } class ImagePickerCoordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { @Binding var path: NavigationPath @Binding var picture: UIImage? init(path: Binding<NavigationPath>, picture: Binding<UIImage?>) { self._path = path self._picture = picture } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { if let newpicture = info[.originalImage] as? UIImage { picture = newpicture } path = NavigationPath() } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { path = NavigationPath() } } |
这一representable视图控制器创建一个UIImagePickerController
类的实例,并将ImagePickerCoordinator
赋为其代理 。接着检查相机是否就绪,成功后配置控制器或是失败在控制台打印消息。将camera
赋给sourceType
属性来告诉控制器通过相机获取图像,对mediaTypes
属性赋一个public.image
数组,指定获取的图片,allowEditting
设置为false
阻止用户编辑图片,cameraCaptureMode
赋了值photo
来允许用户仅捕获图像。
相机界面包含控制相机和捕获图像的按钮。用户拍完照后,会出现一组新的按钮,允许用户选择图像或再拍一张。如果用户决定使用当前图片,控制器会对代理调用imagePickerController(didFinishPickingMediaWithInfo:)
方法。该方法接收一个参数info
,可读取它来获取控制器返回的媒体资源并进行处理(保存到文件、数据库或在屏幕上显示)。本例中,我们读取originalImage
键的值来获取用户拍摄图片的UIImage
对象,将对象赋给@State
属性使其在视图中可用。注意我们还在coordinator中实现了imagePickerControllerDidCancel()
方法来在用户点击Cancel按钮时释放控制器。
视图中必须包含一下打开图片选择控制器的按钮以及一个展示用户拍摄照片的Image
视图。
示例18-7:定义拍照的界面
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 path = NavigationPath() @State private var picture: UIImage? var body: some View { NavigationStack(path: $path) { VStack { HStack { Spacer() NavigationLink("Get Picture", value: "Open Picker") }.navigationDestination(for: String.self, destination: { _ in ImagePicker(path: $path, picture: $picture) }) Image(uiImage: picture ?? UIImage(named: "nopicture")!) .resizable() .scaledToFit() .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .clipped() Spacer() }.padding() }.statusBarHidden() } } |
这个视图创建了一个ImagePicker
结构体的实例,将其声明为NavigationLink
按钮的目的地。点击按钮时打开视图。如果用户拍好照并决定使用它,该图片通过代理方法赋值给picture
属性,屏幕上显示的Image
视图也进行了更新。
✍️跟我一起做:创建一个多平台项目。使用示例18-6中的代码创建ImagePicker.swift
文件。使用示例18-7中的代码更新ContentView.swift
文件。下载nopicture.png
文件放到资源目录中。在应用配置的Info面板中使用希望对用户显示的文字添加Privacy - Camera Usage Description
选项。在设备上运行应用,点击按钮。拍照并按下按钮使用这张照片。会在屏幕中看到这张照片。
存储图片
在前面的示例中,我们在屏幕上展示了图片,但也可以将其存储到文件或数据库中。另外有时使用相机将照片存储到设备的相册薄里会很有用,这样可供其它应用访问。UIKit框架提供了如下两个保存图片和视频的函数。
- UIImageWriteToSavedPhotosAlbum(UIImage, Any?, Selector?, UnsafeMutableRawPointer?):该函数将第一个参数所指定的图像添加到相册。第二个参数是在处理结束后包含所要执行方法的对象的指针,第三个参数是表示该方法的选择器,最后一个参数是传递给该方法数据的指针。
- UISaveVideoAtPathToSavedPhotosAlbum(String, Any?, Selector?, UnsafeMutableRawPointer?):该函数将第一个参数所指定路径的视频添加到相册。第二个参数是在处理结束后包含所要执行方法的对象的指针,第三个参数是表示该方法的选择器,最后一个参数是传递给该方法数据的指针。
注意:将照片或视频存储到设备中,必须要有用户的授权。没错,通过应用配置的Info面板可以实现。对于本例,必须添加
Privacy - Camera Usage Description
选项,设置请求授权时向用户展示的信息。
这些是在Objective-C中定义的老方法,因此用到了一些在SwiftUI应用中不常见的参数。但如果只是要保存图片,我们可以只指定第一个参数,将其它的定义为nil
。例如,可以在前面的应用界面的上方添加一个按钮,打开带两个按钮的警告视图,一个按钮用于取消操作,另一个用于将当前图片保存到相册。点击按钮保存图片时,我们可以调用UIImageWriteToSavedPhotosAlbum
,传入picture
属性的指针,图片就会被保存。
示例18-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 |
struct ContentView: View { @State private var path = NavigationPath() @State private var picture: UIImage? @State private var showAlert: Bool = false var body: some View { NavigationStack(path: $path) { VStack { HStack { Button("Share Picture") { showAlert = true }.disabled(picture == nil ? true : false) Spacer() NavigationLink("Get Picture", value: "Open Picker") }.navigationDestination(for: String.self, destination: { _ in ImagePicker(path: $path, picture: $picture) }) .alert("Save Picture", isPresented: $showAlert, actions: { Button("Cancel", role: .cancel, action: { showAlert = false }) Button("YES", role: .none, action: { if let picture { UIImageWriteToSavedPhotosAlbum(picture, nil, nil, nil) } }) }, message: { Text("Do you want to store the picture in the Photo Library?") }) Image(uiImage: picture ?? UIImage(named: "nopicture")!) .resizable() .scaledToFit() .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .clipped() Spacer() }.padding() }.statusBarHidden() } } |
流程和之前相同。图片选择控制器让用户可以拍照,然后调用代理方法来处理。图片赋值给picture
属性来在屏幕上进行显示,但此时我们多了一个按钮可以将图片保存到照片库中。
✍️跟我一起做:使用示例18-8中的代码更新ContentView.swift
文件。在应用的info面板中添加Privacy – Photo Library Additions Usage Description选项来获取照片库的访问权限。(别忘了还需要和之前一样配置Privacy – Camera Usage Description来获取相机的权限。)在设备上运行应用、拍照。应该会在屏幕上看到照版。点击Share Picture按钮,会弹出一个警告框要求获取权限。点击YES。这时相片会保存到照片库中。
分享链接
另一种与其它应用分享信息的方式是分享弹窗。这个弹窗由系统提供,通过图标可打开希望共享内容的应用,同时带有拷贝和打印信息的选项。SwiftUI提供了如下打开弹窗的视图。
-
ShareLink(String, item: Item, subject: Text?, message: Text?, preview: SharePreview):这一初始化方法创建一个按钮,可打开弹窗选择希望共享数据的应用。第一个参数是按钮的标签。
item
参数是希望共享的值(必须符合Transferable
协议)。subject
参数是内容的标题。message
参数是内容的描述。preview
参数是提供内容展示的结构体。
如果希望共享图片,必须提供预览。为此SwiftUI内置了SharePreview
结构体。
- SharePreview(String, image: Image):这一初始化方法创建一个分享内容的展示。第一个参数是内容的描述,
image
参数是在视觉上表现内容的Image
视图。
分享链接经常用于分享文本,但也可以分享其它内容,只要内容符合Transferable
协议即可。例如,我们可以分享拍摄的照片。
示例18-9:对其它应用分享图像
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 |
struct ContentView: View { @State private var path = NavigationPath() @State private var picture: UIImage? var body: some View { NavigationStack(path: $path) { VStack { HStack { if let picture = picture { let photo = Image(uiImage: picture) ShareLink("Share Picture", item: photo, preview: SharePreview("Photo", image: photo)) } Spacer() NavigationLink("Get Picture", value: "Open Picker") }.navigationDestination(for: String.self, destination: { _ in ImagePicker(path: $path, picture: $picture) }) Image(uiImage: picture ?? UIImage(named: "nopicture")!) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .clipped() Spacer() }.padding() }.statusBarHidden() } } |
ShareLink
视图使用左侧带有SF图标的预定义标签创建按钮。本例中,我们将其放在左上角,但仅在有图片可共享时才显示(如果用户已使用相机拍摄照片)。按下按钮后,系统会打开一个小弹窗,其中包含可分享信息的应用图标,在弹窗中向上滚动时,会看到拷贝和打印数据等其它操作选项。例如,假设我们安装了Facebook,就可以像下图这样通过图片发帖。
图18-6:分享弹窗
✍️跟我一起做:使用示例18-9中的代码更新ContentView
视图。在设备上运行应用。点击Get Picture按钮拍照。然后点击Share Picture按钮。会在屏幕底部弹出分享弹窗。选择分享图片的应用。
自定义相机
UIImagePickerController
控制器通过AV Foundation框架中定义的类构建。该框架提供了处理媒体资源和控制输入设备所需的代码。因此可以使用框架中的类直接构建自己的控制器以及自定义处理流程和界面。
创建访问相机从输入设备获取信息的自定义控制器,要求多系统的协同,我们需要配置相机和麦克风的输入、处理通过这些输入接收到数据、对用户提供预览并生成图片、实时图片、视频或音频形式的输出。图18-7所有相关的元素。
图18-7 捕获媒体资源的系统
构建之初我们需要确定输入设备。AV Foundation框架为此定义了AVCaptureDevice
类。该类的实例可表示任意输入设备,包括相机和麦克风。下面是该类中包含的访问和管理设备的一方法。
- default(for: AVMediaType):这一类型方法返回一个表示参数指定的默认捕获媒体资源设备的
AVCaptureDevice
对象。for
参数是一个AVMediaType
类型的结构体,包含定义媒体类型的属性。用于操作相机和麦克风的属性为video
和audio
。 - requestAccess(for: AVMediaType):向用户请求访问设备权限的异步类型方法。
for
参数是一个AVMediaType
类型的结构体,包含定义媒体类型的属性。用于操作相机和麦克风的属性为video
和audio
。 - authorizationStatus(for: AVMediaType):该类型方法返回决定使用设备权限状态的值。
for
参数是一个AVMediaType
类型的结构体,包含定义媒体类型的属性。用于操作相机和麦克风的属性为video
和audio
。该方法返回AVAuthorizationStatus
类型的枚举,值有notDetermined
、restricted
、denied
和authorized
。
AVCaptureDevice
类的实例表示一个捕获的设备,如相机或麦克风。该类包含用于配置和管理设备的属性和方法。以下为其中最常用的以及我们在例子中要用到的。
- isSubjectAreaChangeMonitoringEnabled:该属性设置或返回一个布尔值,决定设备是否监测修改的区域,如光照和朝向。
- formats:该属性返回一个
Format
对象的数组,表示设备支持的格式。 - activeFormat:该属性返回一个Format对象,表示设备当前使用的格式。
- lockForConfiguration():该方法请求对配置设备的独占访问。
- unlockForConfiguration():该方法释放所配置的设备。
要将捕获的设备定义为输入设备,我们必须创建控制商品和连接对象。框架为此定义了AVCaptureDeviceInput
类。类中包含如下创建设备输入对象的初始化方法。
- AVCaptureDeviceInput(device: AVCaptureDevice):该初始化方法创建由
device
参数指定的设备输入。
除输入外,我们还需要输出来捕获和处理从其它设备接收到设备。框架定义了基类AVCaptureOutput
的子类 来描述输出。有多个可用的子类 ,比如处理视频帧的AVCaptureVideoDataOutput
和获取音频数据的AVCaptureAudioDataOutput
,但最有用的还是AVCapturePhotoOutput
类,用于捕获单个视频帧(拍照)。这个类包含很多配置输出的属性和方法。下面是设置最大图片尺寸的属性和捕获照片的方法。
- maxPhotoDimensions:此属性设置或返回待捕获图片的大小。这一个
CMVideoDimensions
类型的结构体,包含属性width
和height
。 - capturePhoto(with: AVCapturePhotoSettings, delegate: AVCapturePhotoCaptureDelegate):该方法通过
with
参数指定的设置初始化照片抓取。delegate
参数是一个对象指针,对象实现了AVCapturePhotoCaptureDelegate
协议中接收输出生成数据的方法。
AVCapturePhotoOutput
类与符合AVCapturePhotoCaptureDelegate
协议的委托一起返回一个静止图片,其中定义有如下方法。
- photoOutput(AVCapturePhotoOutput, didFinishProcessingPhoto: AVCapturePhoto, error: Error?):该方法在捕获图片后对委托进行调用。
didFinishProcessingPhoto
参数是一个容器,包含有关图片的信息,error
参数用于报告错误。
为控制输入到输出的数据流,框架定义了AVCaptureSession
类。通过该类的实例,我们可以通过调用如下方法控制输入、输出并决定处理何时开始和结束。
- addInput(AVCaptureInput):此方法向捕获会话添加输入。参数表示希望添加的输入设备。
- addOutput(AVCaptureOutput):此方法向捕获会话添加输出。参数表示我们希望从捕获会话生成的输出。
- startRunning():此方法开启捕获会话。
- stopRunning():此方法停止捕获会话。
框架还定义了AVCaptureVideoPreviewLayer
类向用户展示预览。该类创建一个图层展示输入设备捕获的视频。类中包含如下创建和管理预览图层的初始化方法和属性。
- AVCaptureVideoPreviewLayer(session: AVCaptureSession):此初始化方法创建一个连接由
session
参数定义的捕获会话的AVCaptureVideoPreviewLayer
对象. - connection:此属性返回
AVCaptureConnection
类型的对象,定义捕获会话与预览图层间的连接。
输入、输出和预览图层通过AVCaptureConnection
类的对象与捕捉会话进行连接。该类管理连接信息,有端口、数据和朝向。以下是用于设置预览层朝向的属性和方法。
- videoRotationAngle:该属性返回表示连接应用于预览的旋转角度的
CGFloat
值(角度为0.0, 90.0, 180.0, 270.0)。 - isVideoRotationAngleSupported(CGFloat):该方法返回一个布尔值,表示是否支持由参数所指定的旋转角度。
旋转角度由旋转coordinator决定。框架在AVCaptureDevice
类中定义了RotationCoordinator
来进行创建。该类中包含如下初始化方法。
- AVCaptureDevice.RotationCoordinator(device: AVCaptureDevice, previewLayer: CALayer?):该谢谢学姐为设备创建一个旋转coordinator以及由参数指定的预览层。
在RotationCoordinator
类中包含如下两个属性,可读取获取当前旋转角度。
- videoRotationAngleForHorizonLevelPreview:该属性返回需应用于预览层的旋转角度,与设备朝向进行匹配。
- videoRotationAngleForHorizonLevelCapture:该属性返回需应用于相机抓取图像的旋转角度,与设备朝向进行匹配。
本例所创建的界面与前面的相近。需要一个按钮打开视图允许用户用相机拍照,以及一个在屏幕上显示照片的Image
视图。
图18-8:自定义相机界面
启动相机以及获取用户所拍相片的处理与界面相独立,但如果希望用户看到来自相机的图片,我们需要创建一个预览层。图层是视图在屏幕上展示图像的方式。视图定义区域并提供功能,但图像由CALayer
类创建的图层进行展示。UIView
类创建的每个包含可用于展示视频的图层,但图层必须转换为AVCaptureVideoPreviewLayer
。为此我们需要创建一个UIView
的子类 ,重载类型属性layerClass
,将该视频的图层转换为预览图层,然后创建一个UIViewRepresentable
结构体来展示SwiftUI界面中的视图。
示例18-10:定义一个UIView
的子类展示相机的预览视频
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import SwiftUI import AVFoundation class CustomPreviewView: UIView { override class var layerClass: AnyClass { return AVCaptureVideoPreviewLayer.self } } struct CustomPreview: UIViewRepresentable { let view = CustomPreviewView() func makeUIView(context: Context) -> UIView { return view } func updateUIView(_ uiView: UIView, context: Context) {} } |
layerClass
属性是系统读取决定图层数据类型的类型属性。本例中,我们重载了该属性返回AVCaptureVideoPreviewLayer
类的指针,这样系统知道我们使用这一视图层来显示视频。representable视图的其它代码和之前一样。本例我们会在model中管理所有相机的逻辑。以下是配置系统所需的基本元素。
示例18-11:定义管理相机的属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import SwiftUI import Observation import AVFoundation class ViewData { var captureDevice: AVCaptureDevice? var captureSession: AVCaptureSession? var stillImage: AVCapturePhotoOutput? var rotationCoordinator: AVCaptureDevice.RotationCoordinator? var previewObservation: NSKeyValueObservation? } @Observable class ApplicationData: NSObject, AVCapturePhotoCaptureDelegate { var path = NavigationPath() var picture: UIImage? @ObservationIgnored var cameraView: CustomPreview! @ObservationIgnored var viewData: ViewData! override init() { cameraView = ç() viewData = ViewData() } } |
以上代码是这模型的第一部分,我们还要添加一些方法来启用和控制相机,但它提供了需要存储系统各个元素指针的属性。因这些属性在多个方法中用到,我们将其声明到了单独的类ViewData
。在初始化模型时,我们创建了此类的实例和表征视图(CustomPreview
),将它们存在于非观测属性中供其它代码访问。
下一步是定义方法获取访问相机的权限。如果使用UIImagePickerController
控制器这会自动实现,但在自定义控制器中我们需要使用AVCaptureDevice
类所提供的方法自己实现。以下是在模型中添加的对应方法。
示例18-12:请求使用相机的权限
1 2 3 4 5 6 7 8 9 10 |
func getAuthorization() async { let granted = await AVCaptureDevice.requestAccess(for: .video) await MainActor.run { if granted { self.prepareCamera() } else { print("Not Authorized") } } } |
requestAccess()
方法是异步的,它等待用户响应,返回Bool
类型的值报告结果。如果用户授权访问,我们执行prepareCamera()
方法。这里我们开始构建图18-7中介绍的对象网络。该方法必须获取当前视频拾取设备的指针,创建我们抓取静止图像(拍照)的输入和输出。
示例18-13:初始化相机
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 |
func prepareCamera() { viewData.captureSession = AVCaptureSession() viewData.captureDevice = AVCaptureDevice.default(for: AVMediaType.video) if let _ = try? viewData.captureDevice?.lockForConfiguration() { viewData.captureDevice?.isSubjectAreaChangeMonitoringEnabled = true viewData.captureDevice?.unlockForConfiguration() } if let device = viewData.captureDevice { if let input = try? AVCaptureDeviceInput(device: device) { viewData.captureSession?.addInput(input) viewData.stillImage = AVCapturePhotoOutput() if viewData.stillImage != nil { viewData.captureSession?.addOutput(viewData.stillImage!) if let max = viewData.captureDevice?.activeFormat.supportedMaxPhotoDimensions.last { viewData.stillImage?.maxPhotoDimensions = max } } showCamera() } else { print("Not Authorized") } } else { print("Not Authorized") } } |
该方法一开始新建会话并请求对相机的访问。如果default()
方法返回值,如就将true
赋值给isSubjectAreaChangeMonitoringEnabled
属性,开启对设备朝向变化的监控。
有了会话和设备访问权限后,我们就可以定义所需的输入和输出。并没有特别的顺序要求,但因为AVCapturePhotoOutput()
初始化方法会抛错误,我们先使用了它。这个初始化方法创建一个管理捕获设备输入的对象。如果成功,使用addInput()
将其添加到捕获会话,再创建输出。
本例中,我们希望使用会话捕获静止图像。因此,我们使用AVCapturePhotoOutput
类创建输出,将其添加到会话,然后配置返回允许的最大尺寸的图像。注意最大尺寸由maxPhotoDimensions
属性决定,但不能对其赋自定义值。我们需要获取相机可生成的可用尺寸列表并使用最大的那个。实现这一任务,我们读取activeFormat
属性获取相机当前使用格式的Format
对象,并读取其supportedMaxPhotoDimensions
属性。这个属性返回一个CMVideoDimensions
结构体数组,包含设备所支持的尺寸,我们获取最后一个赋值给输出,得到尽可能大尺寸的图像。
在读取输入、输出获取捕获会话后,prepareCamera()
方法还执行showCamera()
方法定义预览层并在屏幕上显示来自相机的视频。
示例18-14:在屏幕上显示来自相机的视频
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
func showCamera() { let previewLayer = cameraView.view.layer as? AVCaptureVideoPreviewLayer previewLayer?.session = viewData.captureSession if let device = viewData.captureDevice, let preview = previewLayer { viewData.rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: preview) preview.connection?.videoRotationAngle = viewData.rotationCoordinator!.videoRotationAngleForHorizonLevelCapture viewData.previewObservation = viewData.rotationCoordinator!.observe(\.videoRotationAngleForHorizonLevelPreview, changeHandler: { old, value in preview.connection?.videoRotationAngle = self.viewData.rotationCoordinator!.videoRotationAngleForHorizonLevelPreview }) } Task(priority: .background) { viewData.captureSession?.startRunning() } } |
前面已经提到,包含UIView
类创建视图的图层由CALayer
类型对象定义。这是显示图像和执行动画的基类。但要显示来自相机的视频,我们需要将其转换为AVCaptureVideoPreviewLayer
对象。在将图层转化为预览层后,我们可以创建旋转协调器来设置视频的朝向。协调器检测设备和预览层,并将当前旋转角度存储到videoRotationAngleForHorizonLevelPreview
属性中,因此我们将该属性的值赋给AVCaptureConnection
对象的videoRotationAngle
属性来设置当前朝向。为保持该值实时更新,我们对属性videoRotationAngleForHorizonLevelPreview
添加了观察者,每当该属性值发生改变时设置视频的朝向(参见第14章的键/值观察)。准备好预览层和旋转协调器后,捕获会话通过startRunning()
方法初始化。(系统要求该方法在后台线程中执行。)
此时,视频在屏幕上播放,系统可以捕捉图像了。捕捉图像的过程由AVCapturePhotoOutput
对象提供的capturePhoto()
方法初始化,输出的图片类型由AVCapturePhotoSettings
对象决定。该类包含多个初始化方法。以下是最常用的那个。
- AVCapturePhotoSettings():此初始化方法使用默认格式创建一个
AVCapturePhotoSettings
对象。
以下是该类中用于配置图像和预览的一些属性。
- maxPhotoDimensions:该属性设置或返回所捕捉图片的尺寸。这是一个
CMVideoDimensions
类型的结构体,包含属性width
和height
。 - previewPhotoFormat:该属性设置或返回一个字典,包含的键和值决定预览图片的特征。键包括kCVPixelBufferPixelFormatTypeKey (未压缩格式), kCVPixelBufferWidthKey (宽) and kCVPixelBufferHeightKey (高)。
- flashMode:该属性设置或返回捕捉图像时使用的闪光灯模式。这是一个
FlashMode
类型的枚举,值有on
、off
和auto
。
配置图像,我们要通过AVCapturePhotoSettings
对象定义设置,调用AVCapturePhotoOutput
对象的capturePhoto()
方法,并定义接收图像的委托方法。以下是需要向模型添加的拍照方法。
示例18-15:拍照
1 2 3 4 5 6 7 |
func takePicture() { let settings = AVCapturePhotoSettings() if let max = viewData.captureDevice?.activeFormat.supportedMaxPhotoDimensions.last { settings.maxPhotoDimensions = max } viewData.stillImage?.capturePhoto(with: settings, delegate: self) } |
在用户点击按钮拍照时,执行takePicture()
方法并调用capturePhoto()
方法请求捕捉图像的输出对象。捕捉图像后,此对象将结果发送给委托对象(见示例18-11),因此我们可以在模型内实现委托方法。参见下面我们对该方法的实现。
示例18-16:处理图像
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene let scale = scene?.screen.scale ?? 1 let orientationAngle = viewData.rotationCoordinator!.videoRotationAngleForHorizonLevelCapture var imageOrientation: UIImage.Orientation! switch orientationAngle { case 90.0: imageOrientation = .right case 270.0: imageOrientation = .left case 0.0: imageOrientation = .up case 180.0: imageOrientation = .down default: imageOrientation = .right } if let imageData = photo.cgImageRepresentation() { picture = UIImage(cgImage: imageData, scale: scale, orientation: imageOrientation) path = NavigationPath() } } |
photoOutput(AVCapturePhotoOutput, didFinishProcessingPhoto:)
方法接收相机所生成的图片。方法接收的值是AVCapturePhoto
类型的对象,这是一个带有图片信息的容器。类中包含两个方便获取表示图像的数据的方法。
- fileDataRepresentation():该方法返回可用于创建
UIImage
对象的图像数据形式。 - cgImageRepresentation():该方法以
UIImage
对象(Core Graphic)返回图像。
在本例中,我们实现了cgImageRepresentation()
方法,因为UIImage
类定义了一个便捷的初始化方法,可通过包含缩放比例和朝向的CGImage
创建图像。通过旋转协调器的videoRotationAngleForHorizonLevelCapture
属性获取朝向。该属性返回带有旋转角度的CGFloat
值,我们可将其转换为Orientation
值来设置图像的朝向(参见第10章中的UIImage
)。要设置缩放比例,我们需要访问屏幕。屏幕由UIScreen
类的对象进行管理,自动按设备创建并赋值给Scene
属性。因此,要访问屏幕和缩放比例,我们需要读取UIWindowScene
对象,它通过UIApplication
对象的connectedScenes
属性控制当前场景。我们在第14章中介绍过这个对象。它由系统创建用于控制应用。该对象由shared
类提供的类型属性返回。要访问应用所打开的场景,我们读取connectedScenes
属性。本例我们为移动设备开发应用,因此只需要访问第一个场景。UIWindowScene
对象包含screen
属性,返回表示屏幕的UIScreen
对象指针,而UIScreen
对象包含有返回当前比例的scale
属性,以及屏幕大小的bounds
属性。通过这些值,我们创建了UIImage
对象,并将其赋值给picture
属性更新视图及显示图像,如下所示。
示例18-17:显示图像
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { NavigationStack(path: Bindable(appData).path) { VStack { HStack { Spacer() NavigationLink("Take Picture", value: "Open Camera") }.navigationDestination(for: String.self, destination: { _ in CustomCameraView() }) Image(uiImage: appData.picture ?? UIImage(named: "nopicture")!) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .clipped() Spacer() }.padding() .navigationBarHidden(true) }.statusBarHidden() } } |
示例18-17中并没有太多新内容,只是不再打开包含标准界面的UIImagePickerController
,我们打开了一个需添加供用户拍照的按钮和自定义控件的视图。以下是对该视图的实现。
示例18-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 33 |
import SwiftUI struct CustomCameraView: View { @Environment(ApplicationData.self) private var appData var body: some View { ZStack { appData.cameraView VStack { Spacer() HStack { Button("Cancel") { appData.path = NavigationPath() } Spacer() Button("Take Picture") { appData.takePicture() } }.padding() .frame(height: 80) .background(Color(red: 0.9, green: 0.9, blue: 0.9, opacity: 0.8)) } }.edgesIgnoringSafeArea(.all) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .navigationBarHidden(true) .task { await appData.getAuthorization() } .onDisappear { appData.viewData.previewObservation = nil } } } |
如图18-8(右图)所示,该视频包含有UIView
,显示 来自相机的视频,以及底部的另一个视图,包含两个按钮,一个用于取消处理和释放视频,另一个用于拍照。该视图出现在屏幕上时,我们调用getAuthorization()
方法来启动处理。如果用户点击Take Picture按钮,我们调用takePicture()
方法捕捉图像。处理好图像后,委托方法释放该视图并在屏幕上显示图像。注意我们应用了onDisappear()
修饰符来删除观察者。这样可以确保在不需要时不再有活跃的观察者。
✍️跟我一起做:创建一个多平台项目。下载nopicture.png图片,将其添加到资源目录。使用示例18-10的代码创建CustomPreview.swift
,用示例18-11的代码创建ApplicationData.swift
。在模型中添加示例18-12、18-13、18-14、18-15及18-16中的方法。用示例18-17中的代码更新ContentView
视图。创建一个SwiftUI文件CustomCameraView.swift
,代码见示例18-18。别忘了在应用设置的Info面板中添加rivacy - Camera Usage Description
选项,并将ApplicationData
对象注入到应用和预览中(参见第7章示例7-4)。在设备中运行应用并拍照测试。
视频
录制和播放视频对用户来说和拍照、显示图片一样重要。和图片一样,Apple框架中内置了播放视频和创建自定义播放器的工具。
视频播放器
SwiftUI定义了VideoPlayer
视图用于播放视频。该视图提供了所有用于播放、停止、前进和后退的控件。视图包含如下初始化方法。
- VideoPlayer(player: AVPlayer?, videoOverlay: Closure):该初始化方法创建视频播放器来播放通过参数提供的视频。
player
参数是负责播放的对象,videoOverlay
参数提供希望展示在视频上方的视图。
VideoPlayer
视图展示用户控制视频的界面,但视频由AVPlayer
类的对象播放。该类包含如下初始化方法。
- AVPlayer(url: URL):该初始化方法创建一个
AVPlayer
对象来播放url
参数所指向URL的媒体。
AVPlayer
类还提供通过程序控制视频的属性和方法。
- volume:该属性设置或返回决定播放器音量的值。值为0.0到1.0之间的
Float
类型值。 - isMuted:该属性是一个布尔值,决定播放器的音频是否为静音。
- rate:该属性设置或返回一个
Float
值,决定所播放媒体的速度。0.0表示暂停视频,1.0设为常速。 - play():该方法开启播放。
- pause():该方法暂停播放。
- addPeriodicTimeObserver(forInterval: CMTime, queue: DispatchQueue?, using: Closure):该方法添加一个观察者,每隔一定的时间执行闭包的内容。
forInterval
参数决定执行的间隔,queue
参数为闭包所处的队列(推荐用主线程),using
参数是希望执行闭包。闭包接收CMTime
类型的值,为闭包调用的时间。
VideoPlayer
视图需要有AVPlayer
对象来播放视频,该对象对过URL加载视频。如果希望播放线上的视频,只需要URL,但如果视频由应用提供,则需要通过包来获取(参见第10章中的Bundle)。在以下的模型中,我们在项目中添加了一个视频videotrees.mp4,通过Bundle
对象获取指向该文件的URL,并用该值创建一个AVPlayer
对象。
示例18-19:准备待播放的视频
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import SwiftUI import Observation import AVKit @Observable class ApplicationData { var player: AVPlayer! init() { let bundle = Bundle.main if let videoURL = bundle.url(forResource: "videotrees", withExtension: "mp4") { player = AVPlayer(url: videoURL) } } } |
VideoPlayer
视图和AVPlayer
类来自AVKit框架。导入该框架后,我们获取到videotrees.mp4视频的URL,创建AVPlayer
对象并将其存储到可观测属性中,以供视图使用。在视图中,我们需要检测该属性并在视频准备就绪后显示VideoPlayer
视图。
示例18-20:播放视频
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import SwiftUI import AVKit struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { if appData.player != nil { VideoPlayer(player: appData.player) .ignoresSafeArea() } else { Text("Video not available") } } } |
图18-9:标准视频播放器
✍️跟我一起做:创建一个多平台项目。下载videotrees.mp4并添加至项目中(别忘了在弹窗中选择target)。使用示例18-19中的代码创建一个Swift模型文件ApplicationData.swift
。使用示例18-20中的代码更新ContentView
视图。还要将ApplicationData
对象注入应用和预览的环境中(参见第7章示例7-4)。运行应用。点击播放按钮播放视频。
上例中,视频需要由用户点击播放按钮才开始播放。但我们可以实现AVPlayer
属性和方法来通过程序控制视频。例如,以下示例在视图加载完后就开始播放视频。
示例18-21:自动播放视频
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { if appData.player != nil { VideoPlayer(player: appData.player) .onAppear { appData.player.play() } .ignoresSafeArea() } else { Text("Video not available") } } } |
VideoPlayer
视图初始化方法还可以包含一个参数,接收闭包来在视频上添加浮层。下例中,实现的初始化方法在视频的顶部添加标题。
示例18-22:在视频上展示视图
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 { @Environment(ApplicationData.self) private var appData var body: some View { if appData.player != nil { VideoPlayer(player: appData.player, videoOverlay: { VStack { Text("Title: Trees at the park") .font(.title) .padding([.top, .bottom], 8) .padding([.leading, .trailing], 16) .foregroundColor(.black) .background(.ultraThinMaterial) .cornerRadius(10) .padding(.top, 8) Spacer() } }) .ignoresSafeArea() } else { Text("Video not available") } } } |
闭包返回中的视频位于视频之上和控件之下,因此无法接收用户的输入,但可以使用它来提供额外的信息,就像本例中这样。结果如下所示。
图18-10:浮层视图
自定义视频播放器
除了让VideoPlayer
视图正常工作的代码外,AVFoundation框架还提供了创建播放媒体独立组件的功能。有一个负责资源(视频或音频)的类,一个负责将媒体资源发送给播放器的类,一个播放媒体的类以及一个负责在屏幕上显示媒体的类。图18-11描述了这一结构。
图18-11:播放媒体的系统
待播放的媒体以资源形式提供。资源以一个或多个媒体轨道组成,包括视频、音频和字幕等。AVFoundation框架定义了一个AVAsset
类来加载资源。该类包含如下初始化方法。
- AVURLAsset(url: URL):这个初始化方法使用
url
参数指定位置的资源创建AVURLAsset
对象。参数是一个URL结构体,包含本地或远程资源的位置。
资源包含有静态信息,在播放后无法管理自身的状态。框架定义了AVPlayerItem
类来控制资源。通过此类我们可以引用资源并管理其时间轴。该类中包含多个初始化方法。以下是最常用的一个。
- AVPlayerItem(asset: AVAsset):本初始化方法创建一个表示
asset
参数所指定的资源的AVPlayerItem
对象。
AVPlayerItem
类还包含一些控制资源状态的属性和方法。以下是最常用的那些。
- status:该属性返回表示播放项状态的值。这是一个位于
AVPlayerItem
类中Status
枚举。值有unknown
、readyToPlay
和failed
。 - duration:该属性返回表示播放项时长的值。它是一个
CMTime
类型的结构体。 - currentTime():此方法返回播放项当前时间的
CMTime
值。 - seek(to: CMTime):这一异步方法将播放游标移动到
to
参数所指定的时间,返回一个寻址操作是否完成的布尔值。
AVPlayerItem
对象管理播放所需的信息,但不会播放媒体,这是由AVPlayer
类的实例来处理的。它是稍早我们在VideoPlayer
视图中用于加载视频相同的类。该类包含如下通过AVPlayerItem
对象创建播放器的初始化方法。
- AVPlayer(playerItem: AVPlayerItem?):这一初始化方法创建一个
AVPlayer
对象播放playerItem
参数所表示的媒体资源。
系统所需的最后一个对象负责展示媒体资源。它是CALayer
的子类AVPlayerLayer
,提供了在屏幕上绘制视频帧所需要的代码。该类包含如下创建和配置播放层的初始化方法和属性。
- AVPlayerLayer(player: AVPlayer):本初始化方法创建一个
AVPlayerLayer
对象,关联player
参数所指定的播放器。 - videoGravity:此属性定义了如何将视频调整为预览层的大小。它是一个
AVLayerVideoGravity
结构体,包含类型属性resize
、resizeAspect
和resizeAspectFill
。
这些类一起定义了用于播放媒体的系统,但还要有方法来控制时间。因浮点数的精度不适合于播放媒体资源,框架还通过旧框架的Core Media实现了CMTime
结构体。这一结构体包含了很多以分数表示时间的值。最重要的两个是value
和timescale
,分别表示分子和分母。例如,想要创建表示0.5秒的CMTime
结构体时,可以指定分子为1、分母为2(1除以2得0.5)。该类包含一些创建这些值的初始化方法和类型属性。以下是最常使用的。
- CMTime(value: CMTimeValue, timescale: CMTimeScale):此初始化方法通过
value
和timescale
所指定的值创建一个CMTime
结构体。参数分别为整型Int64
主Int32
。 - CMTime(seconds: Double, preferredTimescale: CMTimeScale):此初始化方法通过表示秒数的浮点值创建一个
CMTime
结构体。seconds
参数为赋给结构体的秒数,preferredTimescale
参数为希望使用的单位。值为1时保持为第一个参数的秒数。 - zero:该类型属性返回值为0的
CMTime
结构体。
CMTime
结构体还包含一些设置和获取值的属性。最常用的如下。
- seconds:该属性以秒数返回
CMTime
结构体的时间。类型为Double
。 - value:该属性返回
CMTime
结构体的值。 - timescale:该属性返回
CMTime
结构体的时间单位。
要自定义视频播放器,我们必须加载资源(AVURLAsset
),创建一个管理资源的子项(AVPlayerItem
),将子项添加至播放器(AVPlayer
),将播放器关联至屏幕上媒体的显示层(AVPlayerLayer
)。
就像前面用于显示来自相机的视频的预览层,我们需要将UIView
对象提供的显示层转化为预览层(本例中CALayer
需要转换为AVPlayerLayer
对象)。以下是本例需要实现的表现视图。
示例18-23:构建自定义播放器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import SwiftUI import AVFoundation class CustomPlayerView: UIView { override class var layerClass: AnyClass { return AVPlayerLayer.self } } struct PlayerView: UIViewRepresentable { var view = CustomPlayerView() func makeUIView(context: Context) -> UIView { return view } func updateUIView(_ uiView: UIViewType, context: Context) {} } |
有了表现视图,下一步就是构建视频播放器,然后在就绪后调用player()
方法播放视频。
示例18-24:构建自定义视频播放器
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 |
import SwiftUI import Observation import AVFoundation class ViewData: NSObject { var playerItem: AVPlayerItem? var player: AVPlayer? var playerLayer: AVPlayerLayer? var playerObservation: NSKeyValueObservation? func setObserver() { playerObservation = playerItem?.observe(\.status, options: .new, changeHandler: { item, value in if item.status == .readyToPlay { self.player?.play() } }) } } @Observable class ApplicationData { @ObservationIgnored var customVideoView: PlayerView! @ObservationIgnored var viewData: ViewData init() { customVideoView = PlayerView() viewData = ViewData() let bundle = Bundle.main let videoURL = bundle.url(forResource: "videotrees", withExtension: "mp4") let asset = AVURLAsset(url: videoURL!) viewData.playerItem = AVPlayerItem(asset: asset) viewData.player = AVPlayer(playerItem: viewData.playerItem) viewData.playerLayer = customVideoView.view.layer as? AVPlayerLayer viewData.playerLayer?.player = viewData.player viewData.setObserver() } } |
视频并非立马可见,需要进行加载和做好播放准备,因而不能马上播放,需等待其就绪。媒体的状态由AVPlayerItem
的status
属性进行上报。因此需要监测该属性的值,公在其值等于readyToPlay
时开始播放。这就要使用到观察过生日和。因此,在定义三个属性后,我们需要存储播放项、播放器和播放层,我们定义了一个存储观察者的属性,调用AVPlayerItem
对象的observer()
方法来跟踪status
属性。在当前状态为readyToPlay
时播放视频。
为配置视频播放器,我们从bundle中加载视频、创建播放器结构体、将UIView
层转换为AVPlayerLayer
,将其赋值给player
。因所有内容都在模型中进行了准备,界面只需要在展示视图中进行显示。视频填满屏幕、适配屏幕的朝向并在加载视图后进行播放。
示例18-25:显示视频
1 2 3 4 5 6 7 8 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { appData.customVideoView .ignoresSafeArea() } } |
✍️跟我一起做:创建一个多平台项目。下载videotrees.mp4,添加至项目中(记住要勾选Add to Target选项)。使用示例18-23中的代码创建CustomPlayerView.swift
文件,使用示例18-24中的创建模型文件ApplicationData.swift
。再用示例18-25中的代码更新ContentView
视图。运行应用,视频应该会在应用启动后立即播放。
上例中进行了视频的播放,但没为用户提供任何控件工具。AVPlayer
类包含有播放、暂停和检查媒体状态的方法,但需要我们来创建界面。下例中我们会创建一个带有按钮和进度条的界面,这样用户可以播放、暂停并查看视频的进度。
图18-12:自定义视频播放器的控件
如何控制流程以及对界面进行响应取决于应用的要求。例如,我们决定定义两个状态,一个表示视频是否在播放,另一个表示进度条的位置。以下是对模型所做的修改,让用户可以播放、暂停视频以及拖动进度条。
示例18-26:准备视频播放器
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 42 43 44 45 46 47 48 49 |
import SwiftUI import Observation import AVFoundation class ViewData: NSObject { var playerItem: AVPlayerItem? var player: AVPlayer? var playerLayer: AVPlayerLayer? } @Observable class ApplicationData { var playing: Bool = false var progress: CGFloat = 0 @ObservationIgnored var customVideoView: PlayerView! @ObservationIgnored var viewData: ViewData init() { customVideoView = PlayerView() viewData = ViewData() let bundle = Bundle.main let videoURL = bundle.url(forResource: "videotrees", withExtension: "mp4") let asset = AVURLAsset(url: videoURL!) viewData.playerItem = AVPlayerItem(asset: asset) viewData.player = AVPlayer(playerItem: viewData.playerItem) viewData.playerLayer = customVideoView.view.layer as? AVPlayerLayer viewData.playerLayer?.player = viewData.player let interval = CMTime(value: 1, timescale: 2) viewData.player?.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main, using: { time in if let duration = self.viewData.playerItem?.duration { let position = time.seconds / duration.seconds self.progress = CGFloat(position) } }) } func playVideo() { if viewData.playerItem?.status == .readyToPlay { if playing { viewData.player?.pause() playing = false } else { viewData.player?.play() playing = true } } } } |
本例中,我们添加了一个playVideo()
方法,在用户点击Play按钮时执行。该方法检测是否可以播放媒体,然后根据playing
属性的值执行操作。如果视频在播放就暂停,如果在暂停就播放。playing
属性的值会进行更新来反映新的状态。
要计算进度条的长度,必须要实现一个观察者。但不是像之前所实现的KVO观察者。常规的观察者不够快,所以AVFoundation框架自带了一个addPeriodicTimeObserver()
方法创建提供更精准响应的观察者。该方法需要一个CMTime
值来指定执行任务的频率、一个主队列指针以及一个带每次触发观察者执行代码的闭包。本例中,我们创建一个表示0.5秒时长的CMTime
值,然后使用它调用addPeriodicTimeObserver()
方法来注册观察者。之后,传递给观察者的闭包在播放期间每0.5秒执行一次。在闭包中,我们获取到了当前时间以及视频时长秒数,通过将秒数转换成0.0到1.0之间的值来计算进度,稍后可转化成点数在屏幕上显示进度条。
注意:
addPeriodicTimeObserver()
方法无法用于Swift的并发。而是需要将线程定义在DispatchQueue
对象中。这是由Dispatch框架定义的老类,用于创建异步任务。该类包含一个类型属性main
,定义一个主队列任务(Main Actor),这正是确保赋给这一方法的闭包在主线程中运行的方式。
播放器已就绪,是时修改定义界面了。在这个场景中,我们需要在ZStack
中展示表现视图,这样可以在上层显示工具栏(参见图18-12)。
示例18-27:播放及暂停视频
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 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { ZStack { appData.customVideoView .ignoresSafeArea() VStack { Spacer() HStack { Button(appData.playing ? "Pause" : "Play") { appData.playVideo() }.frame(width: 70) .foregroundColor(.black) GeometryReader { geometry in HStack { Rectangle() .fill(Color(red: 0, green: 0.4, blue: 0.8, opacity: 0.8)) .frame(width: geometry.size.width * appData.progress, height: 20) Spacer() } } .padding(.top, 15) } .padding([.leading, .trailing]) .frame(height: 50) .background(Color(red: 0.9, green: 0.9, blue: 0.9, opacity: 0.8)) } } } } |
工具栏包含一个按钮和一个表示进度条的Rectangle
视图。按钮的标签取决于playing
属性的值。在视频播放时显示Pause,暂停时显示Play。为计算表示进度条的Rectangle
视图的长度,我们嵌入了GeometryReader
视图,然后用其宽度乘上progress
属性。因该属性包含一个0.0到1.0之间的值,这一运算返回一个设置进度条宽度的值,在屏幕上显示出进度。
✍️跟我一起做:使用示例18-26中的代码更新模型,用示例18-27中的代码更新ContentView
视图。运行程序,就会看到图18-12中所示的视频播放器。
通过addPeriodicTimeObserver()
方法添加的观察者不是获取播放器的信息的唯一方式。AVPlayerItem
类还定义了一些通知用于报告媒体播放期间发生的事件。例如,我们可以监听AVPlayerItemDidPlayToEndTime
通知来了解视频何时停止播放。为此,我们需要在模型中定义一个方法监听并响应该通知,并添加一个任务在展示视图创建时调用该方法。以下是我们需要在ApplicationData
类的初始化方法添加的任务。
示例18-28:执行异步方法监测视频结束
1 2 3 |
Task(priority: .background) { await rewindVideo() } |
在rewindVideo()
方法中,我们必须监听AVPlayerItemDidPlayToEndTime
通知,并准备再次播放视频。为此,AVPlayerItem
类提供了seek()
方法。该方法将播放进度移到参数所指定的时间,并在处理完成后执行一个闭包。本例中我们将使用值为0的CMTime
将播放器移到视频开头,然后重置playing
和progress
属性允许用户重新播放视频。
示例18-29:重新播放视频
1 2 3 4 5 6 7 8 9 10 11 12 |
func rewindVideo() async { let center = NotificationCenter.default let name = NSNotification.Name.AVPlayerItemDidPlayToEndTime for await _ in center.notifications(named: name, object: nil) { if let finished = await viewData.playerItem?.seek(to: CMTime.zero), finished { await MainActor.run { playing = false progress = 0 } } } } |
✍️跟我一起做:将示例18-28中的任务添加到ApplicationData
初始化方法的最后。将示例18-29中的方法添加到ApplicationData
类的最后。运行程序。点击播放,等待视频结束。播放器应该会重置,可以再次播放视频。
如果希望按顺序播放多个视频,我们可以使用AVPlayerItemDidPlayToEndTime
通知将新资源赋值给AVPlayer
对象,但框架提供了AVPlayer
类的子类AVQueuePlayer
,专门上用于管理视频列表。该类通过AVPlayerItem
对象数组创建一个播放列表。以下为初始化方法和其中的一些方法。
- AVQueuePlayer(items: [AVPlayerItem]):该方法通过
items
参数指定的播放项创建一个播放列表。 - advanceToNextItem():该方法将播放内容递进至列表中的下一项。
- insert(AVPlayerItem, after: AVPlayerItem?):该方法在列表中插入一个新项。
- remove(AVPlayerItem):该方法从列表中删除一项。
AVQueuePlayer
对象替换用于展示媒体资源的AVPlayer
对象。播放视频序列我闪只需要每个视频创建一个AVPlayerItem
对象,以及将我们一直使用的AVPlayer
对象替换为AVQueuePlayer
对象,如下例所示。
示例18-30:播放视频列表
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 42 43 44 45 46 |
import SwiftUI import Observation import AVFoundation class ViewData: NSObject { var playerItem1: AVPlayerItem! var playerItem2: AVPlayerItem! var player: AVQueuePlayer! var playerLayer: AVPlayerLayer? var playerObservation: NSKeyValueObservation? func setObserver() { playerObservation = playerItem1.observe(\.status, options: .new, changeHandler: { item, value in if item.status == .readyToPlay { self.player.play() } }) } } @Observable class ApplicationData { var playing: Bool = false var progress: CGFloat = 0 @ObservationIgnored var customVideoView: PlayerView! @ObservationIgnored var viewData: ViewData init() { customVideoView = PlayerView() viewData = ViewData() let bundle = Bundle.main let videoURL1 = bundle.url(forResource: "videotrees", withExtension: "mp4") let videoURL2 = bundle.url(forResource: "videobeaches", withExtension: "mp4") let asset1 = AVURLAsset(url: videoURL1!) let asset2 = AVURLAsset(url: videoURL2!) viewData.playerItem1 = AVPlayerItem(asset: asset1) viewData.playerItem2 = AVPlayerItem(asset: asset2) viewData.player = AVQueuePlayer(items: [viewData.playerItem1, viewData.playerItem2]) viewData.playerLayer = customVideoView.view.layer as? AVPlayerLayer viewData.playerLayer?.player = viewData.player viewData.setObserver() } } |
本例中,我们使用的是示例18-25中的ContentView
视图。代码中加载了两个视频,videotrees.mp4和videobeaches.mp4,然后创建两个AVURLAsset
对象以及两个用于展示它们的AVPlayerItem
对象。接着定义AVQueuePlayer
对象来按顺序播放这两个视频。注意因为在本例中我们使用的界面没带播放按钮,我们对第一个视频添加了一个观察者,在准备就绪后调用play()
方法。
✍️跟我一起做:使用示例18-30中的代码更新ApplicationData.swift文件。结合示例18-25中的ContentView
视图使用。下载videotrees.mp4和videobeaches.mp4视频添加至项目中(记得勾选Add to Target)。运行应用,视频应该会逐一播放。
颜色拾取器
SwiftUI自带了ColorPicker
视图来允许用户选取颜色。该视图创建一个按钮,打开预定义界面,自带有选取和配置颜色的工具。以下是该视图的初始化方法。
- ColorPicker(String, selection: Binding, supportsOpacity: Bool):本初始化方法创建一个颜色拾取器。第一个参数为显示在按钮旁的标签,
selection
参数是一个绑定属性,存储用户所选颜色的Color
视图,supportsOpacity
参数指定是否允许用户设置透明度。默认值为true
。
颜色拾取器的实现非常简单。我们用Color
视图定义一个@State
属性,然后使用它初始化ColorPicker
视图,这样每次用户选择颜色时,就会存储到该属性中,我们可以使用它来修改其它视图。在下例中,我们使用该属性的值来修改界面的背景色。
示例18-31:显示颜色拾取器
1 2 3 4 5 6 7 8 9 10 11 |
struct ContentView: View { @State private var selectedColor: Color = .white var body: some View { VStack { ColorPicker("Select a Color", selection: $selectedColor) .padding() Spacer() }.background(selectedColor) } } |
ColorPicker
视图展示一个按键,打开用户选择颜色的界面。用户选取颜色后,颜色会自动赋给@State
属性。这意味着用户可以按意愿多次修改选择,但只有最后一次所选的颜色保存到该属性中。
图18-13:颜色拾取器
✍️跟我一起做:创建一个多平台项目。使用示例18-31中的代码更新ContentView
视图。运行应用、点击颜色拾取器按钮。选择颜色,会看到界面颜色的变化,如图18-13所示。
代码请见:GitHub仓库