在前面的示例中,我们在屏幕上展示了图片,但也可以将其存储到文件或数据库中。另外有时使用相机将照片存储到设备的相册薄里会很有用,这样可供其它应用访问。UIKit框架提供了如下两个保存图片和视频的函数。
注意:将照片或视频存储到设备中,必须要有用户的授权。没错,通过应用配置的Info面板可以实现。对于本例,必须添加
Privacy - Camera Usage Description
选项,设置请求授权时向用户展示的信息。
这些是在Objective-C中定义的老方法,因此用到了一些在SwiftUI应用中不常见的参数。但如果只是要保存图片,我们可以只指定第一个参数,将其它的定义为nil
。例如,可以在前面的应用界面的上方添加一个按钮,打开带两个按钮的警告视图,一个按钮用于取消操作,另一个用于将当前图片保存到相册。点击按钮保存图片时,我们可以调用UIImageWriteToSavedPhotosAlbum
,传入picture
属性的指针,图片就会被保存。
示例18-8:在保存图片时显示警告视图
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提供了如下打开弹窗的视图。
item
参数是希望共享的值(必须符合Transferable
协议)。subject
参数是内容的标题。message
参数是内容的描述。preview
参数是提供内容展示的结构体。如果希望共享图片,必须提供预览。为此SwiftUI内置了SharePreview
结构体。
image
参数是在视觉上表现内容的Image
视图。分享链接经常用于分享文本,但也可以分享其它内容,只要内容符合Transferable
协议即可。例如,我们可以分享拍摄的照片。
示例18-9:对其它应用分享图像
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
类。该类的实例可表示任意输入设备,包括相机和麦克风。下面是该类中包含的访问和管理设备的一方法。
AVCaptureDevice
对象。for
参数是一个AVMediaType
类型的结构体,包含定义媒体类型的属性。用于操作相机和麦克风的属性为video
和audio
。for
参数是一个AVMediaType
类型的结构体,包含定义媒体类型的属性。用于操作相机和麦克风的属性为video
和audio
。for
参数是一个AVMediaType
类型的结构体,包含定义媒体类型的属性。用于操作相机和麦克风的属性为video
和audio
。该方法返回AVAuthorizationStatus
类型的枚举,值有notDetermined
、restricted
、denied
和authorized
。AVCaptureDevice
类的实例表示一个捕获的设备,如相机或麦克风。该类包含用于配置和管理设备的属性和方法。以下为其中最常用的以及我们在例子中要用到的。
Format
对象的数组,表示设备支持的格式。要将捕获的设备定义为输入设备,我们必须创建控制商品和连接对象。框架为此定义了AVCaptureDeviceInput
类。类中包含如下创建设备输入对象的初始化方法。
device
参数指定的设备输入。除输入外,我们还需要输出来捕获和处理从其它设备接收到设备。框架定义了基类AVCaptureOutput
的子类 来描述输出。有多个可用的子类 ,比如处理视频帧的AVCaptureVideoDataOutput
和获取音频数据的AVCaptureAudioDataOutput
,但最有用的还是AVCapturePhotoOutput
类,用于捕获单个视频帧(拍照)。这个类包含很多配置输出的属性和方法。下面是设置最大图片尺寸的属性和捕获照片的方法。
CMVideoDimensions
类型的结构体,包含属性width
和height
。with
参数指定的设置初始化照片抓取。delegate
参数是一个对象指针,对象实现了AVCapturePhotoCaptureDelegate
协议中接收输出生成数据的方法。AVCapturePhotoOutput
类与符合AVCapturePhotoCaptureDelegate
协议的委托一起返回一个静止图片,其中定义有如下方法。
didFinishProcessingPhoto
参数是一个容器,包含有关图片的信息,error
参数用于报告错误。为控制输入到输出的数据流,框架定义了AVCaptureSession
类。通过该类的实例,我们可以通过调用如下方法控制输入、输出并决定处理何时开始和结束。
框架还定义了AVCaptureVideoPreviewLayer
类向用户展示预览。该类创建一个图层展示输入设备捕获的视频。类中包含如下创建和管理预览图层的初始化方法和属性。
session
参数定义的捕获会话的AVCaptureVideoPreviewLayer
对象.AVCaptureConnection
类型的对象,定义捕获会话与预览图层间的连接。输入、输出和预览图层通过AVCaptureConnection
类的对象与捕捉会话进行连接。该类管理连接信息,有端口、数据和朝向。以下是用于设置预览层朝向的属性和方法。
CGFloat
值(角度为0.0, 90.0, 180.0, 270.0)。旋转角度由旋转coordinator决定。框架在AVCaptureDevice
类中定义了RotationCoordinator
来进行创建。该类中包含如下初始化方法。
在RotationCoordinator
类中包含如下两个属性,可读取获取当前旋转角度。
本例所创建的界面与前面的相近。需要一个按钮打开视图允许用户用相机拍照,以及一个在屏幕上显示照片的Image
视图。
图18-8:自定义相机界面
启动相机以及获取用户所拍相片的处理与界面相独立,但如果希望用户看到来自相机的图片,我们需要创建一个预览层。图层是视图在屏幕上展示图像的方式。视图定义区域并提供功能,但图像由CALayer
类创建的图层进行展示。UIView
类创建的每个包含可用于展示视频的图层,但图层必须转换为AVCaptureVideoPreviewLayer
。为此我们需要创建一个UIView
的子类 ,重载类型属性layerClass
,将该视频的图层转换为预览图层,然后创建一个UIViewRepresentable
结构体来展示SwiftUI界面中的视图。
示例18-10:定义一个UIView
的子类展示相机的预览视频
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:定义管理相机的属性
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:请求使用相机的权限
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:初始化相机
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:在屏幕上显示来自相机的视频
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
对象。以下是该类中用于配置图像和预览的一些属性。
CMVideoDimensions
类型的结构体,包含属性width
和height
。FlashMode
类型的枚举,值有on
、off
和auto
。配置图像,我们要通过AVCapturePhotoSettings
对象定义设置,调用AVCapturePhotoOutput
对象的capturePhoto()
方法,并定义接收图像的委托方法。以下是需要向模型添加的拍照方法。
示例18-15:拍照
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:处理图像
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
类型的对象,这是一个带有图片信息的容器。类中包含两个方便获取表示图像的数据的方法。
UIImage
对象的图像数据形式。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:显示图像
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:拍照
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)。在设备中运行应用并拍照测试。