iOS 屏幕旋转/横竖屏切换适配

前言

现在大部分的智能移动设备通过自动旋转,能够自动切换去呈现最适合当前屏幕显示的内容,无疑大大提升了使用者的用户体验。不过作为开发者,想要达到完美的适配效果,还是要下一番功夫钻研尝试才能做得的。笔者就根据自己适配屏幕自动旋转的工作经验,在此做一点总结。

硬件原理

为了检测设备(最关键的就是面子——屏幕)当前在三维空间中的朝向,现在的智能设备都内置了加速计。这一部分完全参照来源【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

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

推荐阅读更多精彩内容

  • 1.监听屏幕旋转方向 在处理iOS横竖屏时,经常会和UIDeviceOrientation、UIInterface...
    彬至睢阳阅读 2,536评论 1 6
  • [这是第11篇] 导语: iOS App中大多数页面是只展示竖屏下的效果,但是少部分页面需要支持横竖屏。本文分别介...
    南华coder阅读 14,490评论 18 93
  • iOS屏幕旋转学习笔记iOS开发中使用屏幕旋转功能的相关方法 1、基本知识点解读 了解屏幕旋转首先需要区分两种 o...
    Laughingg阅读 13,503评论 13 39
  • 一: 序 二: 基本知识点了解 三: 屏幕旋转流程 四: 屏幕旋转设置 四: 结言 序: 这几天项目要适...
    Luyc_Han阅读 492评论 0 1
  • 时光匆匆流去一别竟是半生回忆想起您循循善诱的教诲想起您如沐春风的话语想起您在灯下批阅作业的身影想起您在三尺讲台上挥...
    哲语细细阅读 331评论 26 32