iOS自定义不同设备下的UI布局

原创文章转载请注明出处,谢谢


已经有两个星期没有更新博客了实属无奈,最近实在是太忙了昨天刚刚Release,习惯是一定要遵守的,即使没有时间也要挤出来。好了言归正传,这次要分享的内容主要是我之前在项目上遇到一个布局方面的问题。

首先我们来看一张设计图:

Collage_Fotor.jpg

上面分别是iPhone5s,iPhone6,iPhone6s以及iPad的四张设计图,对应了我们的日历模块,我分别对图上的几个特别的点进行了标注,我们可以发现基本上所有的标注尺寸都是不同的,原因是因为考虑到不同设备大小尺寸的不同,所以在美观度上设计师分别设计了四种方案。

那么我所遇到的问题就是如何采用一种方案,把关于设备适配这种逻辑从代码里解偶出来,这是我想要做的。

我们的项目是通过纯代码的AutoLayout(Masonry实现)来布局的,那么之前的代码中就存在类似于以下结构的代码:

 [self.measureTopView mas_makeConstraints:^(MASConstraintMaker *make) {
    CGFloat measureTopViewHeight;
    CGFloat measureTopViewLeftAndRightToSuperViewDistance;
    if (WIDTH_SCREEN == kBMTDeviceScreenWidthIphone5) {
      measureTopViewHeight = 105.0f;
      measureTopViewLeftAndRightToSuperViewDistance = 12.0f;
    } if (WIDTH_SCREEN == kBMTDeviceScreenWidthIphone6) {
      measureTopViewHeight = 105.0f;
      measureTopViewLeftAndRightToSuperViewDistance = 15.0f;
    } else {
      measureTopViewHeight = 114.0f;
      measureTopViewLeftAndRightToSuperViewDistance = 20.0f;
    }
    make.top.equalTo(self);
    make.left.equalTo(self.mas_left).offset(measureTopViewLeftAndRightToSuperViewDistance);
    make.right.equalTo(self.mas_right).offset(-measureTopViewLeftAndRightToSuperViewDistance);
    make.height.mas_equalTo(measureTopViewHeight);
  }];

基本上大部分的View里都会充斥着这类代码非常不美观又提高了耦合度。所以我们的目标就是将这类代码从里面分离出来View只做数据的展示而不做其它的判断。


一般在iOS中界面适配常用到的方式就是storyboard的AutoLayout的布局,但是我个人不喜欢这种布局方式,原因有三点:

  • 在于xib在多人合作开发时如果有冲突是不能merge的,
  • 对于那种会动态修改的界面来说,我们还需要在代码中修改界面的属性,那不如直接就采用纯代码的布局方便。
  • 采用纯代码的布局有利于我们更好的了解控件的属性。

但即使我们使用了storyboard+Sizeclass的方式也解决不了我们的问题,因为Sizeclass其实就是解决横屏适配和iPhone iPad共享一个设计板的需要。


第二种我想到可以解决问题的方案是我们对Masonry进行一次关于Device的扩展:

@interface MASConstraint (DeviceAdapter)

#define mas_equalTo_iPhone5Or5S(...)  equalTo_iPhone5Or5S(MASBoxValue((__VA_ARGS__)))
#define mas_equalTo_iPhone6Or6S(...)  equalTo_iPhone6Or6S(MASBoxValue((__VA_ARGS__)))
#define mas_equalTo_iPhone6POr6SP(...)  equalTo_iPhone6POr6SP(MASBoxValue((__VA_ARGS__)))
#define mas_equalTo_iPhoneIPad(...)  equalTo_iPhoneIPad(MASBoxValue((__VA_ARGS__)))
#define mas_equalTo_iPhone5Or5SOr6Or6S(...)  equalTo_iPhone5Or5SOr6Or6S(MASBoxValue((__VA_ARGS__)))
#define mas_equalTo_iPhone6POr6SPOrIPad(...)  equalTo_iPhone6POr6SPOrIPad(MASBoxValue((__VA_ARGS__)))

- (MASConstraint * (^)(id attr))equalTo_iPhone5Or5S;

- (MASConstraint * (^)(id attr))equalTo_iPhone6Or6S;

- (MASConstraint * (^)(id attr))equalTo_iPhone6POr6SP;

- (MASConstraint * (^)(id attr))equalTo_iPhoneIPad;

- (MASConstraint * (^)(id attr))equalTo_iPhone5Or5SOr6Or6S;

- (MASConstraint * (^)(id attr))equalTo_iPhone6POr6SPOrIPad;

- (MASConstraint * (^)(CGFloat offset))iPhone5Or5SOffset;

- (MASConstraint * (^)(CGFloat offset))iPhone6Or6SOffset;

- (MASConstraint * (^)(CGFloat offset))iPhone6POr6SPOffset;

- (MASConstraint * (^)(CGFloat offset))iPhoneIPadOffset;

- (MASConstraint * (^)(CGFloat offset))iPhone5Or5SOr6Or6SOffset;

- (MASConstraint * (^)(CGFloat offset))iPhone6POr6SPOrIPadOffset;

@end

好有了这一层的扩展我们就可以把之前代码修改成如下的样子:

 [self.measureTopView mas_makeConstraints:^(MASConstraintMaker *make) {
   make.top.equalTo(self);
make.left.equalTo(self.mas_left).offset(114).iPhone5Offset(105);
   make.right.equalTo(self.mas_right).offset(-20).iPhone5Offset(-12).iPhone6Offset(15);
   make.height.equalTo_iPhone5Or5S(105.f).equalTo_iPhone6Or6S(105.0f).equalTo_iPhone6POr6SP(115.0f).equalTo_iPhone6POr6SPOrIPad(115.0f);
 }];

这个样子虽然把判断设备的那部分代码从View中解偶了,但是在设备的扩展性方面却是非常的差,而且代码的美观度还是不够。


第三种方法是一个网易的朋友告诉我,比较的抽象,大致思路分为三部分:

  • 首先找设计的同学问设计图上的数字是通过什么方式计算出来的。
  • 然后将这些思路抽象成具体的公式,比如iPhone5,iPhone6,iPad是2x的,iPhone6p是3x的,那么把iPhone6作为一个基准,iPhone6p就是它的1.5倍。当然这只是其中一种最简单的抽象,其它还有很多方面要考虑。
  • 最后如果设计师给的值只是从感性的角度上得出来的,并没有什么计算方式,那么你只能构建一个方便的封装,类似于我上面对于Masonry的扩展,便于你去指定分别的参数。

但是这种方案并不适用于我遇到的上面这种情况,原因是不好抽象,而且对于没有规律的界面来说我们依然得依赖Masonry的扩展,我们不能做到一个统一的标准进行适配。


第四种方法采用了Android对于屏幕适配的方案,使用配置文件的方式来完成,抽象出一些常用的约束,我们首先看一下下面的设计图,以6plus为例:

2.pic.jpg

其实对于一个元素来说,确定它们位置的约束包括以下几个属性:marginLeft,marginRight,marginTop,marginBottom,width,height。margin标记的元素指的都是相对于另一个元素的位置,通过这样的方式我们就可以去定义一个元素在View中的具体位置。

// Standard Attributes
@property (nonatomic, assign, readonly) CGFloat marginLeft;

@property (nonatomic, assign, readonly) CGFloat marginRight;

@property (nonatomic, assign, readonly) CGFloat marginTop;

@property (nonatomic, assign, readonly) CGFloat marginBottom;

@property (nonatomic, assign, readonly) CGFloat width;

@property (nonatomic, assign, readonly) CGFloat height;

// Special Attributes
@property (nonatomic, assign, readonly) CGFloat fontSize;

@property (nonatomic, copy, readonly) NSString *imageName;

@property (nonatomic, assign, readonly) CGFloat remarkParameterOne;

@property (nonatomic, assign, readonly) CGFloat remarkParameterTwo;

我们定义了一些基础的属性,以及一些可扩展的特殊属性。基础属性是必须实现的,扩展属性只对于一些特殊的元素进行定义。

那么现在我们就可以把上面Button的约束抽象出来,以上的Button分为两种状态,分别是选中和非选中的状态,这个时候它们的大小是不一样的,但是坐标其实是不会变的,也就是说我们只需要区分选中和非选中状态下的margin值和Size就可以了。

// iPhone6 Plus
{
 "BMTReportCalendarDayButton" :
 [
   {
      "bmt_reportCalendarDayButtonId_Selected" :
      {
        "marginLeft" : 6.0,
        "marginRight" : 0.0,
        "marginTop" : 0.0,
        "marginBottom" : 0.0,
        "width" : 60.0,
        "height" : 60.0
      }
   },
   {
      "bmt_reportCalendarDayButtonId_Normal" :
      {
        "marginLeft" : -3.0,
        "marginRight" : 0.0,
        "marginTop" : 0.0,
        "marginBottom" : 0.0,
        "width" : 40.0,
        "height" : 40.0,
        "fontSize" : 20.0
      }
   }
 ]
}

// iPhone6
{
 "BMTReportCalendarDayButton" :
 [
    {
      "bmt_reportCalendarDayButtonId_Selected" :
      {
        "marginLeft" : 4.0,
        "marginRight" : 0.0,
        "marginTop" : 0.0,
        "marginBottom" : 0.0,
        "width" : 55.0,
        "height" : 55.0
      }
    },
    {
      "bmt_reportCalendarDayButtonId_Normal" :
      {
        "marginLeft" : -3.0,
        "marginRight" : 0.0,
        "marginTop" : 0.0,
        "marginBottom" : 0.0,
        "width" : 35.0,
        "height" : 35.0,
        "fontSize" : 20.0
      }
    }
  ]
}

// iPhone5
{
 "BMTReportCalendarDayButton" :
 [
    {
      "bmt_reportCalendarDayButtonId_Selected" :
      {
        "marginLeft" : 2.5,
        "marginRight" : 0.0,
        "marginTop" : 0.5,
        "marginBottom" : 0.0,
        "width" : 45.0,
        "height" : 45.0
      }
    },
    {
      "bmt_reportCalendarDayButtonId_Normal" :
      {
        "marginLeft" : 0.0,
        "marginRight" : 0.0,
        "marginTop" : 0.0,
        "marginBottom" : 0.0,
        "width" : 30.0,
        "height" : 30.0,
        "fontSize" : 17.0
      }
    }
  ]
}

// iPad
{
 "BMTReportCalendarDayButton" :
 [
    {
      "bmt_reportCalendarDayButtonId_Selected" :
      {
        "marginLeft" : 4.0,
        "marginRight" : 0.0,
        "marginTop" : 0.0,
        "marginBottom" : 0.0,
        "width" : 55.0,
        "height" : 55.0
      }
    },
    {
      "bmt_reportCalendarDayButtonId_Normal" :
      {
        "marginLeft" : 62.5,
        "marginRight" : 0.0,
        "marginTop" : 0.0,
        "marginBottom" : 0.0,
        "width" : 35.0,
        "height" : 35.0,
        "fontSize" : 20.0
      }
    }
  ]
}

这分别是四种分辨率下的约束值,最后我们只需要写一个配置文件解释器就可以拿到不同设备下的值了。

其实解释器很简单,主要的思想可以分为以下三个部分:

  • 每一个元素都有一个唯一的属性约束,因为即使是同一个元素,在不同的场景下约束也是不一样的,所以我们需要对每一个元素做一个唯一的ID进行记录。
  • 定义一个约束解释器,得到对应的设备名称,然后反射类名组合得到约束文件名,最后通过解析JSON的方式,拿到对应的约束,同时保存到数据缓存,防止二次解析浪费资源。
  • 注意多线程读写锁的问题。

我们首先来看一下约束解释器具体的定义:

typedef void(^AsyncLayoutContraintCallback)(BMTLayoutConstraintBase *layoutConstraintBase);

@class BMTLayoutConstraintBase;

@interface BMTLayoutContraintInterpreter : NSObject

+ (instancetype)sharedInstance;

// 异步解析,读写锁,解析单个约束
- (void)parseLayoutConstraintAsyncWithViewId:(NSString *)viewConstraintId
                               containerName:(NSString *)containerName
                       andCompletionCallback:(AsyncLayoutContraintCallback)completeCallback;

// 异步解析,读写锁,解析全文件
- (void)layoutConstraintAsyncWithContainerName:(NSString *)containerName
                         andCompletionCallback:(void (^)(void))completeCallback;
// 同步解析,读写锁,解析单个约束 
- (BMTLayoutConstraintBase *)parseLayoutConstraintSyncWithViewId:(NSString *)viewConstraintId
                                                andContainerName:(NSString *)containerName;

@end

这样设计的原因在于,为了保证在加载配置文件的时候主线程不卡顿,所以要异步加载资源同时缓存配置数据,但是由于UI异步加载资源的时候不能保证在我们获取配置数据的时候数据已经在加载完成,所以我们应该在程序启动的时候就去load资源文件,这样我们就可以直接在UI的主线直接读取数据而且不会影响UI线程,同时也能保证读取数据的时候数据已经加载完成。

@interface BMTLayoutContraintInterpreter() {
  dispatch_queue_t _queue;
}

@property (nonatomic, copy, readwrite) NSMutableDictionary *cacheConstraintMapDict;

@end

@implementation BMTLayoutContraintInterpreter

+ (instancetype)sharedInstance {
  static BMTLayoutContraintInterpreter *_sharedManager = nil;
  static dispatch_once_t oncePredicate;
  dispatch_once(&oncePredicate, ^{
    _sharedManager = [[self alloc] init];
  });
  
  return _sharedManager;
}

- (instancetype)init {
  if (self = [super init]) {
    _cacheConstraintMapDict = [[NSMutableDictionary alloc] init];
    _queue = dispatch_queue_create("com.bongmi.layoutcontraintqueue", DISPATCH_QUEUE_CONCURRENT);
  }
  return self;
}

#pragma mark - private

- (NSString *)getLayoutConstraintDeviceName {
  if ((WIDTH_SCREEN == kBMTDeviceScreenWidthIphone5 &&
       HEIGHT_SCREEN == kBMTDeviceScreenHeightIphone5) ||
      (WIDTH_SCREEN == kBMTDeviceScreenWidthIphone5s &&
       HEIGHT_SCREEN == kBMTDeviceScreenHeightIphone5s)) {
    return kBMTLayoutConstraintDeviceiPhone5_S_C_SE;
  } else if ((WIDTH_SCREEN == kBMTDeviceScreenWidthIphone6 &&
              HEIGHT_SCREEN == kBMTDeviceScreenHeightIphone6) ||
             (WIDTH_SCREEN == kBMTDeviceScreenWidthIphone6s &&
              HEIGHT_SCREEN == kBMTDeviceScreenHeightIphone6s)) {
    return kBMTLayoutConstraintDeviceiPhone6_S;
  } else if ((WIDTH_SCREEN == kBMTDeviceScreenWidthIphone6p &&
               HEIGHT_SCREEN == kBMTDeviceScreenHeightIphone6p) ||
              (WIDTH_SCREEN == kBMTDeviceScreenWidthIphone6sp &&
               HEIGHT_SCREEN == kBMTDeviceScreenHeightIphone6sp)) {
    return kBMTLayoutConstraintDeviceiPhone6P_S;
  } else if (WIDTH_SCREEN == kBMTDeviceScreenWidthIpad &&
              HEIGHT_SCREEN == kBMTDeviceScreenHeightIpad) {
    return kBMTLayoutConstraintDeviceiPad;
  } else {
    return kBMTLayoutConstraintDeviceiPad;
  }
}

- (NSString *)concatenateLayoutConstraintFilePath:(NSString *)fileName {
  NSString *fileType = @"json";
  NSString *constraintDeviceName = [self getLayoutConstraintDeviceName];
  NSString *combinationFileName =
  [NSString stringWithFormat:@"%@_%@", fileName, constraintDeviceName];
  NSString *constraintFilePath =
  [[NSBundle mainBundle] pathForResource:combinationFileName ofType:fileType];
  return constraintFilePath;
}

- (void)parseTargetLayoutConstraintFile:(NSString *)fileName {
  NSError *error = nil;
  NSString *constraintFilePath = [self concatenateLayoutConstraintFilePath:fileName];
  NSString *jsonContents = [NSString stringWithContentsOfFile:constraintFilePath
                                                     encoding:NSUTF8StringEncoding
                                                        error:&error];
  if (error) {
    DDLogDebug(@"%s read %@ file error %@", object_getClassName([self class]), constraintFilePath, error);
    assert(NO);
  }
  
  NSData *data = [jsonContents dataUsingEncoding:NSUTF8StringEncoding];
  NSDictionary *firstDict =
  [NSJSONSerialization JSONObjectWithData:data
                                  options:kNilOptions
                                    error:&error];
  if (error) {
    DDLogDebug(@"%s convert dictionary error %@", object_getClassName([self class]), error);
    assert(NO);
  }
  for (NSString *keyOne in firstDict) {
    for (NSDictionary *secondDict in firstDict[keyOne]) {
      for (NSString *keyTwo in secondDict) {
        BMTLayoutConstraintModel *model =
         [[BMTLayoutConstraintModel alloc] initWithDictionary:secondDict[keyTwo]];
        if (model) {
          BMTLayoutConstraintBase *base =
          [[BMTLayoutConstraintBase alloc] initWithLayoutConstraintModel:model];
          [self setCacheConstraintMapDictObject:base
                                         forKey:keyTwo];
        }
      }
    }
  }
}

// safe read-write
- (id)cacheConstraintMapDictObjectForKey:(id)viewConstraintId {
  __block id obj;
  dispatch_sync(_queue, ^{
    obj = [_cacheConstraintMapDict objectForKey:viewConstraintId];
  });
  return obj;
}

- (void)setCacheConstraintMapDictObject:(id)obj
                                 forKey:(id)key {
  dispatch_barrier_async(_queue, ^{
    [_cacheConstraintMapDict setObject:obj
                                forKey:key];
  });
}

#pragma mark - public
- (void)parseLayoutConstraintAsyncWithViewId:(NSString *)viewConstraintId
                               containerName:(NSString *)containerName
                       andCompletionCallback:(AsyncLayoutContraintCallback)completeCallback {
  if ([self cacheConstraintMapDictObjectForKey:viewConstraintId]) {
    if (completeCallback) {
      completeCallback([self cacheConstraintMapDictObjectForKey:viewConstraintId]);
    }
  } else {
    @weakify(self)
    dispatch_async(_queue, ^{
      @strongify(self)
      [self parseTargetLayoutConstraintFile:containerName];
      @weakify(self)
      dispatch_async(dispatch_get_main_queue(), ^{
        @strongify(self)
        if (completeCallback) {
          completeCallback([self cacheConstraintMapDictObjectForKey:viewConstraintId]);
        }
      });
    });
  }
}

- (void)layoutConstraintAsyncWithContainerName:(NSString *)containerName
                         andCompletionCallback:(void (^)(void))completeCallback {
    @weakify(self)
    dispatch_async(_queue, ^{
      @strongify(self)
      [self parseTargetLayoutConstraintFile:containerName];
      if (completeCallback) {
        completeCallback();
      }
    });
}

- (BMTLayoutConstraintBase *)parseLayoutConstraintSyncWithViewId:(NSString *)viewConstraintId
                                                andContainerName:(NSString *)containerName {
  if ([self cacheConstraintMapDictObjectForKey:viewConstraintId]) {
    return [self cacheConstraintMapDictObjectForKey:viewConstraintId];
  } else {
    [self parseTargetLayoutConstraintFile:containerName];
    return [self cacheConstraintMapDictObjectForKey:viewConstraintId];
  }
}

解析的过程其实很简单,通过屏幕分辨率获取当设备的型号, 然后通过Class的反射得到配置文件的文件名同时获取文件路径,然后通过解析json获取到元素的约束信息,同时要保证多线程的读写锁,最后将解析后的值保存到缓存中,防止二次解析。

最后我们只要给UIView增加一个Category就可以了,以后在View中就可以直接读取配置文件:

@implementation UIView (LayoutConstraint)

- (BMTLayoutConstraintBase *)layoutConstraintWithViewId:(NSString *)viewConstraintId
                                        containerObject:(id)containerObject {
  return [[self class] layoutConstraintWithViewId:viewConstraintId
                                  containerObject:containerObject];
}

+ (BMTLayoutConstraintBase *)layoutConstraintWithViewId:(NSString *)viewConstraintId
                                        containerObject:(id)containerObject {
  NSString *containerObjectName =
  [NSString stringWithUTF8String:object_getClassName(containerObject)];
  return [[BMTLayoutContraintInterpreter sharedInstance] parseLayoutConstraintSyncWithViewId:viewConstraintId
                                                                         andContainerName:containerObjectName];
}

@end

总结一下,采用配置文件的方式会使你的代码变得非常整洁,而且对于界面扩展来说非常的方便,如果以后新增加一种分辨率,我们只需要为每个界面新增一个配置文件就可以马上进行适配十分的便捷。

备注:具体的代码我会过两天整理好传上去

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容