前段时间负责项目横屏适配,做屏幕旋转时遇到了很多问题,在这里梳理一下,主要包括以下内容:
1.屏幕旋转控制优先级
2.屏幕旋转中横竖屏方向枚举说明
3.触发开启屏幕旋转
4.旋转后页面适配问题总结(原生,Flutter)
5.旋转方向不一致引发崩溃 'preferredinterfaceorientationforpresentation 'landscaperight' must match a supported interface orientation: 'portrait'!
一 屏幕旋转控制优先级
这里优先级:工程Target配置(全局权限)= Appdelegate > 根视图控制器 > 普通视图控制器
1. 工程Target配置
在项目中直接勾选设置,项目中只要求iPad适配横竖屏,iPhone只支持竖屏,这里和info.plist中设置是同步的,一边更改另外一个地方也会跟着改变
2. Appdelegate中设置
正常情况下,APP从Appdelegate中启动,这里的设置也是全局有效的。
- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window {
LogI(@"appdelegate", @"supportedInterfaceOrientationsForWindow");
return UIInterfaceOrientationMaskAll;
}
注意:
1.如果实现了这里的方法,全局旋转设置将以这里为准,如果工程Target配置了全部方向,但是这里设置的只支持竖屏,那么只能支持竖屏。
2.如果项目要求从启动就默认是横屏,跟随设备方向,就需要设置工程Target,如果不要求,那么只设置Appdelegate中就可以
3. 开启屏幕旋转的局部权限(视图控制器)
这里视图控制器分为三种:
1.UITabbarViewController
2.UINavigationController
3.UIViewController
上面全局所支持的方向设置后,就要设置下面的局部权限,这里权限最高的是window的根视图控制器。一般情况下都是用UITabbarViewController作为根视图控制器,管理着多个导航控制器,然后由导航控制器管理普通的视图控制器UIViewController
所以这里的优先级是:
UITabbarViewController > UINavigationController > UIViewController
如果高优先级关闭了旋转设置,那么低优先级的控制器是无法旋转的。
另外还需要注意模态视图
模态视图不受这种根视图控制器优先级的限制,因为是隔离出来的,具体设置和普通视图代码相同,如果有UINavigationController需要注意设置UINavigationController
二 屏幕旋转中横竖屏方向枚举
在说明后续的触发屏幕旋转方法前,先说下屏幕旋转中要用到的方向枚举
这里方向枚举一共有三种:UIDeviceOrientation,UIInterfaceOrientation,UIInterfaceOrientationMask。
1. UIDeviceOrientation
UIDeviceOrientation表示设备方向,是指硬件设备本身的当前旋转方向,共有以下七种,设备方向只能取值,不能设置
**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
} API_UNAVAILABLE(tvOS);
获取设备当前的旋转方向使用:
UIDevice.currentDevice.orientation
2. UIInterfaceOrientation
UIInterfaceOrientation是开发的程序界面的当前选择方向,是可以设置的,一共有五种类型:
**typedef** NS_ENUM(NSInteger, UIInterfaceOrientation) {
UIInterfaceOrientationUnknown = UIDeviceOrientationUnknown,
UIInterfaceOrientationPortrait = UIDeviceOrientationPortrait,
UIInterfaceOrientationPortraitUpsideDown = UIDeviceOrientationPortraitUpsideDown,
UIInterfaceOrientationLandscapeLeft = UIDeviceOrientationLandscapeRight,
UIInterfaceOrientationLandscapeRight = UIDeviceOrientationLandscapeLeft
} API_UNAVAILABLE(tvOS);
这里需要注意的是左右旋转时是UIInterfaceOrientationLandscapeLeft
与UIDeviceOrientationLandscapeRight
相等,反之亦然,这是因为向左旋转设备需要旋转程序界面右边的内容。
状态栏的方向和这个有关,可以通过以下方法获取
UIInterfaceOrientation interfaceOrientation = [[UIApplication sharedApplication] statusBarOrientation];
3. UIInterfaceOrientationMask
UIInterfaceOrientationMask是iOS6之前增加的一种枚举,也是表示页面方向,是为了集成多种UIInterfaceOrientation而定义的类型
**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),
} API_UNAVAILABLE(tvOS);
三 触发开启屏幕旋转
控制页面的旋转我们一般通过以下三个方法来控制
1. shouldAutorotate 当前界面是否开启自动转屏
2. preferredInterfaceOrientationForPresentation 返回进入界面默认的显示方向
3. supportedInterfaceOrientations 返回支持的旋转方向,这里支持的旋转方向必须包含进入界面默认的显示方向,要不旋转中会报错
在16以下版本shouldAutorotate决定当前界面是否开启自动转屏,如果返回未NO,后面的两个方法也不会调用,但是需要注意的是在16及以上系统,这里的API有较大的变化,需要特别注意,shouldAutorotate方法在16及以上系统会不起作用,而是需要控制supportedInterfaceOrientations方法才有效果,系统中代码注释如下。
// Applications should use supportedInterfaceOrientations and/or shouldAutorotate.
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation API_DEPRECATED("", ios(2.0, 6.0)) API_UNAVAILABLE(tvOS);
上面我们说到了旋转的优先级,根据这个原理我们可以逐级设置各视图控制器,让高优先级跟随低优先级控制器的旋转配置。
UITabbarViewController
中设置:
- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation {
return [self.selectedViewController preferredInterfaceOrientationForPresentation];
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
return [self.selectedViewController supportedInterfaceOrientations];
}
- (BOOL)shouldAutorotate {
return [self.selectedViewController shouldAutorotate];
}
导航控制器 UINavigationController
中设置:
// 控制 vc present进来的横竖屏和进入方向 ,支持的旋转方向必须包含该返回值的方向
- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation {
return [[self.viewControllers lastObject] preferredInterfaceOrientationForPresentation];
}
- (BOOL)shouldAutorotate {
return [[self.viewControllers lastObject] shouldAutorotate];
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
return [[self.viewControllers lastObject] supportedInterfaceOrientations];
}
UIViewController
中设置(开启方向控制):
- (BOOL)shouldAutorotate {
return YES;
}
- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation {
return UIInterfaceOrientationPortrait;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
return UIInterfaceOrientationMaskAll;
}
强制旋转
屏幕旋转分为两种,一种是跟随设备旋转方向自动旋转,一种是强制旋转。在iOS16以下,强制旋转可以直接通过[[UIDevice currentDevice] setValue:@(orientation) forKey:@"orientation"];来设置,但是在16以上会失效,所以需要添加版本兼容。iOS16新增了一个方法,在你想要转屏的时候需要通知UIViewController 表示你已经准备好要转屏的方向了,然后再调用转屏方法,这里用强制旋转的时候最好是升级xcode14,有些系统api方法在xcode14以下没有提供。
/// Notifies the view controller that a change occurred that affects supported interface orientations or the preferred interface orientation for presentation.
/// By default, this will animate any changes to orientation. To perform a non-animated update, call within `[UIView performWithoutAnimation:]`.
- (void)setNeedsUpdateOfSupportedInterfaceOrientations API_AVAILABLE(ios(16.0));
强制旋转方法
+ (void)forceUIDeviceOrientation:(UIDeviceOrientation)orientation {
if (orientation == UIDeviceOrientationUnknown) {
orientation = UIDeviceOrientationPortrait;
}
if (@available(iOS 16.0, *)) {
[[EduAppUtil topMostController] setNeedsUpdateOfSupportedInterfaceOrientations];
NSArray *array = [[[UIApplication sharedApplication] connectedScenes] allObjects];
if (array.count == 0) {
return;
}
id firstObject = [array firstObject];
if (![firstObject isKindOfClass:[UIWindowScene class]]) {
return;
}
UIWindowScene *scene = (UIWindowScene *)firstObject;
UIWindowSceneGeometryPreferencesIOS *geometryPreferences = [[UIWindowSceneGeometryPreferencesIOS alloc] init];
geometryPreferences.interfaceOrientations = orientation;
[scene requestGeometryUpdateWithPreferences:geometryPreferences errorHandler:^(NSError *_Nonnull error) {
//业务代码
}];
} else {
[UIViewController attemptRotationToDeviceOrientation];
[[UIDevice currentDevice] setValue:@(orientation) forKey:@"orientation"];
}
}
四 旋转后页面适配
屏幕旋转后就要考虑页面的适配问题,横屏和竖屏下界面需要重新调整视图布局。
1. 先说下原生这边的适配
1.1 使用Masnory自动布局
如果项目中页面是使用Masonry/SnapKit来做页面布局的,当发生屏幕旋转时不需要重新调整布局。推荐后续新页面尽量都是用自动布局的形式
1.2 使用frame布局
项目中的页面几乎全是使用frame来布局的,接下来说下这部分的适配,这里可以采用Autoresizing布局的形式,除此之外,VC中有三种方法调用会在屏幕旋转后触发,可以在这里实现逻辑。
Autoresizing
Autoresizing是苹果早期的布局适配方法,但是注意它只能相对父控件
布局。因为项目中大部分都是frame布局,父控件改变后,子控件可以使用这种方式随着更改,但是需要注意不要使用不变的屏幕宽高来计算设置。
Autoresizing一共有6种枚举值,可以组合使用。但是比较局限,根据实际情况使用。
typedef NS_OPTIONS(NSUInteger, UIViewAutoresizing) {
UIViewAutoresizingNone = 0,
UIViewAutoresizingFlexibleLeftMargin = 1 << 0,
UIViewAutoresizingFlexibleWidth = 1 << 1,
UIViewAutoresizingFlexibleRightMargin = 1 << 2,
UIViewAutoresizingFlexibleTopMargin = 1 << 3,
UIViewAutoresizingFlexibleHeight = 1 << 4,
UIViewAutoresizingFlexibleBottomMargin = 1 << 5
};
项目中的使用
_videoView.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleLeftMargin;
VC中有三种方法在屏幕旋转后会触发,可以在这里做自己的相关操作
1当设备方向发生变化时会触发UIDeviceOrientationDidChangeNotification
通知方法,我们可以通过监听通知在这里做相关的操作
//监听屏幕旋转通知
[EduNotificationObserver observeName:UIDeviceOrientationDidChangeNotification target:self action:@selector(deviceOrientationChanged:) object:nil];
监听到通知后操作,这里要通过[UIDevice currentDevice].orientation
获取设备方向,根据方向来做对应的设置
- (void)deviceOrientationChanged:(NSNotification *)notification {
//获取设备当前方向
UIDevice *device = [UIDevice currentDevice];
if (UIDeviceOrientationIsPortrait(device.orientation)) {
//竖屏
} else if (UIDeviceOrientationIsLandscape(device.orientation)) {
//横屏
} else if (device.orientation == UIDeviceOrientationFaceUp) {
//屏幕朝上平躺
} else if (device.orientation == UIDeviceOrientationFaceDown) {
//屏幕朝下平躺
}
}
2. 通过 viewWillTransitionToSize
方法,通过注释说明可以看到当屏幕旋转或者大小发生改变时触发
/*
This method is called when the view controller's view's size is changed by its parent (i.e. for the root view controller when its window rotates or is resized).
If you override this method, you should either call super to propagate the change to children or manually forward the change to children.
*/
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator API_AVAILABLE(ios(8.0));
3. 通过viewWillLayoutSubviews
方法
这个方法中也可以监听到方向的变化来做view的改变,但是使用时需要慎重,因为会调用多次,可能拉垮程序,如果要使用,建议添加view的size是否更改,保证只在需要的时候调用
1.3 项目中在做布局时,通常会使用这几种size来做对应的更新布局:self.view.frame.size.width
,SCREEN_WIDTH
,SCREEN_WIDTH_ORI
,UIScreen.mainScreen.bounds.size.width
,在上面不同的方法中,这几种size的大小也会有不同,而且在16以上和16以下版本中表现也不一样,通过日志打印来看一下
方法调用
-(void)viewWillLayoutSubviews
{
[super viewWillLayoutSubviews];
NSLog(@"屏幕发生旋转==viewWillLayoutSubviews==%f==%d==%d==%f",self.view.frame.size.width,SCREEN_WIDTH,SCREEN_WIDTH_ORI,UIScreen.mainScreen.bounds.size.width);
}
-(void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator
{
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
NSLog(@"屏幕发生旋转==viewWillTransitionToSize==%f==%d==%d==%f",self.view.frame.size.width,SCREEN_WIDTH,SCREEN_WIDTH_ORI,UIScreen.mainScreen.bounds.size.width);
}
//处理监听屏幕旋转通知
- (void)handleDeviceOrientationDidChange:(NSNotification *)notification {
NSLog(@"屏幕发生旋转==handleDeviceOrientationDidChange==%f==%d==%d==%f",self.view.frame.size.width,SCREEN_WIDTH,SCREEN_WIDTH_ORI,UIScreen.mainScreen.bounds.size.width);
}
16及以上系统(该设备竖屏下,宽:810,高:1080)
竖屏旋转到横屏打印结果如下:
2023-01-18 18:56:24.797152+0800 Test[12171:3262879] 屏幕发生旋转==handleDeviceOrientationDidChange==810.000000==810==1080==810.000000
2023-01-18 18:56:24.797950+0800 Test[12171:3262879] 屏幕发生旋转==viewWillTransitionToSize==810.000000==810==1080==1080.000000
2023-01-18 18:56:24.814537+0800 Test[12171:3262879] 屏幕发生旋转==viewWillLayoutSubviews==1080.000000==810==1080==1080.000000
横屏转竖屏:
2023-01-18 18:58:19.281700+0800 Test[12171:3262879] 屏幕发生旋转==handleDeviceOrientationDidChange==1080.000000==810==810==1080.000000
2023-01-18 18:58:19.282561+0800 Test[12171:3262879] 屏幕发生旋转==viewWillTransitionToSize==1080.000000==810==810==810.000000
2023-01-18 18:58:19.293358+0800 Test[12171:3262879] 屏幕发生旋转==viewWillLayoutSubviews==810.000000==810==810==810.000000
16以下系统(该设备竖屏下,宽:768,高:1024)
竖屏旋转到横屏打印结果如下:
2023-01-19 13:24:19.248161+0800 Test[829:30050] 屏幕发生旋转==viewWillTransitionToSize==768.000000==768==768==768.000000
2023-01-19 13:24:19.296882+0800 Test[829:30050] 屏幕发生旋转==viewWillLayoutSubviews==1024.000000==768==1024==1024.000000
r2023-01-19 13:24:19.368117+0800 Test[829:30050] 屏幕发生旋转==handleDeviceOrientationDidChange==1024.000000==768==1024==1024.000000
横屏转竖屏:
2023-01-19 13:24:43.075147+0800 Test[829:30050] 屏幕发生旋转==viewWillTransitionToSize==1024.000000==768==1024==1024.000000
2023-01-19 13:24:43.101307+0800 Test[829:30050] 屏幕发生旋转==viewWillLayoutSubviews==768.000000==768==768==768.000000
2023-01-19 13:24:43.164203+0800 Test[829:30050] 屏幕发生旋转==handleDeviceOrientationDidChange==768.000000==768==768==768.000000
总结:
1. 从上述可以看出三个方法中当发生屏幕旋转时SCREEN_WIDTH不会发生变化,尽量不要使用这个和SCREEN_HEIGHT,如果需要固定值的话可以使用,比如设置整个全屏横屏播放器的宽高,其他情况下尽量不要使用(项目中之前使用的地方很多)。
2. self.view.frame.size和UIScreen.mainScreen.bounds.size在屏幕发生变化的时候会更新,但是在上面的三个方法中表现不同,16和16以下差异较大,使用起来还是要格外注意。除了SCREEN_WIDTH外的三个值,在viewWillLayoutSubviews
方法中都已经切换为旋转后的值,在handleDeviceOrientationDidChange
通知监听中,16以下版本也都切换为旋转后的值,在16及以上的版本中,除了SCREEN_WIDTH_ORI切换成功后,其他都较慢,所以使用时需要根据情况选择对应的方法具体的时机,是个坑点。
1.4 补充
除了VC外,还有一些view的更新适配,比如自定义的view,cell,这时候需要用到layoutSubviews
方法,当屏幕旋转时会触发这个方法,可以在这里做一些更新操作
- (void)layoutSubviews {
[super layoutSubviews];
}
另外还有layer的更新,这里有一个坑点,layer不会自动随着view的更新而更新,需要我们另外操作,即当图层的bounds发生改变的时候,layoutSublayers
方法会被调用,我们可以在这里重新调整图层的大小,而不能像view的autoresizingMask和constraints属性做到自适应屏幕旋转,不过这个只有view才有这个方法,如果是VC,同上述说明的三种方法,在屏幕旋转时重新设置图层的bounds
override func layoutSublayers(of layer: CALayer) {
super.layoutSublayers(of: layer)
//ipad屏幕旋转造成宽度改变时刷新layer图层
if (iPadAdaptLandscapeIsOpen()){
let bounds = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: self.bounds.height)
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: [UIRectCorner.topLeft, UIRectCorner.topRight], cornerRadii: CGSize(width: 12, height: 12))
self.contentView.layer.mask?.frame = bounds
let maskLayer = self.contentView.layer.mask as? CAShapeLayer
maskLayer?.path = path.cgPath
}
}
代码说明注释
:
#define SCREEN_WIDTH [UIScreenHelper screenWidth]
#define SCREEN_WIDTH_ORI [UIScreenHelper screenWidthOri] //支持横屏竖屏
#define SCREEN_HEIGHT [UIScreenHelper screenHeight]
#define SCREEN_HEIGHT_ORI [UIScreenHelper screenHeightOri] //支持横屏竖屏
+ (int)screenWidth {
static int s_scrWidth = 0;
if (s_scrWidth == 0) {
CGRect screenFrame = [UIScreen mainScreen].bounds;
s_scrWidth = MIN(screenFrame.size.width, screenFrame.size.height);
}
return s_scrWidth;
}
+ (int)screenHeight {
static int s_scrHeight = 0;
if (s_scrHeight == 0) {
CGRect screenFrame = [UIScreen mainScreen].bounds;
s_scrHeight = MAX(screenFrame.size.width, screenFrame.size.height);
}
return s_scrHeight;
}
+ (int)screenWidthOri {
static int s_scrWidth = 0;
CGRect screenFrame = [UIScreen mainScreen].bounds;
UIInterfaceOrientation interfaceOrientation = [[UIApplication sharedApplication] statusBarOrientation];
if (UIInterfaceOrientationIsLandscape(interfaceOrientation)) {
//横屏
s_scrWidth = MAX(screenFrame.size.width, screenFrame.size.height);
if (isIPad() && [EduChatViewLayoutMgr shareInstance].isiPadSidebar) {
s_scrWidth = 375;
}
} else {
s_scrWidth = MIN(screenFrame.size.width, screenFrame.size.height);
}
return s_scrWidth;
}
+ (int)screenHeightOri {
static int s_scrHeight = 0;
CGRect screenFrame = [UIScreen mainScreen].bounds;
UIInterfaceOrientation interfaceOrientation = [[UIApplication sharedApplication] statusBarOrientation];
if (UIInterfaceOrientationIsLandscape(interfaceOrientation)) {
//横屏
s_scrHeight = MIN(screenFrame.size.width, screenFrame.size.height);
} else {
s_scrHeight = MAX(screenFrame.size.width, screenFrame.size.height);
}
return s_scrHeight;
}
2. Flutter旋转适配
Flutter工程中,当应用尺寸改变的时候会触发didChangeMetrics
方法,屏幕旋转时可以在这里做相应的更新操作。
混入WidgetsBindingObserver
class MiniCourseDetailCoverWidgetState
extends State<MiniCourseDetailCoverWidget> with WidgetsBindingObserver {
添加观察者
@override
void initState() {
super.initState();
_initCoverSize();
WidgetsBinding.instance?.addObserver(this);
}
在这里更新对应的size布局,不需要setState,会自动调用
@override
void didChangeMetrics() {
super.didChangeMetrics();
_initCoverSize();
}
最后记得要移除
@override
void dispose() {
super.dispose();
WidgetsBinding.instance?.removeObserver(this);
}
五 旋转方向不一致引发常见崩溃
在做横竖屏切换的时候,有一个crash,之前开发时没有留意到
'preferredinterfaceorientationforpresentation 'landscaperight' must match a supported interface orientation: 'portrait'!
因为supportedInterfaceOrientations
和 preferredInterfaceOrientationForPresentation
返回内容不符,特别是preferredInterfaceOrientationForPresentation
返回的是当前状态栏的方向UIApplication.shared.statusBarOrientation
时,比如当前页面方向仅支持竖屏,模态弹出一个横屏页面,当退出模态,也就是调用dismiss的时候,会调用preferredInterfaceOrientationForPresentation
方法,横屏模态还没完全旋转到当前竖屏页面时,当前状态栏方向为landscaperight,supportedInterfaceOrientations返回仅支持竖屏,这时候就会崩溃。
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
return UIInterfaceOrientationMaskPortrait;
}
有两种修复方式,一种是在调用横屏的dismiss方法时,更新当前竖屏页面的supportedInterfaceOrientations
方法,但是这种有个问题是如果调起的方法在其他工具类里时要记录这个状态可能有些麻烦,也可以使用第二种方式,修改preferredinterfaceorientationforpresentation
方法,竖屏页面仅设置当前preferredinterfaceorientationforpresentation
方法返回的是UIInterfaceOrientationMaskPortrait也可修复。