[iOS]终极横竖屏切换解决方案

你们的项目都是只支持竖屏的吧?大多数朋友(这其中固然也包括博主),都没有作过横屏开发,此次项目恰好有这个需求,所以把横竖屏相关的心得写成一遍文章供诸位参考。html

01.综述

大多数公司的项目都只支持竖屏,只有一两个界面须要同时支持横屏,就像视频 APP 同样,只有视频播放的时候须要横屏,其余时候都只容许竖屏。给出的 demo 中处理两种须要横屏的情形:git

  • 第一种是录制视频时横屏
  • 第二种是播放视频时横屏

具体使用演示请前往优酷视频查看:BLLandscape Demogithub

02.录制视频横屏

通常可能只须要播放视频时横屏,录制横屏通常用不到,可是若是有朋友须要作横屏视频录制,这时候就须要录制横屏处理,就像下面这样的。web

这个思路是这样的:markdown

  • 横屏的时候,首先把要横屏的 view 从原先的 superView 中移除,添加到当前的 keyWindow 上,而后作 frame 动画,将窗口的高设为 view 的宽,窗口的宽设置为 view 的高,而后将 view 的旋转 90°,执行动画,就能获得当前的效果。架构

  • 竖屏的时候,是一个相反的过程,先在窗口上作完动画,再将 view 插入到横屏以前的 superView 中的对应位置上。app

2.1.横屏切换

我把这些实现都抽成一个 UIView 的分类,看一下实现:ide

// frame 转换.
- (void)landscapeExecute{
    self.transform = CGAffineTransformMakeRotation(M_PI_2);
    CGRect bounds = CGRectMake(0, 0, CGRectGetHeight(self.superview.bounds), CGRectGetWidth(self.superview.bounds));
    CGPoint center = CGPointMake(CGRectGetMidX(self.superview.bounds), CGRectGetMidY(self.superview.bounds));
    self.bounds = bounds;
    self.center = center;
}


- (void)bl_landscapeAnimated:(BOOL)animated animations:(BLScreenEventsAnimations)animations complete:(BLScreenEventsComplete)complete{
    if (self.viewStatus != BLLandscapeViewStatusPortrait) {
        return;
    }

    self.viewStatus = BLLandscapeViewStatusAnimating;

    self.parentViewBeforeFullScreenSubstitute.anyObject = self.superview;
    self.frame_beforeFullScreen = [NSValue valueWithCGRect:self.frame];
    NSArray *subviews = self.superview.subviews;
    if (subviews.count == 1) {
        self.indexBeforeAnimation = 0;
    }
    else{
        for (int i = 0; i < subviews.count; i++) {
            id object = subviews[i];
            if (object == self) {
                self.indexBeforeAnimation = i;
                break;
            }
        }
    }

    CGRect rectInWindow = [self.superview convertRect:self.frame toView:nil];
    [self removeFromSuperview];
    self.frame = rectInWindow;
    [[UIApplication sharedApplication].keyWindow addSubview:self];

    if (animated) {
        [UIView animateWithDuration:0.35 animations:^{
        
            [self landscapeExecute];
            if (animations) {
                animations();
            }
        
        } completion:^(BOOL finished) {
        
            [self landscpeFinishedComplete:complete];
        
        }];
    }
    else{
        [self landscapeExecute];
        [self landscpeFinishedComplete:complete];
    }

    self.viewStatus = BLLandscapeViewStatusLandscape;
    [self refreshStatusBarOrientation:UIInterfaceOrientationLandscapeRight];
}
复制代码

2.2.竖屏切换

竖屏和横屏就是一个相反的过程,这里不贴代码也不作解释了。不懂的去看源码就知道了。oop

2.3.注意点

2.3.1.分类中实现 weak

源码没什么难度,可是有一个细节须要注意,咱们要在分类中以 weak 的内存管理策略去引用动画以前的 superView,以便咱们回来作竖屏动画完成之后将当前 view 添加到动画以前的 superView 上。可是在分类中添加属性的内存管理策略中没有 weak 属性,可是有一个 OBJC_ASSOCIATION_ASSIGN,它相似咱们经常使用的 assignassign 策略的特色就是在对象释放之后,不会主动将应用的对象置为 nil,这样会有访问僵尸对象致使应用奔溃的风险。布局

为了解决这个问题,咱们能够建立一个替身对象,咱们能够在分类中以 OBJC_ASSOCIATION_RETAIN_NONATOMIC 的策略来强引用替身对象,而后在替身对象中以 weak 的策略去引用咱们真实须要保存的对象。这样就能解决这个可能致使奔溃的问题了。

最近在知乎上有一个朋友说起了另一种方式,咱们能够建立一个 block,在 block 中引用咱们须要使用 weak 内存管理的对象,而后咱们强引用这个 block,就像下面这样:

#import <Foundation/Foundation.h>

@interface NSObject (Weak)

/**
 * weak
 */
@property(nonatomic) id weakObject;

@end

#import "NSObject+Weak.h"
#import <objc/runtime.h>

@implementation NSObject (Weak)

- (void)setWeakObject:(id)weakObject {
    id __weak __weak_object = weakObject;
    id (^__weak_block)() = ^{
        return __weak_object;
    };
    objc_setAssociatedObject(self, @selector(weakObject),   __weak_block, OBJC_ASSOCIATION_COPY);
}

- (id)weakObject {
    id (^__weak_block)() = objc_getAssociatedObject(self, _cmd);
    return __weak_block();
}

@end
复制代码
2.3.2.布局

因为咱们作的是 frame 动画,因此以后在这个 view 上再添加子控件的时候必须使用 frame 布局,Autolayout 布局在当前的 view 上将不会被更新,致使 UI 错乱。

03.播放视频横屏

大多数场景都是播放视频的时候横屏,好比下面这样的:

若是你在网上搜 iOS 横竖屏切换 能搜到的也就是播放视频的时候的横屏了,而这些文章彷佛都是抄的某一篇文章,你们说的都同样。虽然你们抄来抄去,彷佛他们在文章中写的都能解决问题,但实际上他们的文章是不能解决实际问题的。

3.1.播放视频横屏

咱们来看一下控制屏幕旋转的两个方法:

@interface UIViewController (UIViewControllerRotation)
...
- (BOOL)shouldAutorotate NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED;
- (UIInterfaceOrientationMask)supportedInterfaceOrientations NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED;
...
@end
复制代码

能够看到能够控制屏幕方向的方法是定义在 UIViewController 里的,第一个 -shouldAutorotate 方法,系统会询问当前控制器是否支持旋转,第二个方法 -supportedInterfaceOrientations 告诉系统当前控制器支持那几个方向的旋转。

真实项目中,咱们的 UI 架构多是这样的:

咱们的项目中从窗口开始,依次是一个根控制器,而后再是 UITabbarController 而后再是 UINavigationController,最后才到咱们的 UIViewController,咱们是某些界面须要横屏,因此必需要把系统的询问细化到每一个控制器的方法才行。

结合上图,咱们看下一个横竖屏事件的传递过程:

  • 先是陀螺仪捕获到一个横屏事件
  • 接下来系统会找到当前用户操做的那个 APP
  • APP 会找到当前的窗口 window
  • 窗口 window 会找到根控制器,这个时候事件终于传到咱们开发者手里了
  • 对于咱们自定义的根控制器,它须要把这个事件传递到 UITabbarController
  • 对于 UITabbarController,须要把事件传递到 UINavigationController
  • 对于 UINavigationController,须要把事件传递到咱们本身的控制器
  • 最后在咱们本身的控制器中决定某个界面是否须要横屏

等等,你个人项目是一个已经可能有上千个控制器的大工程了,若是按照这个逻辑走下去,咱们要在每一个控制器写这个两个方法,不敢想象。

此时咱们第一要考虑的就是借助分类来实现,既简单又优雅,并且维护起来集中干净,何乐而不为?

#import <UIKit/UIKit.h>

@interface UIViewController (Landscape)

/**
 * 是否须要横屏(默认 NO, 即当前 viewController 不支持横屏).
 */
@property(nonatomic) BOOL bl_shouldAutoLandscape;

@end

#import "UIViewController+Landscape.h"
#import <objc/runtime.h>
#import <JRSwizzle.h>

@implementation UIViewController (Landscape)

+ (void)load{
    [self jr_swizzleMethod:@selector(shouldAutorotate) withMethod:@selector(bl_shouldAutorotate) error:nil];
    [self jr_swizzleMethod:@selector(supportedInterfaceOrientations) withMethod:@selector(bl_supportedInterfaceOrientations) error:nil];
}

- (BOOL)bl_shouldAutorotate{ // 是否支持旋转.

    if ([self isKindOfClass:NSClassFromString(@"BLAppRootViewController")]) {
        return self.childViewControllers.firstObject.shouldAutorotate;
    }

    if ([self isKindOfClass:NSClassFromString(@"UITabBarController")]) {
        return ((UITabBarController *)self).selectedViewController.shouldAutorotate;
    }

    if ([self isKindOfClass:NSClassFromString(@"UINavigationController")]) {
        return ((UINavigationController *)self).viewControllers.lastObject.shouldAutorotate;
    }

    if ([self checkSelfNeedLandscape]) {
        return YES;
    }

    if (self.bl_shouldAutoLandscape) {
        return YES;
    }

    return NO;
}

- (UIInterfaceOrientationMask)bl_supportedInterfaceOrientations{ // 支持旋转的方向.

    if ([self isKindOfClass:NSClassFromString(@"BLAppRootViewController")]) {
        return [self.childViewControllers.firstObject supportedInterfaceOrientations];
    }

    if ([self isKindOfClass:NSClassFromString(@"UITabBarController")]) {
        return [((UITabBarController *)self).selectedViewController supportedInterfaceOrientations];
    }

    if ([self isKindOfClass:NSClassFromString(@"UINavigationController")]) {
        return [((UINavigationController *)self).viewControllers.lastObject supportedInterfaceOrientations];
    }

    if ([self checkSelfNeedLandscape]) {
        return UIInterfaceOrientationMaskAllButUpsideDown;
    }

    if (self.bl_shouldAutoLandscape) {
        return UIInterfaceOrientationMaskAllButUpsideDown;
    }

    return UIInterfaceOrientationMaskPortrait;
}

- (void)setBl_shouldAutoLandscape:(BOOL)bl_shouldAutoLandscape{
    objc_setAssociatedObject(self, @selector(bl_shouldAutoLandscape), @(bl_shouldAutoLandscape), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (BOOL)bl_shouldAutoLandscape{
    return [objc_getAssociatedObject(self, _cmd) boolValue];
}

- (BOOL)checkSelfNeedLandscape{
    NSProcessInfo *processInfo = [NSProcessInfo processInfo];
    NSOperatingSystemVersion operatingSytemVersion = processInfo.operatingSystemVersion;
    if (operatingSytemVersion.majorVersion == 8) {
        NSString *className = NSStringFromClass(self.class);
        if ([@[@"AVPlayerViewController", @"AVFullScreenViewController", @"AVFullScreenPlaybackControlsViewController"
           ] containsObject:className]) {
            return YES;
        }
    
        if ([self isKindOfClass:[UIViewController class]] && [self childViewControllers].count && [self.childViewControllers.firstObject isKindOfClass:NSClassFromString(@"AVPlayerViewController")]) {
            return YES;
        }
    }
    else if (operatingSytemVersion.majorVersion == 9){
        NSString *className = NSStringFromClass(self.class);
        if ([@[@"WebFullScreenVideoRootViewController", @"AVPlayerViewController", @"AVFullScreenViewController"
           ] containsObject:className]) {
            return YES;
        }
    
        if ([self isKindOfClass:[UIViewController class]] && [self childViewControllers].count && [self.childViewControllers.firstObject isKindOfClass:NSClassFromString(@"AVPlayerViewController")]) {
            return YES;
        }
    }
    else if (operatingSytemVersion.majorVersion == 10){
        if ([self isKindOfClass:NSClassFromString(@"AVFullScreenViewController")]) {
            return YES;
        }
    }

    return NO;
}

@end
复制代码

3.2.注意点

3.2.1. JPWarpViewController

当前 demo 中使用了 JPNavigationController,由于 JPNavigationController 结构的特殊性,因此这里加了一个

if ([self isKindOfClass:NSClassFromString(@"JPWarpViewController")]) {
    return [self.childViewControllers.firstObject supportedInterfaceOrientations];
 }
复制代码

若是你项目中有为每一个界面定制导航条的需求,你或许能够前往个人 GitHub 查看。

3.2.2.AVFullScreenViewController

当网页中有 video 标签的时候,iPhone 打开这个网页的的时候会把 video 标签替换为对应的系统的播放器,当咱们点击这个视频的时候,系统会全屏进入一个视频播放界面,经过打印这个控制器咱们能够看到这个控制器的类名是 AVFullScreenViewController,因此,这个界面须要横屏,就返回横屏对应的属性就能够实现这个控制器横屏。

3.2.3.实现有视频的网页须要横屏

并非全部的网页都须要横屏,可是若是这个网页有视频,每每须要横屏,那咱们怎么知道某个页面是否须要横屏,是否有视频呢?

一种方式是和 h5 约定一个事件,若是有视频就告诉原生 APP 作一个标记,将 bl_shouldAutoLandscape 置为 YES

可是我这里提供一种更加简便优雅的方式,咱们的 UIWebView 是能够经过 -stringByEvaluatingJavaScriptFromString: 方法和咱们交互的,因此咱们能够尝试下面的方法:

#pragma mark - UIWebViewDelegate

- (void)webViewDidFinishLoad:(UIWebView *)webView{
    NSString *result = [webView stringByEvaluatingJavaScriptFromString:@"if(document.getElementsByTagName('video').length>0)document.getElementsByTagName('video').length;"];
    if (result.length && result.integerValue != 0) {
        self.bl_shouldAutoLandscape = YES;
    }
}
复制代码

WebView 加载完之后,咱们去查找当前的 h5 页面中有没有 Video 标签,若是有,那咱们就能够拿到结果,作对应的横屏处理。

3.2.4.大坑来了

原本咱们的这个 UITableViewController 是不支持横竖屏的,就像这样,注意,这个时候那个 UISwitch 按钮是关闭的。

接下来,咱们把这个开关打开,这个开关对应的代码是这样:

- (void)switchValueChanged:(UISwitch *)aswitch{
    if (aswitch.isOn) {
        BLAnotherWindowViewController *vc = [BLAnotherWindowViewController new];
        self.anotherWindow.rootViewController = vc;
        [self.anotherWindow insertSubview:vc.view atIndex:0];
    }
    else{
        self.anotherWindow.rootViewController = nil;
    }
}
复制代码

就是打开后会为另一个窗口添加一个根控制器,而这个根控制器的代码是这样的:

#import "BLAnotherWindowViewController.h"

@interface BLAnotherWindowViewController ()

@end

@implementation BLAnotherWindowViewController

- (BOOL)shouldAutorotate{
    return YES;
}

- (UIInterfaceOrientationMask)supportedInterfaceOrientations{
    return UIInterfaceOrientationMaskAll;
}

@end
复制代码

这样之后,咱们观察一下控制器的表现:

看起来咱们的主界面确实仍然不支持横竖屏,这是没有问题的,可是好像咱们的状态栏被蓝色的这个窗口劫持了,它们俩双宿双飞,一块儿干了这么一个横竖屏的勾当。

咱们想象一下,如今这个蓝色的窗口在最前面,咱们能敏捷的观察到时这个蓝色的窗口劫持了状态栏。那若是这个蓝色的窗口在咱们的主窗口后面呢,那咱们根本就不会察觉到这个细节,咱们能看到的就是下面这样:

第一次碰到这个 bug,个人心里是奔溃的。

咱们一块儿来分析一下这个问题是怎么形成的。再来看一下这个横竖屏系统询问路径图,当咱们有多个窗口之时,每一个窗口都是平等的,那个蓝色的窗口也收到了系统的询问。

  • 还记得以前两个系统询问的方法是 UIViewController 的方法,此时若是窗口并无 rootViewController 的话,那系统问也白问,因此蓝色窗口并不会劫持状态栏和横屏事件。
  • 若是此时蓝色窗口有 rootViewController 的话,那么该控制的返回值就会决定设备的方向。也就形成了这个 bug。

04.补充更新

又发现了一个新的 bug,如今补充一下,若是咱们的应用是竖屏的,只是某些界面须要横屏,那么若是咱们把项目的 info.plist 的横竖屏所有都打开的话,就像下面这样:

那么对于 plus 手机的话,若是你是横着手机打开 APP,那么首个界面确定是会 UI 错乱的,由于这个时候 APP 还没启动完,系统会根据咱们 info.plist 的配置进行初始化,因此会致使这个 bug。

如今解决方式是这样的, info.plist 咱们仍然这样写,以保证刚启动 APP 的时候不至于 UI 错乱。

image.png

接下来,咱们来到 AppDelegate 里返回支持的横屏方向,就像下面这么写,功能和在 info.plist 也是同样的。

- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window{
    return self.window.rootViewController.supportedInterfaceOrientations;
}
复制代码

05.最后

最后 GitHub 地址在这里 BLLandscape

NewPan 的文章集合

下面这个连接是我全部文章的一个集合目录。这些文章凡是涉及实现的,每篇文章中都有 Github 地址,Github 上都有源码。

NewPan 的文章集合索引

若是你有问题,除了在文章最后留言,还能够在微博 @盼盼_HKbuy 上给我留言,以及访问个人 Github

相关文章
相关标签/搜索