画中画(PictureInPicture)在iOS9就已经推出了,不过之前都只能在iPad使用,iPhone要使用画中画就得更新到iOS14才能使用。
Demo:JPPictureInPictureDemo
前提准备
- 下载并安装Xcode12(系统并不需要更新到最新);下载地址
- 如果要用真机测试,就得下载iOS14的描述文件,然后更新并安装iOS14的beat版本即可。下载地址
基本使用
如果对播放器要求不大的可以直接使用AVPlayerViewController
,自身就提供画中画功能可直接使用,而自定义的播放器要开启画中画那就使用AVPictureInPictureController
,也是很简单易用,并且动画效果内部已经实现,只需要以下3步即可实现:
- 使用Xcode12打开工程,首先得开启后台模式:
初始化播放器的同时创建
AVPictureInPictureController
:
// 1. 判断当前项目是否支持画中画
if AVPictureInPictureController.isPictureInPictureSupported() == true {
// 2. 开启权限
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true, options: [])
} catch {
print("AVAudioSession发生错误")
}
// 3. 创建实例
pipCtr = AVPictureInPictureController(playerLayer: playerLayer) // playerLayer为播放器的图层,不能为新建的AVPlayerLayer
pipCtr?.delegate = self // 成为代理
}
- 开启/关闭画中画:
if pipCtr.isPictureInPictureActive == true {
pipCtr.stopPictureInPicture()
} else {
pipCtr.startPictureInPicture()
}
到目前为止已经可以实现基本的画中画效果了:
全局画中画
不过现在退出控制器后画中画也会跟着关闭,这里参考哔哩哔哩App的画中画,人家是开启画中画的同时退出当前播放器,另外开启新的画中画,会先关闭当前的画中画再打开新的画中画,所以目前的画中画功能还不够完善。
要想退出控制器后还保持着画中画,那就得继续保住播放器的命,或者保住控制器的命也可以,这里简单演示就保住控制器吧,不过在什么时候保住,什么时候释放呢?这时就得从AVPictureInPictureController
的代理方法中入手了:
// 即将开启画中画
optional func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 已经开启画中画
optional func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 开启画中画失败
optional func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error)
// 即将关闭画中画
optional func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 已经关闭画中画
optional func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 关闭画中画且恢复播放界面
optional func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void)
我的做法是,用一个全局变量来专门来保住控制器性命(毕竟画中画一般也就同时最多存在一个而已):
var playerVC_ : JPPlayerViewController?
class JPPlayerViewController: UIViewController {
......
pipCtr?.delegate = self // 成为代理
}
extension JPPlayerViewController : AVPictureInPictureControllerDelegate {
// 在即将开启画中画时持有该控制器,接着退出控制器,这样控制器就能苟住
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
playerVC_ = self
navigationController?.popViewController(animated: true)
}
// 在确保已经关闭画中画后释放引用,销毁控制器(只要关闭画中画最后都会来到这里,所以个人认为在这里释放比较合适)
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
playerVC_ = nil
}
}
关闭画中画并恢复播放界面
有两种方式:
1. 代码调用stopPictureInPicture()
2. 点击画中画上面的右边按钮
值得注意的是,通过这两种方式的话在关闭画中画前会执行
pictureInPictureController(_ pictureInPictureController:, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler:)
这个代理方法,这是用来恢复播放界面的,只需要在代理方法里面执行一下回调的闭包即可开启恢复动画:
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
completionHandler(true) // 执行回调的闭包
}
不过由于前面的做法是开启画中画的同时退出控制器的,想要恢复播放界面还得重新打开控制器:
var playerVC_ : JPPlayerViewController?
class JPPlayerViewController: UIViewController {
weak var navCtr : UINavigationController?
......
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 保存当前导航控制器
navCtr = navigationController
......
}
}
extension JPPlayerViewController : AVPictureInPictureControllerDelegate {
......
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
// 如果当前导航控制器的栈里并没有当前控制器,则重新打开
if let navCtr = navCtr, navCtr.viewControllers.contains(self) != true {
playerVC_ = nil // 确定关闭,置空防止后续误判
navCtr.pushViewController(self, animated: true)
// 因为push有动画过程,延时一点再恢复,不然动画会恢复到错误位置
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15) {
completionHandler(true)
}
return
}
completionHandler(true)
}
}
如果不是点击画中画的按钮,而是是通过其他途径打开当前画中画的控制器,这时也应该关闭画中画,可以在
viewWillAppear
里面进行判断并关闭:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
......
// 如果打开的控制器是当前画中画的控制器
if playerVC_ == self {
pipCtr?.stopPictureInPicture() // 关闭画中画
}
}
已有画中画的情况下开启新的画中画
画中画一般同时最多存在一个,所以如果要在已有画中画的情况下开启新的画中画,首先确保先完全关闭当前的再去打开新的,防止未知的错误出现,不过关闭画中画是有过程的,我的做法是采用闭包回调:
class JPPlayerViewController: UIViewController {
......
fileprivate var stopPipComplete : (()->())?
// 自定义一个关闭画中画的函数
func stopPictureInPicture(_ complete: (()->())?) {
if let pipCtr = pipCtr, pipCtr.isPictureInPictureActive == true {
stopPipComplete = complete
pipCtr.stopPictureInPicture()
} else {
stopPipComplete = nil
}
}
}
extension JPPlayerViewController : AVPictureInPictureControllerDelegate {
......
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
print("pictureInPictureControllerDidStopPictureInPicture")
playerVC_ = nil
// 执行闭包
if let complete = stopPipComplete { complete() }
stopPipComplete = nil
}
// 多加一个判断条件
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
if stopPipComplete == nil, // 如果关闭全局的画中画,那么stopPipComplete不为nil
let navCtr = navCtr,
navCtr.viewControllers.contains(self) != true {
playerVC_ = nil
navCtr.pushViewController(self, animated: true)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15) {
completionHandler(true)
}
return
}
completionHandler(true)
}
}
在另一个控制器中开启新的画中画:
func __togglePictureInPicture() {
guard let pipCtr = pipCtr else { return }
if pipCtr.isPictureInPictureActive {
controlView.pipBtn?.isSelected = false
pipCtr.stopPictureInPicture()
} else {
// 如果全局变量有值,说明已经存在一个画中画了,先关闭再打开新的
if let playerVC = playerVC_ {
// 如果时间较长可以加个hud
playerVC.stopPictureInPicture { [weak self] in
// 关闭hud
self?.controlView.pipBtn?.isSelected = true
self?.pipCtr?.startPictureInPicture()
}
} else {
controlView.pipBtn?.isSelected = true
pipCtr.startPictureInPicture()
}
}
}
额外问题
如果是想在打开画中画时才创建AVPictureInPictureController
(懒加载),有很大几率会没有反应,这时候应该加个延时再打开:
pipCtr = AVPictureInPictureController(playerLayer: playerLayer)
pipCtr?.delegate = self
guard let pipCtr = pipCtr else { return }
// 初始化的同时开启画中画很有可能会失效,可能是完全没有初始化完毕导致的,最好延时一下再开启
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
pipCtr.startPictureInPicture()
}
这是因为这个view在创建Controller后的下一次RunLoop循环才会初始化完毕,使用gcd延时执行就可以在往后的循环中拿到这个初始化好的view。
参考:官方文档
Demo:JPPictureInPictureDemo