前言
现在大部分的智能移动设备通过自动旋转,能够自动切换去呈现最适合当前屏幕显示的内容,无疑大大提升了使用者的用户体验。不过作为开发者,想要达到完美的适配效果,还是要下一番功夫钻研尝试才能做得的。笔者就根据自己适配屏幕自动旋转的工作经验,在此做一点总结。
硬件原理
为了检测设备(最关键的就是面子——屏幕)当前在三维空间中的朝向,现在的智能设备都内置了加速计。这一部分完全参照来源【1】:
通过感知特定方向的惯性力总量,加速计可以测量出加速度和重力,ios设备内的加速计是一个三轴加速计,这意味着它能够检测出三维空间中的运动或重力引力。因此加速计不但可以指示握持电话的方式(如自动旋转功能),而且如果电话放在桌子上的话还可以指示电话的正面朝上还是朝下。
加速计可以测量g引力(g代表重力),因此加速计返回值为1.0时,表示在特定的方向上感知到1g。
- 如果是静止握持iphone而没有任何运动,那么地球引力对其施加的力大约为1g
- 如果是纵向竖直握持,那么设备会检测并报告在其y轴上施加的力大约为1g
- 如果是以一定的角度握持,那么1g的力会分布到不同的轴上,这取决于握持的方式,在以45度握持时,1g的力会均匀的分解到两个轴上。如果检测到加速计值远大于1g,那么可以判断是突然运动,,正常使用时加速计在任何一个轴上都不会检测到远大于1g的值,如果摇动、坠落或投掷设备,那么加速计便会在一个或多个轴上检测到很大的力。
下图所示加速计所使用的三轴结构
当然,如今的智能手机里往往不光内置了加速计,往往还有陀螺仪。这一方面的知识就由大家自行去挖掘吧,很多游戏都是利用它去实现很自然的操作感。
软件适配
朝向定义
既然硬件能获取到当前屏幕的朝向,苹果的SDK也一定会为开发者提供接口指定有哪些朝向可选,以及如何获取到当前朝向。在 UIDevice.h 以及 UIApplication.h 中可见其定义如下:
7种设备朝向:
typedef NS_ENUM(NSInteger, UIDeviceOrientation) {
UIDeviceOrientationUnknown,
UIDeviceOrientationPortrait, // Device oriented vertically, home button on the bottom
UIDeviceOrientationPortraitUpsideDown, // Device oriented vertically, home button on the top
UIDeviceOrientationLandscapeLeft, // Device oriented horizontally, home button on the right
UIDeviceOrientationLandscapeRight, // Device oriented horizontally, home button on the left
UIDeviceOrientationFaceUp, // Device oriented flat, face up
UIDeviceOrientationFaceDown // Device oriented flat, face down
} __TVOS_PROHIBITED;
5种界面朝向:
// Note that UIInterfaceOrientationLandscapeLeft is equal to UIDeviceOrientationLandscapeRight (and vice versa).
// This is because rotating the device to the left requires rotating the content to the right.
typedef NS_ENUM(NSInteger, UIInterfaceOrientation) {
UIInterfaceOrientationUnknown = UIDeviceOrientationUnknown,
UIInterfaceOrientationPortrait = UIDeviceOrientationPortrait,
UIInterfaceOrientationPortraitUpsideDown = UIDeviceOrientationPortraitUpsideDown,
UIInterfaceOrientationLandscapeLeft = UIDeviceOrientationLandscapeRight,
UIInterfaceOrientationLandscapeRight = UIDeviceOrientationLandscapeLeft
} __TVOS_PROHIBITED;
可见二者的枚举值相互之间对应得上。
另外还有可组合使用的OrientationMask定义,通常在页面声明支持的朝向时用到,后面再展开讨论。
typedef NS_OPTIONS(NSUInteger, UIInterfaceOrientationMask) {
UIInterfaceOrientationMaskPortrait = (1 << UIInterfaceOrientationPortrait),
UIInterfaceOrientationMaskLandscapeLeft = (1 << UIInterfaceOrientationLandscapeLeft),
UIInterfaceOrientationMaskLandscapeRight = (1 << UIInterfaceOrientationLandscapeRight),
UIInterfaceOrientationMaskPortraitUpsideDown = (1 << UIInterfaceOrientationPortraitUpsideDown),
UIInterfaceOrientationMaskLandscape = (UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight),
UIInterfaceOrientationMaskAll = (UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight | UIInterfaceOrientationMaskPortraitUpsideDown),
UIInterfaceOrientationMaskAllButUpsideDown = (UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight),
} __TVOS_PROHIBITED;
朝向获取和设置
有了朝向的定义,该如何获取当前的朝向取值呢?
如果是要获取设备朝向,可以直接通过 UIDevice 实例的属性
// return current device orientation. this will return UIDeviceOrientationUnknown unless device orientation notifications are being generated.
@property(nonatomic,readonly) UIDeviceOrientation orientation __TVOS_PROHIBITED;
需要注意注释的内容,也就是必须首先在 UIDevice 朝向通知生成之后才可以正常获取朝向数据。
也就是要监听UIDevice抛出的系统通知 UIDeviceOrientationDidChangeNotification
[[NSNotificationCenter defaultCenter]addObserver:self
selector:@selector(updateOrientation:)
name:UIDeviceOrientationDidChangeNotification object:nil];
不过这里其实有一点小坑,那就是还有一对关键的接口苹果没有直接告诉你,那就是
- (void)beginGeneratingDeviceOrientationNotifications __TVOS_PROHIBITED; // nestable
- (void)endGeneratingDeviceOrientationNotifications __TVOS_PROHIBITED;
必须要在调用前者之后,才会在每次设备朝向变化时触发 UIDeviceOrientationDidChangeNotification 通知。
不过没有必要的话,也要及时调用后者去结束对加速计数据的获取,默默的为用户电池续航助力。
类似的,也同样可以通过监听下面两个通知去获取UIInterfaceOrientation的变化:
UIKIT_EXTERN NSString *const UIApplicationWillChangeStatusBarOrientationNotification __TVOS_PROHIBITED; // userInfo contains NSNumber with new orientation
UIKIT_EXTERN NSString *const UIApplicationDidChangeStatusBarOrientationNotification __TVOS_PROHIBITED; // userInfo contains NSNumber with old orientation
二者的差异关键是在notification的userInfo中携带的值,一个是新的朝向值,一个是旧的朝向值,可不要搞反了哦。
再有是通过UIApplication的下面这个属性也可以获取界面朝向。
// Explicit setting of the status bar orientation is more limited in iOS 6.0 and later.
@property(readwrite, nonatomic) UIInterfaceOrientation statusBarOrientation NS_DEPRECATED_IOS(2_0, 9_0) __TVOS_PROHIBITED;
有的同学可能会有疑问,DeviceOrientation 和 StatusBarOrientation是否可以等同使用?关于这个问题,有句话说的好:
纸上得来终觉浅,绝知此事要躬行
动手试一试就会明白,二者实则有着本质不同。
真相在此:前者是指示设备朝向,而后者则是指示当前界面中状态栏的朝向;在[UIDevice beginGeneratingDeviceOrientationNotifications]之后,每次设备旋转,都会有UIDeviceOrientationDidChangeNotification的通知生成,而 UIApplicationWillChangeStatusBarOrientationNotification 则是当前显示controller支持对应的InterfaceOrientation时才会触发。
所以可能会出现这种情况,DeviceOrientation 值 为UIDeviceOrientationLandscapeLeft,但InterfaceOrientation 值却是 UIInterfaceOrientationPortrait,下图就是典型的例子:
另外,某些应用场景下,还需要去手动设置屏幕旋转,比如播放器往往都既支持自动旋转屏幕去切换全屏播放,同时也允许用户去手动切换全屏或小屏播放。但翻看了半天API描述和文档,要么就是不提供接口,要么就是警告设置受限,那要怎么做呢?其实很简单,只要两行代码搞定:
NSNumber *value = @(UIInterfaceOrientationPortrait);//或者别的想要的值
[[UIDevice currentDevice] setValue:value forKey:@"orientation"];
App及页面适配
- App全局配置
App中全局配置支持朝向的地方,最方便的就是在工程的Target中了,如图所示:
理所当然全局配置其优先级当然是最高的,即使某个页面声明支持某Orientation,但全局配置中并没有选中对应的Device Orientation,是不会起效的。
-
单个页面配置
具体到某个页面(controller)层级的配置,UIViewController提供了如下的回调方法// New Autorotation support. - (BOOL)shouldAutorotate NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED; - (UIInterfaceOrientationMask)supportedInterfaceOrientations NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED; // Returns interface orientation masks. - (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED;
第一个方法在首次进入controller以及屏幕方向未锁定且触发旋转时会被系统调用(且不重写的话,默认返回值为YES),如果返回NO,那么表明该页面不支持对屏幕旋转做适配;若返回YES,则表明支持旋转,但具体适配了哪几个朝向,则依赖于supportedInterfaceOrientations 方法的返回值,也就是UIInterfaceOrientationMask类型的Option组合。
看起来并不复杂对不对?在设定了App的全局配置,并在相应的controller中实现了这些回调之后发现,有同学可能会失望地发现,设备旋转时这些方法却并没有期望地那样被调到,这是为什么呢?
通过反复验证,发现其实系统确实会调用这个方法,但默认执行粒度是到系统级的 Container View Controller(UINavigationController/UITabBarController)为止(其实直接挂在UIWindow上作为其rootViewController的UIViewController对象的 shouldAutoRotate 方法也会得到调用,但毕竟大多数情况下,我们不会用这么简单的组合结构的)。所以我们额外需要实现的一步,就是转发这个调用消息到我们真正想要处理的那个controller上。当然,可以通过hook系统类的对应方法去做实现,但笔者采用的是在自行定义的UINavigationController继承类中重写这些方法:
#pragma mark Orientation
- (BOOL)shouldAutorotate
{
BOOL shouldAutorotate = NO;
UIViewController *viewController;
if (IOS_VERSION_FLOAT_VALUE >= 8.0)
{
viewController = [self visibleViewController];
}
else
{
viewController = [self topViewController];
}
if ([viewController isKindOfClass:K12RootViewController.class] && ((K12RootViewController *)viewController).visibleNav) {
viewController = ((K12RootViewController *)viewController).visibleNav;
}
if (viewController.ht_currentChildViewController) {
viewController = viewController.ht_currentChildViewController;
}
if ([viewController isKindOfClass:[UIViewController class]])
{
shouldAutorotate = [(UIViewController *)viewController shouldAutorotate];
}
//弹框也要支持旋转
if ([viewController isKindOfClass:K12PlayerController.class] || ((IOS_VERSION_FLOAT_VALUE >= 8.0) ? [viewController isKindOfClass:UIAlertController.class] : NO)) {
return YES;
}
else {
return NO;
}
return shouldAutorotate;;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
NSUInteger supportedInterfaceOrientations = UIInterfaceOrientationMaskPortrait;
UIViewController *viewController;
if (IOS_VERSION_FLOAT_VALUE >= 8.0)
{
viewController = [self visibleViewController];
}
else
{
viewController = [self topViewController];
}
if ([viewController isKindOfClass:K12RootViewController.class] && ((K12RootViewController *)viewController).visibleNav) {
viewController = ((K12RootViewController *)viewController).visibleNav;
}
if (viewController.ht_currentChildViewController) {
viewController = viewController.ht_currentChildViewController;
}
//向UIAlertController发送supportedInterfaceOrientations消息会crash……
if ([viewController isKindOfClass:UIAlertController.class]) {
return UIInterfaceOrientationMaskAllButUpsideDown;
}
if ([viewController isKindOfClass:[UIViewController class]])
{
supportedInterfaceOrientations = [(UIViewController *)viewController supportedInterfaceOrientations];
}
return supportedInterfaceOrientations;
}
可以看到其中有各种各样case的处理,原因就是除了播放页面支持竖屏、左横屏以及右横屏(UIInterfaceOrientationMaskAllButUpsideDown)之外,我们产品中的其他页面都是只支持横屏显示的(UIInterfaceOrientationMaskPortrait),同时在当前页面上有UIAlertController(iOS8 之后)弹出时,也要设置其支持跟随屏幕旋转。
类似的,如果 UIWindow 对象的 rootViewController 是 UITabBarController 的话,则需要转发消息给其 selectedViewController 属性对象,具体实现就不再赘言啦。
- 踩过的坑
说起来,项目开发中不踩点坑简直对不起程序猿这个title啊 —— 前面提到过
...挂在UIWindow上作为其rootViewController的UIViewController对象的 shouldAutoRotate 方法也会得到调用
这里往往会隐藏一个问题,默认在AppDelegate.m中,我们会这样做:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
...
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.k12RootController = [[K12RootViewController alloc] init];
K12NavigationController *navController = [[K12NavigationController alloc] initWithRootViewController: self.k12RootController];
self.window.rootViewController = navController;
[self.window makeKeyAndVisible];
...
}
当然,这看起来没有问题。但是假如App中还存在别的 UIWindow 对象呢?旋转时,它的rootViewController 的 shouldAutoRotate 方法也将被调用,若没有重写过,则其默认返回YES;如果与其他 UIWindow对象(特别是keyWindow) 所呈现的最顶部页面的返回值不一致,就会出现一些神奇的表现,如下图所示:
切换到横屏下时,状态栏居然消失了!!该情况的出现,就是因为在该答题页面的上一个页面(播放页面)中使用了一个第三方组件去绘制Menu,而其设计存在瑕疵,在生成Menu对象而非显示时就已经生成了一个UIWindow对象并持有了它。然后在进入答题页面时,虽然对应的controller的 shouldAutoRotate 方法返回了 NO,但Menu对应的UIWindow对象其rootViewController默认返回YES,导致出现页面保持竖屏显示,但状态栏响应了旋转的奇怪现象。
这个问题最终还是通过hook掉 UIViewController 的shouldAutoRotate 方法,去追踪究竟是哪个controller对象返回了默认值 YES 才最终大白天下。这也提醒我们,对开源库的品质也是谨慎对待的,往往太复杂业务场景,还是需要自己去定制功能才能满足。
总结
这篇文章也算是在参与某产品开发过程中,屏幕旋转适配过程中,踩了不少坑之后经验教训的一个总结。当然,想要实现页面的横竖屏切换效果,并不是只有这一条路径,还可以通过UIView的transform属性去实现,不过那就是另一个话题啦 。
@property(nonatomic) CGAffineTransform transform; // default is CGAffineTransformIdentity. animatable
ヾ( ̄▽ ̄)ByeBye
参考资料
【1】ios 关于屏幕旋转和屏幕晃动
【2】iOS指定页面屏幕旋转,手动旋转(某app实现功能全过程)
【3】iOS: Using UIDeviceOrientation to Determine Orientation