好奇心日报iPad版的分屏适配实现
好奇心日报是一个媒体阅读类app,因此不可能放弃iPad这个平台,恰逢iPad pro上线,我们决定开发iPad版本并直接适配iOS9的新特性----分屏。
文章中提到的适配方案,思路基础源于微信iOS客户端的ui处理,特此感谢微信,感谢曾经在广研工作的日子。
写在前面
Apple在iOS9中针对iPad做了一些多任务处理模式,其中分屏模式为“Slide Over”和“Split View”。
- Slide Over支持全部iPad版本,支持其功能的app会在其它app在前台的情况下,通过侧滑右边,获得一个宽度为320(iPad Pro为375)的空间用于展示。这种为伪多任务,最终其实也只能处理一个app。样式如下:
-
Split View才是我们所理解的真正应用程序分屏功能,可以将屏幕分成大约3:1,2:2,2:1等多种情况(我们遍历宽度,发现一共12种...)。支持的机型现有3中,就是iPad pro,iPad air2,iPad mini4。样式如下
通过调研,我们发现通过iPad分屏,以及转屏切换等操作,我们开发者将面对的app屏幕宽度足足有11种之多:
iPad pro : 1366,1024,981,678,639,375
iPad air : 1024,768,694,507,438,320
其中宽度相差很大,可以和混乱的Android市场一拼,写死frame的方式肯定不行,单纯的采用autolayout布局的方式也不可能满足如此跨度宽度的需求。市面上和我们比较类似的应用,同时支持了split view的只发现了豌豆荚一览,参考他们的适配风格,发现他们是1行,在不同情况下只需要更改间距即可。下图是豌豆荚一览的图片,可以看出他们针对split view的适配是比较舒服而且比较简单的
好奇心日报的iPhone版本是一种2列卡片形式的瀑布流,相应的,iPad版本预计也设计成卡片形式,这样我们面对的适配情况要比两列复杂的多,那么确定适配方案将会是最重要的部分。下图是好奇心日报iPhone版本和iPad版本视觉初稿,我们预计要搞成这个样子:
适配方案
即使采用autolayout,split view切分过程中依然要修改其约束值,几套不同参数的适配值不可避免。
11种屏幕方案,UI同学不可能出11套视觉稿
,所以我们根据宽度和机型不同,将这11种情况划分3个区间:
4列情况:1366,1024,981,768(非mini)
3列情况:768(mini),694,478,639
2列情况:407,438,375,320
UI同学只需要根据出上述3中情况最大尺寸的视觉稿,我们开发根据最大视觉稿做向下兼容适配。在同一个配置文件下,根据宽度的不同,可以让其中的一些间距、字体不变或者等比压缩。
在这种混乱屏幕的适配方面,web页面以及Android的app在这方面远远走在了iOS的前面。在吸取两者优点的情况下,我们最终采用方案
是基于css的配置模式,每个css中可以定义当前模式下的间距,颜色,以及字体等与UI相关的参数。具体:
定义了3中css,style~iPad4Column.css、style~iPad3Column、style~iPad2Column.css,并约定style~iPad4Column.css为其基础配置。即通过一个theme管理层,使其在2列的情况下先读取2Column.css的数值,如果该值不存在,读取基础类4Column.css的值,3列情况于此相同。
而且,在css参数内部,定义了一个dynamic属性,使其能够根据宽度自由放缩。例如在4Column.css中定义了一个”1366 dynamic“的值,由于1366、1024、981都会读取该配置,但是却在这三种情况下分别获得的值为1366、1024和981,实现了等比放缩。
总结,方案基本就是代码层采用autolayout(iOS方案),theme层用3套配置文件(Android方案---xml)+等比放缩(web方案,css百分比),通过这种方案,基本能够满足我们对多屏幕适配的要求。
下面贴一点css部分的代码:
通过这种配置文件,以后老板如果说,2列下面这个字体有点大吧,我们就不需要到无限的m文件里面去痛苦的查找,直接找到这里改掉就好了,而且编包不需要重新编译,超快~
针对css文件的解析,我是在facebook开源出来的react Native中找到了其中的css解析文件,做了一些适应性修改,拿来用的,文件名如下图,自己去找:
再贴一部分theme层的代码,这一部分通过css的配置文件将相同的宏变量在不同的宽度下映射出不同的值:
ThemeMgr.m
//loadcss部分,将3套资源load成3个dictionary,最终的值是一个NSArray对象
- (void)loadCSSStyle{
NSString *bundleRoot = [[NSBundle mainBundle] bundlePath];
NSString *path = [bundleRoot stringByAppendingPathComponent:THEME_STYLE_DEFINE_FILE];
NSDictionary* parentDic = nil;
NSString* column2Path = [[[path stringByDeletingPathExtension] stringByAppendingString:THEME_RES_PATH_2COLUMN_SUBFIX] stringByAppendingPathExtension:@"css"];;
parentDic = [[[NICSSParser alloc] init] dictionaryForPath:column2Path];
[self rebuildThemeDictionaryWithThemeDictionary:parentDic toDictionary:_2ColumnThemeDic];
...重复代码,省略...
}
//根据css的key获取其值,我们定义了一个方法[DeviceInfo iPadColumn]来确认当前需要读取的配置文件。
- (NSArray*)getValueOfProperty:(NSString*)property forSeletor:(NSString *)selector
{
NSArray* result = nil;
int column = [DeviceInfo iPadColumn];
if (column == 2) {
NSDictionary* child2ColumnThemeDic = [_2ColumnThemeDic objectForKey:selector];
if (child2ColumnThemeDic) {
result = [child2ColumnThemeDic objectForKey:property];
}
result = [self constantsValue:result];
}
if (result && result.count > 0) {
return result;
}
if (column == 3) {
NSDictionary* child3ColumnThemeDic = [_3ColumnThemeDic objectForKey:selector];
if (child3ColumnThemeDic) {
result = [child3ColumnThemeDic objectForKey:property];
}
result = [self constantsValue:result];
}
if (result && result.count > 0) {
return result;
}
//2和3没找到,默认走4
NSDictionary* child4ColumnThemeDic = [_4ColumnThemeDic objectForKey:selector];
if (child4ColumnThemeDic) {
result = [child4ColumnThemeDic objectForKey:property];
}
result = [self constantsValue:result];
return result;
}
ThemeUtil.m
//将传入数组转成对应UIFont
+(UIFont*) parseFontFromValues:(NSArray*)value
{
if (value==nil||[value count]==0) {
return [UIFont systemFontOfSize:[UIFont systemFontSize]];
}
NSInteger fontSize = [[value firstObject] integerValue];
if (fontSize<=5) {
fontSize = 5;
}
if ([value count]==1) {
return [UIFont systemFontOfSize:fontSize];
}
if ([[value lastObject] isEqualToString:@"dynamic"]) {
fontSize = (int) (fontSize * [DeviceInfo iPadThemeScale]);
}
if ([value count]==2) {
if ([[value objectAtIndex:1] isEqualToString:@"bold"]) {
return [UIFont boldSystemFontOfSize:fontSize];
}else if([[value objectAtIndex:1] isEqualToString:@"italic"]){
return [UIFont italicSystemFontOfSize:fontSize];
}else{
return [UIFont systemFontOfSize:fontSize];
}
}
return [UIFont systemFontOfSize:[UIFont systemFontSize]];
}
//传入数组转成对应的CGFloat
+(CGFloat) parseFloatFromValues:(NSArray*)value
{
CGFloat floatValue = [[value firstObject] floatValue];
if ([[value lastObject] isEqualToString:@"dynamic"]) {
floatValue *= [DeviceInfo iPadThemeScale];
}
return
适配过程中定义的一些工具
如何识别iPad pro,iPad mini,以及普通iPad
有时候要根据设备不同进行区分对待,尤其是mini和air具有相同的分辨率但尺寸不同,就有其需要有一些特殊化操作。贴代码:
+ (BOOL) isiPadPro {
static BOOL s_isiPadPro = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if ([self isiPad]) {
s_isiPadPro = MAX(QDScreenHeight, QDScreenWidth) > 1024;
}
});
return s_isiPadPro;
}
+ (BOOL) isiPadMini {
static BOOL s_isiPadMini = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if ([self isiPad]) {
NSString *nsPlatform = [UIDevice currentDevice].platform;
s_isiPadMini = ([nsPlatform hasPrefix:@"iPad2,5"] || [nsPlatform hasPrefix:@"iPad2,6"] || [nsPlatform hasPrefix:@"iPad2,7"] //mini
|| [nsPlatform hasPrefix:@"iPad4,4"] || [nsPlatform hasPrefix:@"iPad4,5"] || [nsPlatform hasPrefix:@"iPad4,6"] //mini2
|| [nsPlatform hasPrefix:@"iPad4,7"] || [nsPlatform hasPrefix:@"iPad4,8"] || [nsPlatform hasPrefix:@"iPad4,9"] //mini3
|| [nsPlatform hasPrefix:@"iPad5,1"] || [nsPlatform hasPrefix:@"iPad5,2"] //mini4
);
}
});
return s_isiPadMini;
}
+ (BOOL) isiPadNormal { //默认认为不是mini和pro的都是normal
static BOOL s_isiPadNormal = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if ([self isiPad]) {
s_isiPadNormal = ![self isiPadMini] && ![self isiPadPro];
}
});
return s_isiPadNormal;
}
获得分屏的通知
系统没有针对分屏发送通知,我们需要自己实现。通过实验,虽然在分屏过程中系统会回掉appdelegate的applicationDidBecomeActive:方法,但我们发现最先获得分屏事件的是可见window的layoutSubviews方法,因此在此做一些操作。贴代码:
- (void) layoutSubviews {
[super layoutSubviews];
if (self == [AppDelegate sharedAppDelegate].window && _lastWidth != self.width) {
_lastWidth = self.width;
[DeviceInfo changeSplitValueIfNeed];
}
}
DeviceInfo.m
+ (void) changeSplitValueIfNeed {
if ([DeviceInfo isiPadUniversal] && [DeviceInfo isiOS9plus]) {
UIScreen *screen = [UIScreen mainScreen];
CGFloat appWidth = screen.applicationFrame.size.width;
BOOL curSplitState = (appWidth != screen.bounds.size.width && appWidth != screen.bounds.size.height);
if (curSplitState != s_biPadSplitState) { //本次后台前台切换了状态
s_biPadSplitState = curSplitState;
}
} else {
s_biPadSplitState = NO;
}
int column = [DeviceInfo getCurrentiPadColumnIfChange];
if (column != s_biPadColumnNum) {
s_biPadColumnNum = column;
[[NSNotificationCenter defaultCenter] postNotificationName:KNotificationColumnChange object:nil];
}
}
获取当前宽度
由于分屏了,使用[UIScreen mainScreen].bounds可能就不能满足了。贴代码:
+ (CGFloat) appWidth {
UIScreen *screen = [UIScreen mainScreen];
if ([DeviceInfo isiPadUniversal] && [DeviceInfo isiOS9plus]) {
return screen.applicationFrame.size.width;;
}
return screen.bounds.size.width;
}
什么时候处理UI布局更新事件
viewcontroller的viewWillLayoutSubviews,view的layoutSubviews时处理UI的最好时刻,如果使用了collectionview,那么如果在分屏过程中重新定义了layout,那么在cell的applyLayoutAttributes:方法中处理ui也是个不错的时机。
一些在适配过程中的注意
1、window的设定
在iOS9下window不要设置它的宽高,它默认init就好了。
但是,iOS8及以下版本一定要初始化宽高,要不然发生任何恐怖事情不负责。
2、多使用autolayout,如果UI很简单,不需要AutoLayout,起码要记得添加autoResizingMask属性。
3、面对的宽度会相差近5倍,所以所有的宽度值都不会是安全的了,每个写死的数值都要考虑如果分屏了怎么办。
4、cache、cache、cache!分屏可能会出现多种ui情况,很多宽度高度要不停的计算,如果算过了,请把值缓存下来。
5、使用splitview必须要支持4个方向的旋转,so,你可能要改好多交互方案了
最后
gif欣赏
以后加上,或者过两天你去appstore下载个看看
最后的最后
如果你嫌麻烦,可以通过在plist中增加参数来关闭对split view的支持。。。
如果你喜欢我的文章,可以直接查看我的博客,上面会有更多内容