视频
录制和播放视频对用户来说和拍照、显示图片一样重要。和图片一样,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:准备待播放的视频
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:播放视频
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:自动播放视频
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:在视频上展示视图
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:构建自定义播放器
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:构建自定义视频播放器
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:显示视频
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:准备视频播放器
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:播放及暂停视频
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:执行异步方法监测视频结束
Task(priority: .background) { await rewindVideo() }
在rewindVideo()
方法中,我们必须监听AVPlayerItemDidPlayToEndTime
通知,并准备再次播放视频。为此,AVPlayerItem
类提供了seek()
方法。该方法将播放进度移到参数所指定的时间,并在处理完成后执行一个闭包。本例中我们将使用值为0的CMTime
将播放器移到视频开头,然后重置playing
和progress
属性允许用户重新播放视频。
示例18-29:重新播放视频
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:播放视频列表
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:显示颜色拾取器
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所示。