iOS 屏幕旋转的那些事(二)

前言

iOS 屏幕旋转的那些事(一)一文中,整个实践的过程都比较顺利,但直到我遇到了 FLEX……


迷之 FLEX

如果你对 FLEX 还不了解,可参见:https://github.com/Flipboard/FLEX

由于在 iPad 版的某些页面,需要以 Modal View 的形式展现,其中又涉及到横竖屏切换时,Modal View 的高度相应改变的问题,所以我需要获取到 Screen Width 与 Screen Height 的值。效果如下图所示:

于是,我在 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator; 中执行如下代码:

1
2
3
4
5
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
CGFloat screenHeight = [UIScreen mainScreen].bounds.size.height;
}

你觉得此时 screenWidthscreenHeight 的值是屏幕旋转前还是旋转后的呢?

顺便说一句:iOS 7 之前 UIScreen 的 bounds 不会随着方向而变化,但是到了 iOS 8 以后,随着设备方向的旋转,[UIScreen mainScreen].bounds.size.width[UIScreen mainScreen].bounds.size.height 也会相应发生变化。具体请参见: Is [UIScreen mainScreen].bounds.size becoming orientation-dependent in iOS8?

最终经过测试后的答案是:要看 FLEX 使用声明语句的位置而定!

FLEX 的使用非常简单,我们来看下 AppDelegate.m 中的 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(nullable NSDictionary *)launchOptions;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.backgroundColor = [UIColor whiteColor];
[self.window makeKeyAndVisible];

QTViewController *viewController = [[QTViewController alloc] init];
viewController.title = @"QTViewController";
UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:viewController];

// 在此处及之前开启,则在 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator 中使用 [UIScreen mainScreen].bounds.size 获取的为旋转后的值。
[[FLEXManager sharedManager] showExplorer];
// !!!:
self.window.rootViewController = navigationController;
// 在此处及之后开启,则在 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator 中使用 [UIScreen mainScreen].bounds.size 获取的为旋转前的值。
[[FLEXManager sharedManager] showExplorer];

随后,我又改进了写法,进行进一步测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];

NSLog(@"Before: %f", [UIScreen mainScreen].bounds.size.width);
NSLog(@"Before: %f", [UIScreen mainScreen].bounds.size.height);

[coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {

NSLog(@"After: %f", [UIScreen mainScreen].bounds.size.width);
NSLog(@"After: %f", [UIScreen mainScreen].bounds.size.height);
}
completion:^(id<UIViewControllerTransitionCoordinatorContext> context){

}];
}

最终的测试结果为:

是否开启 FLEX 位置 Before 处的 Size After 处的 Size
旋转前 旋转后
self.window.rootViewController = xxx 之后 旋转前 旋转后
self.window.rootViewController = xxx 之前 旋转后 旋转后

所以,如果你真的需要在屏幕旋转时获取 Screen Size,请将操作放在 After 中,因为此时无论 FLEX 是否开启,也无论声明的位置,其都是获取到屏幕旋转之后的值。或将 FLEX 的使用声明语句放到 self.window.rootViewController = xxx 之后。

虽然已经可以解决问题了,但我仍有疑问,那就是「FLEX 到底做了什么,导致在 self.window.rootViewController = xxx 之前 声明,会改变 Before 处的 Size?」

于是,我们顺着 [[FLEXManager sharedManager] showExplorer]; 查看源码中的调用顺序:

1
2
3
4
5
6
7
8
9
+ (instancetype)sharedManager
{
static FLEXManager *sharedManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedManager = [[[self class] alloc] init];
});
return sharedManager;
}
1
2
3
4
- (void)showExplorer
{
self.explorerWindow.hidden = NO;
}
1
2
3
4
5
6
7
8
9
10
11
12
- (FLEXWindow *)explorerWindow
{
NSAssert([NSThread isMainThread], @"You must use %@ from the main thread only.", NSStringFromClass([self class]));

if (!_explorerWindow) {
_explorerWindow = [[FLEXWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
_explorerWindow.eventDelegate = self;
_explorerWindow.rootViewController = self.explorerViewController;
}

return _explorerWindow;
}

接下来,我们来看下 FLEXWindow 是如何实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
self.backgroundColor = [UIColor clearColor];
// Some apps have windows at UIWindowLevelStatusBar + n.
// If we make the window level too high, we block out UIAlertViews.
// There's a balance between staying above the app's windows and staying below alerts.
// UIWindowLevelStatusBar + 100 seems to hit that balance.
self.windowLevel = UIWindowLevelStatusBar + 100.0;
}
return self;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
BOOL pointInside = NO;
if ([self.eventDelegate shouldHandleTouchAtPoint:point]) {
pointInside = [super pointInside:point withEvent:event];
}
return pointInside;
}

- (BOOL)shouldAffectStatusBarAppearance
{
return [self isKeyWindow];
}

- (BOOL)canBecomeKeyWindow
{
return [self.eventDelegate canBecomeKeyWindow];
}

+ (void)initialize
{
// This adds a method (superclass override) at runtime which gives us the status bar behavior we want.
// The FLEX window is intended to be an overlay that generally doesn't affect the app underneath.
// Most of the time, we want the app's main window(s) to be in control of status bar behavior.
// Done at runtime with an obfuscated selector because it is private API. But you shoudn't ship this to the App Store anyways...
NSString *canAffectSelectorString = [@[@"_can", @"Affect", @"Status", @"Bar", @"Appearance"] componentsJoinedByString:@""];
SEL canAffectSelector = NSSelectorFromString(canAffectSelectorString);
Method shouldAffectMethod = class_getInstanceMethod(self, @selector(shouldAffectStatusBarAppearance));
IMP canAffectImplementation = method_getImplementation(shouldAffectMethod);
class_addMethod(self, canAffectSelector, canAffectImplementation, method_getTypeEncoding(shouldAffectMethod));

// One more...
NSString *canBecomeKeySelectorString = [NSString stringWithFormat:@"_%@", NSStringFromSelector(@selector(canBecomeKeyWindow))];
SEL canBecomeKeySelector = NSSelectorFromString(canBecomeKeySelectorString);
Method canBecomeKeyMethod = class_getInstanceMethod(self, @selector(canBecomeKeyWindow));
IMP canBecomeKeyImplementation = method_getImplementation(canBecomeKeyMethod);
class_addMethod(self, canBecomeKeySelector, canBecomeKeyImplementation, method_getTypeEncoding(canBecomeKeyMethod));
}

这里,也许你需要一点关于 UIWindow 的相关知识。简言之,就是将 FLEXWindow 置于整个 View Hierarchy 的最顶部,如下图所示:

图片来源

那 FLEX 又在屏幕旋转时做了哪些操作呢?首先我们需要了解下整体响应旋转变化的事件流程,简单来说如下:

1
UIScreen -> UIWindow -> UIViewController -> ChildViewControllers -> View -> Subviews

当发生屏幕旋转事件的时候,UIApplication 对象会将旋转事件传递给 UIWindowUIWindow 本身并不处理旋转事件,而是将旋转事件传递给它的根控制器,由根控制器决定是否需要旋转及旋转操作。

最后,我们来看下 FLEXExplorerViewController 中关于 Rotation 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#pragma mark - Rotation

- (UIViewController *)viewControllerForRotationAndOrientation
{
UIWindow *window = self.previousKeyWindow ?: [[UIApplication sharedApplication] keyWindow];
UIViewController *viewController = window.rootViewController;
NSString *viewControllerSelectorString = [@[@"_vie", @"wContro", @"llerFor", @"Supported", @"Interface", @"Orientations"] componentsJoinedByString:@""];
SEL viewControllerSelector = NSSelectorFromString(viewControllerSelectorString);
if ([viewController respondsToSelector:viewControllerSelector]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
viewController = [viewController performSelector:viewControllerSelector];
#pragma clang diagnostic pop
}
return viewController;
}

- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
UIViewController *viewControllerToAsk = [self viewControllerForRotationAndOrientation];
UIInterfaceOrientationMask supportedOrientations = [FLEXUtility infoPlistSupportedInterfaceOrientationsMask];
if (viewControllerToAsk && viewControllerToAsk != self) {
supportedOrientations = [viewControllerToAsk supportedInterfaceOrientations];
}

// The UIViewController docs state that this method must not return zero.
// If we weren't able to get a valid value for the supported interface orientations, default to all supported.
if (supportedOrientations == 0) {
supportedOrientations = UIInterfaceOrientationMaskAll;
}

return supportedOrientations;
}

- (BOOL)shouldAutorotate
{
UIViewController *viewControllerToAsk = [self viewControllerForRotationAndOrientation];
BOOL shouldAutorotate = YES;
if (viewControllerToAsk && viewControllerToAsk != self) {
shouldAutorotate = [viewControllerToAsk shouldAutorotate];
}
return shouldAutorotate;
}

- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
{
for (UIView *outlineView in [self.outlineViewsForVisibleViews allValues]) {
outlineView.hidden = YES;
}
self.selectedViewOverlay.hidden = YES;
}

- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation
{
for (UIView *view in self.viewsAtTapPoint) {
NSValue *key = [NSValue valueWithNonretainedObject:view];
UIView *outlineView = self.outlineViewsForVisibleViews[key];
outlineView.frame = [self frameInLocalCoordinatesForView:view];
if (self.currentMode == FLEXExplorerModeSelect) {
outlineView.hidden = NO;
}
}

if (self.selectedView) {
self.selectedViewOverlay.frame = [self frameInLocalCoordinatesForView:self.selectedView];
self.selectedViewOverlay.hidden = NO;
}
}

不过,到目前为止,仍然不能解决我的疑问(瞬间感觉自己弱爆了……)如果你正巧知道原因,欢迎给我留言:)

如果你想对 FLEX 有更深入的了解,可参见系列文章:FLEX 2.0源码分析(一)


One More Thing

好啦,对于已经阅读到这部分的朋友,作为回报,送上两个 iPad 适配相关的 Tips:

  1. UICollectionViewFlowLayout Size Warning When Rotating Device to Landscape
  2. Custom size for Modal View loaded with Form Sheet presentation

参考&推荐

  1. What is the “right” way to handle orientation changes in iOS 8?
  2. 详解iOS开发中处理屏幕旋转的几种方法
  3. FLEX 2.0源码分析(一)
  4. 浅谈iOS的多Window处理
我知道是不会有人点的,但万一有人想不开呢?