野火🔥

好奇心日报iPad版的分屏适配实现

2015/11/23

好奇心日报是一个媒体阅读类app,因此不可能放弃iPad这个平台,恰逢iPad pro上线,我们决定开发iPad版本并直接适配iOS9的新特性—-分屏。

写在前面

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种之多:

1
2
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的支持。。。
UIRequiresFullScreen

CATALOG
  1. 1. 写在前面
  2. 2. 适配方案
  3. 3. 适配过程中定义的一些工具
    1. 3.1. 如何识别iPad pro,iPad mini,以及普通iPad
    2. 3.2. 获得分屏的通知
    3. 3.3. 获取当前宽度
    4. 3.4. 什么时候处理UI布局更新事件
  4. 4. 一些在适配过程中的注意
  5. 5. 最后
    1. 5.1. gif欣赏