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

前言

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

硬件原理

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

通过感知特定方向的惯性力总量,加速计可以测量出加速度和重力,ios设备内的加速计是一个三轴加速计,这意味着它能够检测出三维空间中的运动或重力引力。因此加速计不但可以指示握持电话的方式(如自动旋转功能),而且如果电话放在桌子上的话还可以指示电话的正面朝上还是朝下。

加速计可以测量g引力(g代表重力),因此加速计返回值为1.0时,表示在特定的方向上感知到1g。

  • 如果是静止握持iphone而没有任何运动,那么地球引力对其施加的力大约为1g
  • 如果是纵向竖直握持,那么设备会检测并报告在其y轴上施加的力大约为1g
  • 如果是以一定的角度握持,那么1g的力会分布到不同的轴上,这取决于握持的方式,在以45度握持时,1g的力会均匀的分解到两个轴上。如果检测到加速计值远大于1g,那么可以判断是突然运动,,正常使用时加速计在任何一个轴上都不会检测到远大于1g的值,如果摇动、坠落或投掷设备,那么加速计便会在一个或多个轴上检测到很大的力。

下图所示加速计所使用的三轴结构


iOS 屏幕旋转/横竖屏切换适配_第1张图片

当然,如今的智能手机里往往不光内置了加速计,往往还有陀螺仪。这一方面的知识就由大家自行去挖掘吧,很多游戏都是利用它去实现很自然的操作感。

软件适配

朝向定义

既然硬件能获取到当前屏幕的朝向,苹果的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,下图就是典型的例子:

iOS 屏幕旋转/横竖屏切换适配_第2张图片

另外,某些应用场景下,还需要去手动设置屏幕旋转,比如播放器往往都既支持自动旋转屏幕去切换全屏播放,同时也允许用户去手动切换全屏或小屏播放。但翻看了半天API描述和文档,要么就是不提供接口,要么就是警告设置受限,那要怎么做呢?其实很简单,只要两行代码搞定:

NSNumber *value = @(UIInterfaceOrientationPortrait);//或者别的想要的值
[[UIDevice currentDevice] setValue:value forKey:@"orientation"];

App及页面适配

  • App全局配置

App中全局配置支持朝向的地方,最方便的就是在工程的Target中了,如图所示:

iOS 屏幕旋转/横竖屏切换适配_第3张图片

理所当然全局配置其优先级当然是最高的,即使某个页面声明支持某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) 所呈现的最顶部页面的返回值不一致,就会出现一些神奇的表现,如下图所示:

iOS 屏幕旋转/横竖屏切换适配_第4张图片

切换到横屏下时,状态栏居然消失了!!该情况的出现,就是因为在该答题页面的上一个页面(播放页面)中使用了一个第三方组件去绘制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

你可能感兴趣的:(iOS 屏幕旋转/横竖屏切换适配)