录制和播放视频对用户来说和拍照、显示图片一样重要。和图片一样,Apple框架中内置了播放视频和创建自定义播放器的工具。
SwiftUI定义了VideoPlayer
视图用于播放视频。该视图提供了所有用于播放、停止、前进和后退的控件。视图包含如下初始化方法。
player
参数是负责播放的对象,videoOverlay
参数提供希望展示在视频上方的视图。VideoPlayer
视图展示用户控制视频的界面,但视频由AVPlayer
类的对象播放。该类包含如下初始化方法。
AVPlayer
对象来播放url
参数所指向URL的媒体。AVPlayer
类还提供通过程序控制视频的属性和方法。
Float
类型值。Float
值,决定所播放媒体的速度。0.0表示暂停视频,1.0设为常速。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
类来加载资源。该类包含如下初始化方法。
url
参数指定位置的资源创建AVURLAsset
对象。参数是一个URL结构体,包含本地或远程资源的位置。资源包含有静态信息,在播放后无法管理自身的状态。框架定义了AVPlayerItem
类来控制资源。通过此类我们可以引用资源并管理其时间轴。该类中包含多个初始化方法。以下是最常用的一个。
asset
参数所指定的资源的AVPlayerItem
对象。AVPlayerItem
类还包含一些控制资源状态的属性和方法。以下是最常用的那些。
AVPlayerItem
类中Status
枚举。值有unknown
、readyToPlay
和failed
。CMTime
类型的结构体。CMTime
值。to
参数所指定的时间,返回一个寻址操作是否完成的布尔值。AVPlayerItem
对象管理播放所需的信息,但不会播放媒体,这是由AVPlayer
类的实例来处理的。它是稍早我们在VideoPlayer
视图中用于加载视频相同的类。该类包含如下通过AVPlayerItem
对象创建播放器的初始化方法。
AVPlayer
对象播放playerItem
参数所表示的媒体资源。系统所需的最后一个对象负责展示媒体资源。它是CALayer
的子类AVPlayerLayer
,提供了在屏幕上绘制视频帧所需要的代码。该类包含如下创建和配置播放层的初始化方法和属性。
AVPlayerLayer
对象,关联player
参数所指定的播放器。AVLayerVideoGravity
结构体,包含类型属性resize
、resizeAspect
和resizeAspectFill
。这些类一起定义了用于播放媒体的系统,但还要有方法来控制时间。因浮点数的精度不适合于播放媒体资源,框架还通过旧框架的Core Media实现了CMTime
结构体。这一结构体包含了很多以分数表示时间的值。最重要的两个是value
和timescale
,分别表示分子和分母。例如,想要创建表示0.5秒的CMTime
结构体时,可以指定分子为1、分母为2(1除以2得0.5)。该类包含一些创建这些值的初始化方法和类型属性。以下是最常使用的。
value
和timescale
所指定的值创建一个CMTime
结构体。参数分别为整型Int64
主Int32
。CMTime
结构体。seconds
参数为赋给结构体的秒数,preferredTimescale
参数为希望使用的单位。值为1时保持为第一个参数的秒数。CMTime
结构体。CMTime
结构体还包含一些设置和获取值的属性。最常用的如下。
CMTime
结构体的时间。类型为Double
。CMTime
结构体的值。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
对象数组创建一个播放列表。以下为初始化方法和其中的一些方法。
items
参数指定的播放项创建一个播放列表。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
视图来允许用户选取颜色。该视图创建一个按钮,打开预定义界面,自带有选取和配置颜色的工具。以下是该视图的初始化方法。
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所示。