iOS播放器全屏旋转实现
代码地址:HSPlayerFullScreenDemo 2020年6月19日更新
我们的客户端主要功能就是看电影,所以我们经常要与视频播放器打交道,用到视频播放器就需要满足用户全屏观看需求,视频播放器全屏需求往往需要App界面的旋转来实现,但是界面旋转就会带来一系列的兼容问题,比如系统弹窗方向与播放器方向不一致、导航栏和状态栏的方向大小飘回不定、App页面跳转与横屏播放器冲突、前贴片广告与播放器兼容等等。随着iOS系统的更新以及业务的迭代,我们全屏方案也跟着改进了好几版,在这过程中我们碰到了很多问题也积累了一些经验,现在将部分内容分享出来,供大家参考。
完善播放器的全屏一般需要满足以下功能点:
- 能够正常切换到横屏。
- 必要的过渡动画。
- 返回竖屏播放器大小位置未发生变化。
- 竖屏界面的内容不能发生变化。
- 兼容系统弹窗。
- 跟随设备方向旋转播放器方向。
常见方案介绍:
先来看看主流视频App使用的旋转方案吧:
1、原生页面旋转。
使用苹果原生支持的页面旋转,竖屏状态下强制旋转设备旋转,播放器和所在的页面一同旋转为横屏状态。腾讯视频和芒果TV就是使用了这种方案,这也是苹果支持的方案。
原生旋转优点:逻辑简单,易用,兼容性好
缺点: 过渡动画稍显生硬,需要使用私有方法。
2、播放器View旋转。
使用UIView的transform属性,让播放器View旋转90度,然后通过一些方法把状态栏旋转到对应的方向,达到播放器旋转的目的。今日头条的短视频在使用这种方案,他们技术团队也做了分享 文章直达。
播放器View旋转.gif优点:动画简单高效,过渡自然
缺点:播放器不是真正的横屏,播放器全屏状态下无法使用正常使用AlertView等系统弹窗。
3、播放器View旋转+竖屏Window
这种方案是在第二种方案的基础上添加一个竖屏Window而来,全屏播放器的Window和主界面Window不是同一个Window,这样我们就可以通过全屏window的rootViewcontroller控制状态栏的显示隐藏了。据我所知,新版的zfplayer正在使用此方案。
第三种方案的图层优点:动画简单高效,过渡自然;全屏播放器和主界面不是同一个Window,可以方便的控制状态栏显隐。
缺点:播放器不是真正的横屏,播放器全屏状态下无法使用正常使用AlertView等系统弹窗;播放器从一个Window转移到另个Window上,较大概率能够看到闪屏。
4、播放器View旋转动画+横屏Window
这种方案我们在主界面Window上,使用播放器的旋转动画做过渡动画,动画完成后把播放器View正过来,添加到提前生成的横屏Window上。经分析发现爱奇艺和优酷在使用当前方案。
优点:动画简单高效,过渡自然;全屏播放器和主界面不是同一个Window,可以方便的控制状态栏显隐;播放器是真正的横屏,播放器全屏状态可以完全兼容系统弹窗;
缺点:播放器从一个Window转移到另个Window上,较大概率能够看到闪屏;界面;因为涉及到横竖屏切换,横屏状态下将App切换到后台然后切换到前台可能看到竖屏界面尺寸发生异常改变。
通过xcode查看视图层级发现第四种的图层是正过来,第三种方案的图片是旋转90度的。
第四种方案的图层知识点:
如何控制界面的旋转?
如果应用内所有页面都只支持同一方向或者每个页面都支持所有多个方向,那么在项目中的 info.plist
里通过设置UIInterfaceOrientation
的值或者在xcode里面勾选Device Orientation
的选项值来配置应用支持的设备方向。
如果应用内大部分页面只支持横屏,部分页面支持多个设备反向,那么需要动态灵活的配置应用支持的设备方向。
这时我们就可以实现AppDelegate的supportedInterfaceOrientationsForWindow
方法来动态指定某个Window可以旋转的方向。如果我们没有实现这个方法,应用的支持的方向由info.plist
的UIInterfaceOrientation值确定。如果实现了这个方法那么info.plist
里面的值就无效了。
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
if 横屏条件 {
return UIInterfaceOrientationMask.allButUpsideDown
}
return UIInterfaceOrientationMask.portrait
}
当设备方向发生改变时,系统就会调用包括上面方法等一系列的方法来确定当前页面的方向。
- 首先会调用
supportedInterfaceOrientationsForWindow
方法来确定应用支持的方向,当前window的值为nil。(iOS13 window不会出现nil值) - 然后会再次调用
supportedInterfaceOrientationsForWindow
方法来确定当前页面的Window支持的方向,此时window的值为当前页面的window。 - 最后调用Window的rootViewcontroller的
supportedInterfaceOrientations
和shouldAutorotate
方向来确定当前页面支持的方向。
然而我们的rootViewController大多是TabbarController或者NavigationController,而我们需要旋转的页面一般属于它们的ChildViewController,所以当我们要配置某一个ViewController可以旋转的方向时,需要将当前ViewController的supportedInterfaceOrientations
和shouldAutorotate
值传递给ViewController所在的window的rootViewcontroller。以下是用到的代码:
import UIKit
extension UITabBarController{
open override var shouldAutorotate: Bool{
let selected = self.selectedViewController;
if selected?.isKind(of: UITabBarController.classForCoder()) ?? false{
let nav = selected as! UINavigationController
return nav.topViewController!.shouldAutorotate
}else{
return selected!.shouldAutorotate
}
}
open override var supportedInterfaceOrientations: UIInterfaceOrientationMask{
let selected = self.selectedViewController;
if selected?.isKind(of: UITabBarController.classForCoder()) ?? false{
let nav = selected as! UINavigationController
return nav.topViewController!.supportedInterfaceOrientations
}else{
return selected!.supportedInterfaceOrientations
}
}
open override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation{
let selected = self.selectedViewController;
if selected?.isKind(of: UITabBarController.classForCoder()) ?? false{
let nav = selected as! UINavigationController
return nav.topViewController!.preferredInterfaceOrientationForPresentation
}else{
return selected!.preferredInterfaceOrientationForPresentation
}
}
}
extension UINavigationController{
open override var shouldAutorotate: Bool{
return self.topViewController!.shouldAutorotate
}
open override var supportedInterfaceOrientations: UIInterfaceOrientationMask{
return self.topViewController!.supportedInterfaceOrientations
}
open override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation{
return self.topViewController!.preferredInterfaceOrientationForPresentation
}
open override var childForStatusBarStyle: UIViewController?{
return self.topViewController
}
open override var childForStatusBarHidden: UIViewController?{
return self.topViewController
}
open override var childForHomeIndicatorAutoHidden: UIViewController?{
return self.topViewController
}
}
强制设备旋转
使用如下方法强制设备旋转,不过UIDevice.current.setValue(value, forKey: key)
方法属于私有方法,苹果审核有被拒的风险。
let orientationRawValue = UIInterfaceOrientation.landscapeRight.rawValue
UIDevice.current.setValue(orientationRawValue, forKey: "orientation")
self.setNeedsStatusBarAppearanceUpdate() //在当前ViewController里面调用
UIViewController.attemptRotationToDeviceOrientation()
如何更改状态栏的方向?
如方案2和方案3如何在界面不旋转的情况下更改状态栏的方向,在iOS13之前我们使用了今日头条分享的那种方案:
方法一
- 调用UIApplication的setStatusBarOrientation:animated:方法改变statusBar的方向
- 当前的ViewController的shouldAutorotate方法,返回NO
比如我们项目中就使用了如下代码:
- (void)setStatusBarOrientation:(UIInterfaceOrientation)interfaceOrientation {
[[UIApplication sharedApplication] setStatusBarOrientation:interfaceOrientation animated:NO];
}
但是这个方法已经被苹果depreciate了,在iOS12以及iOS12之前这个方法没有问题,但是到了iOS13,我们使用xcode11编译发布项目,这个方法就无效了,这个方法无法改变状态栏的方向。iOS13发布后,我们试图寻找别的方法更改状态栏的方向,最终我们找到了一个方法。
方法二
我们可以通过创建相应方向的window,然后调用当前ViewController的setNeedsStatusBarAppearanceUpdate
方法和UIViewController
的attemptRotationToDeviceOrientation
达到状态栏旋转的目的。(具体代码可以下载Demo查看)
我们先自定义一个UIViewController的子类HSPlayerSceneController,代码如下:
import UIKit
class HSPlayerSceneController: UIViewController {
var interfaceOrientationMask:UIInterfaceOrientationMask? = nil
override func viewDidLoad() {
super.viewDidLoad()
}
override var shouldAutorotate:Bool{
return !self.shouldNotAutorotate
}
override var supportedInterfaceOrientations:UIInterfaceOrientationMask{
if self.interfaceOrientationMask != nil {
return self.interfaceOrientationMask!
}
return .landscape
}
}
然后创建有方向的window:
let sceneVC = HSPlayerSceneController()
sceneVC.interfaceOrientationMask = (orientation == UIInterfaceOrientation.landscapeLeft) ? UIInterfaceOrientationMask.landscapeLeft : UIInterfaceOrientationMask.landscapeRight
let sceneWnd = UIWindow(frame: UIScreen.main.bounds)
sceneWnd.rootViewController = sceneVC
最后通过以下代码改变状态栏方向:
func updateStatusBarAppearance() {
let window = (UIApplication.shared.delegate as! AppDelegate).window
var top: UIViewController? = window?.rootViewController
while true {
if top?.presentingViewController != nil {
top = top?.presentingViewController
} else if top is UINavigationController {
if let nav: UINavigationController = top as? UINavigationController {
top = nav.topViewController
} else {
break
}
} else if top is UITabBarController {
if let tab: UITabBarController = top as? UITabBarController {
top = tab.selectedViewController
} else {
break
}
} else {
break
}
}
top?.setNeedsStatusBarAppearanceUpdate()
UIViewController.attemptRotationToDeviceOrientation()
}
如何解决播放器的自动布局约束和transform动画冲突?
我们的播放器使用自动布局约束控件,使用播放器的View的transform动画做翻转,这一切在iOS10以上的系统比较正常,但是到iOS10或者iOS10以下系统会出现旋转后界面异常。我们当时查了一些资料,发现播放器的自动布局和transform动画冲突,那么如何解决这个问题呢?
很简单,我们在播放器View上再套一个View,在这个View上做transform动画,就可以解决这个问题。这就是在Demo中我们使用playerTransitionView
的原因。
在旋转动画中playerTransitionView的SubView会出现拖白现象:SubView和动画playerTransitionView的变化不同步。解决这个问题只需要设置动画选项UIView.KeyframeAnimationOptions.layoutSubviews
即可。
UIView.animate(withDuration: 0.35, delay: 0, options: UIView.AnimationOptions.layoutSubviews, animations: {
}) {_ in
}