iPhone上使用画中画(PictureInPicture of iOS14)

画中画(PictureInPicture)在iOS9就已经推出了,不过之前都只能在iPad使用,iPhone要使用画中画就得更新到iOS14才能使用。

Demo:JPPictureInPictureDemo

iPhone上的画中画

前提准备

  • 下载并安装Xcode12(系统并不需要更新到最新);下载地址
  • 如果要用真机测试,就得下载iOS14的描述文件,然后更新并安装iOS14的beat版本即可。下载地址

基本使用

如果对播放器要求不大的可以直接使用AVPlayerViewController,自身就提供画中画功能可直接使用,而自定义的播放器要开启画中画那就使用AVPictureInPictureController,也是很简单易用,并且动画效果内部已经实现,只需要以下3步即可实现:

  1. 使用Xcode12打开工程,首先得开启后台模式:
  2. 初始化播放器的同时创建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 // 成为代理
}
  1. 开启/关闭画中画:
if pipCtr.isPictureInPictureActive == true {
    pipCtr.stopPictureInPicture()
} else {
    pipCtr.startPictureInPicture()
}

到目前为止已经可以实现基本的画中画效果了:

全局画中画

不过现在退出控制器后画中画也会跟着关闭,这里参考哔哩哔哩App的画中画,人家是开启画中画的同时退出当前播放器,另外开启新的画中画,会先关闭当前的画中画再打开新的画中画,所以目前的画中画功能还不够完善。

要想退出控制器后还保持着画中画,那就得继续保住播放器的命,或者保住控制器的命也可以,这里简单演示就保住控制器吧,不过在什么时候保住,什么时候释放呢?这时就得从AVPictureInPictureController的代理方法中入手了:

<AVPictureInPictureControllerDelegate>
// 即将开启画中画
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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,565评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,021评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,003评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,015评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,020评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,856评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,178评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,824评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,264评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,788评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,913评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,535评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,130评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,102评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,334评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,298评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,622评论 2 343