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

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


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

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

iOS自定义不同设备下的UI布局_第1张图片
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为例:

iOS自定义不同设备下的UI布局_第2张图片
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

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

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

你可能感兴趣的:(iOS自定义不同设备下的UI布局)