[iOS] [OC] 轻量级的表单框架 GSForm(附demo)

[iOS] [OC] 轻量级的表单框架 GSForm(附demo)

IOS小彩虹2021-07-11 15:36:45120A+A-

1.主要特点

轻量级,只有4个类,1个控制器Controller,3个视图模型ViewModel 支持** iOS8 及以上 **

GitHub 和 Demo 下载

  1. 支持完全自定义单元格cell类型
  • 支持自动布局Autolayout和固定行高
  • 表单每行row数据和事件整合为一个model,基本只需管理row
  • 积木式组合 row,支持 sectionrow 的隐藏,易于维护
  • 支持传入外部数据
  • 支持快速提取数据
  • 支持参数的最终合法性校验
  • 支持数据模型的类型完全自由自定义,可拆可合
  • 支持设置row的白名单和黑名单及权限管理

2.背景

通常,将一个页面需要编辑/录入多项信息的页面称为“表单页面”,以下称表单,以某注册页面为例:

在移动端进行表单的录入设计本身因为录入效率低,是尽量避免的,但对于特定的业务场景还是有存在的情况。通常基于 UITableView 进行开发,内容多有文本输入、日期(或者其他PickerView)、各类自定义的单元格cell(比如包含 UISwitch、UIStepper等)、以及一些需要前往二级页面获取信息后回调等元素。

表单的麻烦在于行与行之间数据往往没有特定的规律,上图中第二组数据中,姓名、性别、出生日期以及年龄,4个不同的 cell 则是 4个完全不同的交互方式来录入数据,依照传统的 UITableView 的代理模式来处理,有几个弊端:

  • 在实现数据源方法 tableView:cellForRowAtIndexPath:难免要对每一个 indexPath 进行 switch-case 处理,
  • 糟糕的是对于每一行的点击事件,```tableView:didSelectRowAtIndexPath:````方法,也要进行 switch-case 判断
  • 因为 cell 的重用关系,每一行数据的取值也将严重依赖具体的 indexPath,数据的获取变得困难,同样地,编辑变化后的信息也需要存到到数据模型中,对于跳转二级页面回调的数据需要更新数据后要反过来刷新对应的cell
  • 根据不同的入口,有一些 row 可能不存在或者需要临时插入 cell,这使得写死 indexPath 的 switch-case 很不可靠
  • 即便是静态页面的 cell,写死了 indexPath 进行 switch-case 在未来的需求调整时(比如调整了 row 的位置,新增/减少了某些 row),变得难以维护。

3.解决方案

  • 回顾上面的弊端,很大的一个弊病在于严重的依赖了 row 的位置 indexPath 来获取数据、绘制 cell、处理 cell 的事件以及回调刷新 row,借助 MVVM 的思路,将每一行的视图类型、视图刷新以及事件处理由每一行各自处理,用 GSRow 对象进行管理。
  • 单元格的构造,基于运行时和block,通过运行时构建cell,利用 row 对象的 cellClass/nibName 属性分别从代码或者 xib 加载可重用的 cell 视图备用
  • 调用 GSRow 的 configBlock 进行cell 内容的刷新和配置(包括了 cell内部的block回调事件)
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    GSRow *row = [self.form rowAtIndexPath:indexPath];
    
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.reuseIdentifier];
    if (!cell) {
        if (row.cellClass) {
            /// 运行时加载
            cell = [[row.cellClass alloc] initWithStyle:row.style reuseIdentifier:row.reuseIdentifier];
        } else {
          /// xib 加载
            cell = [[[NSBundle mainBundle] loadNibNamed:row.nibName owner:nil options:nil] lastObject];
        }
        /// 额外的视图初始化
        !row.cellExtraInitBlock ?: row.cellExtraInitBlock(cell, row.value, indexPath);
    }
    
    NSAssert(!(row.rowConfigBlockWithCompletion && row.rowConfigBlock), @"row config block 二选一");
    
    GSRowConfigCompletion completion = nil;
    if (row.rowConfigBlock) {
        /// cell 的配置方式一:直接配置
        row.rowConfigBlock(cell, row.value, indexPath);
        
    } else if (row.rowConfigBlockWithCompletion) {
        /// cell 的配置方式二:直接配置并返回最终配置 block 在返回cell前调用(可用作权限管理)
        completion = row.rowConfigBlockWithCompletion(cell, row.value, indexPath);
    }
    
    [self handleEnableForCell:cell gsRow:row atIndexPath:indexPath];
    
    /// 在返回 cell 前做最终配置(可做权限控制)
    !completion ?: completion();
    
    return cell;
}
  • 一个分组可以包含多个 GSRow 对象,在表单中对分组的头尾部视图并没有高度定制和复杂的事件回调,因此暂不做高度封装,主要提供作为 Row 的容器以及整体隐藏使用,即GSSection。
@interface GSSection : NSObject

@property (nonatomic, strong, readonly) NSMutableArray <GSRow *> *rowArray;
@property (nonatomic, assign, readonly) NSUInteger count;
@property (nonatomic, assign) CGFloat headerHeight;
@property (nonatomic, assign) CGFloat footerHeight;
@property (nonatomic, assign, getter=isHidden) BOOL hidden;

`- (void)addRow:(GSRow *)row;
`- (void)addRowArray:(NSArray <GSRow *> *)rowArray;

@end
  • 同理,多个 GSSetion 对象在一个容器内进行管理会更便利,设置 GSForm 作为整个表单的容器,从而数据结构为GSForm 包含多个 GSSection,而 GSSection 包含多个 GSRow,这样与 UITableView 的数据源和代理结构保持一致。
@interface GSForm : NSObject

@property (nonatomic, strong, readonly) NSMutableArray <GSSection *> *sectionArray;
@property (nonatomic, assign, readonly) NSUInteger count;

@property (nonatomic, assign) CGFloat rowHeight;

- (void)addSection:(GSSection *)section;
- (void)removeSection:(GSSection *)section;

- (void)reformRespRet:(id)resp;
- (id)fetchHttpParams;

- (NSDictionary *)validateRows;

/// 配置全局禁用点击事件的block
@property (nonatomic, copy) id(^disableBlock)(GSForm *);

/// 根据 indexPath 返回 row
- (GSRow *)rowAtIndexPath:(NSIndexPath *)indexPath;
/// 根据 row 返回 indexPath
- (NSIndexPath *)indexPathOfGSRow:(GSRow *)row;

@end

为了承载和实现 UITableView 的协议,将 UITabeView 作为控制器的子视图,设为 GSFormVC,GSFormVC 同时是 UITableView 的数据源dataSource 和代理 delegate,负责将 UITableView 的重要协议方法分发给 GSRow 和 GSSection,以及黑白名单控制,如此,具体的业务场景下,通过继承 GSFormVC 配置 GSForm 的结构,即可实现主体功能,对于分组section的头尾视图等可以通过在具体业务子类中实现 UITableView 的方式来实现即可。

4.具体功能点的实现

4.1 支持完全自定义单元格 cell

当 UITableView 的 tableView:cellForRowAtIndexPath:方法调用时,第一步时通过 row 的 reuserIdentifer 获取可重用的cell,当需要创建cell 时通过 GSRow 配置的 cellClass 属性或者 nibName 属性分别通过运行时或者 xib 创建新的cell 实例,从而隔离对 cell类型的直接依赖。 其中 GSRow 的构造方法

- (instancetype)initWithStyle:(UITableViewCellStyle)style
              reuseIdentifier:(NSString *)reuseIdentifier;

接着配置 cell 的具体类型,cellClass 或者 nibName 属性

@property (nonatomic, strong) Class cellClass;
@property (nonatomic, strong) NSString *nibName;

为了在 cell 初始化后可以进行额外的子视图构造或者样式配置,设置 GSRow 的 cellExtraInitBlock,将在 首次构造 cell 时进行额外调用,属性的声明:

@property (nonatomic, copy) void(^cellExtraInitBlock)(id cell, id value, NSIndexPath *indexPath); 
// if(!cell) { extraInitBlock };

下面是构造 cell 的处理

    GSRow *row = [self.form rowAtIndexPath:indexPath];
    
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.reuseIdentifier];
    if (!cell) {
        if (row.cellClass) {
            cell = [[row.cellClass alloc] initWithStyle:row.style reuseIdentifier:row.reuseIdentifier];
        } else {
            cell = [[[NSBundle mainBundle] loadNibNamed:row.nibName owner:nil options:nil] lastObject];
        }
        
        !row.cellExtraInitBlock ?: row.cellExtraInitBlock(cell, row.value, indexPath);
    }

获取到构造的可用的cell 后需要利用数据模型对 cell 的内容进行填入处理,这个操作通过配置rowConfigBlock 或者 rowConfigBlockWithCompletion 属性完成,这两个属性只会调用其中一个,后者的区别时会在配置完成后返回一个 block 变量用于进行最终配置,属性的声明如下:

@property (nonatomic, copy) void(^rowConfigBlock)(id cell, id value, NSIndexPath *indexPath); 
// config at cellForRowAtIndexPath:
@property (nonatomic, copy) GSRowConfigCompletion(^rowConfigBlockWithCompletion)(id cell, id value, NSIndexPath *indexPath); 
// row config at cellForRow with extra final config

4.2 支持自动布局AutoLayout和固定行高

自 iOS8 后 UITableView 支持高度自适应,通过在 GSFormVC 内对 TableView 进行自动布局的设置后,再在各个 Cell 实现各自的布局方案,表单的布局思路可以兼容固定行高和自动布局,TableView 的配置:

- (UITableView *)tableView {
    if (!_tableView) {
        _tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleGrouped];
        _tableView.delegate = self;
        _tableView.dataSource = self;
        _tableView.backgroundColor = [UIColor groupTableViewBackgroundColor];
        _tableView.tableFooterView = [[UIView alloc] init];
        _tableView.rowHeight = UITableViewAutomaticDimension;
        _tableView.estimatedRowHeight = 88.f;
    }
    
    return _tableView;
}

对应地,GSRow 的 rowHeight 属性可以实现 cell高度的固定,如果不传值则默认为自动布局,属性的声明:

@property (nonatomic, assign) CGFloat rowHeight;

进而在 TableView 的代理中实现 cell 的高度布局,如下:

- (CGFloat)tableView:(UITableView *)tableView
        heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    GSRow *row = [self.form rowAtIndexPath:indexPath];
    
    return row.rowHeight == 0 ? UITableViewAutomaticDimension : row.rowHeight;
}

4.3 表单每行row数据和事件整合为一个model,基本只需管理row

为了方便行数据的存储,设置了专门用于存值的属性,根据实际的需要进行赋值和取值即可,声明如下:

@property (nonatomic, strong) id value;

在实际的应用中,value 使用可变字典的场景居多,如果内部有特定的自定义类对象,可以用一个key值保存在可变字典value中,方便存取,value 作为可变字典使用时有极大的自由便利性,可以在其中保存有规律的信息,比如表单cell 左侧的 title,右侧的内容等等,因为 block 可以时分便利地捕获上下对象,而且 GSForm 的设计实现时一个 GSRow 的几乎所有信息都在一个代码块内实现,从而实现上下文的共享,在上一个block存值时的key,可以在下一个block方便地得知用于取值和设值,比如一个 GSRow 的配置:

- (GSRow *)rowForTrace {
      GSRow *row = nil;
    
    GSTTraceListRespRet *model = [[GSTTraceListRespRet alloc] init];
    row = [[GSRow alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"GSLabelFieldCell"];
    row.cellClass = [GSLabelFieldCell class];
    row.rowHeight = 44;
    row.value = @{kCellLeftTitle:@"跟踪方案"}.mutableCopy;
    row.value[kCellModelKey] = model;
    row.rowConfigBlock = ^(GSLabelFieldCell *cell, id value, NSIndexPath *indexPath) {
        cell.leftlabel.text = value[kCellLeftTitle];
        cell.rightField.text = model.name;
        cell.rightField.enabled = NO;
        cell.rightField.placeholder = @"请选择运输跟踪方案";
        cell.accessoryView = form_makeArrow();
    };    
    
    WEAK_SELF
    row.reformRespRetBlock = ^(GSTGoodsOriginInfoRespRet *ret, id value) {
        model.trace_id = ret.trace_id;
        model.name = ret.trace_name;
    };
    
    row.didSelectBlock = ^(NSIndexPath *indexPath, id value) {
        STRONG_SELF
        GSTChooseTraceVC *ctl = [[GSTChooseTraceVC alloc] init];
        ctl.chooseBlock = ^(GSTTraceListRespRet *trace){
            model.trace_id = trace.trace_id;
            model.name = trace.name;
            [strongSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
        };
        [strongSelf.navigationController pushViewController:ctl animated:YES];
    };
  return row;
}

对于需要在点击 row 时跳转二级页面的情况,通过配置 GSRow 的 didSelectBlock 来实现,声明及示例如下:

@property (nonatomic, copy) void(^didSelectCellBlock)(NSIndexPath *indexPath, id value, id cell); 
// didSelectRow with Cell

    row.didSelectBlock = ^(NSIndexPath *indexPath, id value) {
        STRONG_SELF
        GSTChooseTraceVC *ctl = [[GSTChooseTraceVC alloc] init];
        ctl.chooseBlock = ^(GSTTraceListRespRet *trace){
            model.trace_id = trace.trace_id;
            model.name = trace.name;
            [strongSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
        };
        [strongSelf.navigationController pushViewController:ctl animated:YES];
    };

通过对该属性的配置,在 TableView 的代理方法 tableView:didSelectRowAtIndexPath: 来调用:

- (void)tableView:(UITableView *)tableView
         didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
    
    GSRow *row = [self.form rowAtIndexPath:indexPath];
    !row.didSelectBlock ?: row.didSelectBlock(indexPath, row.value);
    UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
    !row.didSelectCellBlock ?: row.didSelectCellBlock(indexPath, row.value, cell);
}

综上,通过多个属性的配合使用,基本达成了 cell 的构造、配置和 cell内部事件以及 cell 整体点击事件的整合。

4.4 积木式组合 row,支持 section 和 row 的隐藏,易于维护

基于每行数据及其事件整合在 GSRow 内,具备了独立性,通过根据需求整合到不同的 GSSection 后即可搭建成具体的业务页面,举例:

/// 构造页面的表单数据
- (void)buildDataSource {
    [self.form addSection:[self sectionChooseProject]];
    [self.form addSection:[self sectionTransportSettings]];
    [self.form addSection:[self sectionUploadAddress]];
    [self.form addSection:[self sectionDownloadAdress]];
    [self.form addSection:[self sectionOtherInfo]];
}

此外,GSSection/GSRow 都支持隐藏,根据不同的场景设置 GSSection/GSRow 的隐藏状态,可以动态设置表单。

@property (nonatomic, assign, getter=isHidden) BOOL hidden;

隐藏属性将通过 UITableView 的数据源 dataSource 协议方法决定是否显示 section/row:

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    NSInteger count = 0;
    for (GSSection *section in self.form.sectionArray) {
        if(!section.isHidden) count++;
    }
    
    return count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    GSSection *fSection = self.form[section];
    NSInteger count = 0;
    for (GSRow *row in fSection.rowArray) {
        if(!row.isHidden) count++;
    }
    
    return count;
}

也正是因为GSSection/GSRow 的隐藏特点,根据 indexPath 取值时不能单方面地根据索引从数组中取值,也应考虑到是否有隐藏的对象,为此在 GSForm 定义了两个工具方法,用于关联 indexPath 与 GSRow 对象,在必要时调用。

/// 根据 indexPath 返回 row
- (GSRow *)rowAtIndexPath:(NSIndexPath *)indexPath;
/// 根据 row 返回 indexPath
- (NSIndexPath *)indexPathOfGSRow:(GSRow *)row;

通过这些可组合性,可以便利地搭建页面,且易于增删或者调整顺序。

4.5 支持传入外部数据

有些编辑类型的表单,首次加载时通过其他渠道加载数据后先填入一部分值,为此,GSRow 设计了从外部取值的属性 reformRespRetBlock,而外部参数经由 GSForm 进行遍历调用。

///GSForm
/// 传入外部数据
- (void)reformRespRet:(id)resp;
- (void)reformRespRet:(id)resp {
    for (GSSection *section in self.sectionArray) {
        for (GSRow *row in section.rowArray) {
            !row.reformRespRetBlock ?: row.reformRespRetBlock(resp, row.value);
        }
    }
}
/// GSRow 从外部取值的block配置
@property (nonatomic, copy) void(^reformRespRetBlock)(id ret, id value);    
 // 外部传值处理

如此,通过网络请求的数据返回后调用 GSForm 将数据分发到 GSRow 存入到各自的 value 后,刷新 TableView 即可实现外部数据的导入,比如网络请求后调用构建页面各个 GSRow 并 传入外部数据:


SomeHTTPModel *result; // 网络请求成功返回值
self.result = result;
[self buildForm];
[self.form reformRespRet:result];
[self.tableView reloadData];

4.6 支持快速提取数据

对应地,当数据录入完成后,点击提交时,需要获取各行数据进行网络请求,此时根据业务场景各自通过,通过每个 GSRow 配置各自的请求参数即可,声明配置请求参数的属性 httpParamConfigBlock,以从表单中提取一个字典参数为例: 声明:

@property (nonatomic, copy) id(^httpParamConfigBlock)(id value); 
// get param for http request

从表单中获取请求参数:

/// 获取当前请求参数
- (NSMutableDictionary *)fetchCurrentRequestInfo {
    NSMutableDictionary *dic = [NSMutableDictionary dictionary];
    for (GSSection *secion in self.form.sectionArray) {
        if (secion.isHidden) continue;
        
        for (GSRow *row in secion.rowArray) {
            if (row.isHidden || !row.httpParamConfigBlock) continue;
            
            id http = row.httpParamConfigBlock(row.value);
            if ([http isKindOfClass:[NSDictionary class]]) {
                [dic addEntriesFromDictionary:http];
            } else if ([http isKindOfClass:[NSArray class]]) {
                for (NSDictionary *subHttp in http) {
                    [dic addEntriesFromDictionary:subHttp];
                }
            }
        }
    }
    return dic;
}

4.7 支持参数的最终合法性校验

一般地,对用户输入的参数在提交前需要进行合法性校验,对于较长的表单而言通常是点击提交按钮时进行,对参数的最终合法性进行逐个校验,当参数不合法时进行提醒,将合法性校验的要求声明为 GSRow 的属性进行处理,如下:

/// check isValid
@property (nonatomic, copy) NSDictionary *(^valueValidateBlock)(id value);

返回值为字典,其中字典的内容并不严格限制,一个好的实践是:用一个key 标记校验是否通过,另外一个key标记校验失败的提醒,比如:

    row.valueValidateBlock = ^id(id value) {
        // 校验失败,返回一个 key 为 @NO 的字典,并携带错误地址。
        if(![value[kCellModelKey] count]) return rowError(@"XX时间不可为空");
    
        return rowOK(); // 返回一个 key 为 @YES 的字典
    };

如此,可由整个表单 GSForm发起整体校验,做遍历处理,举例如下:

/// GSForm
- (NSDictionary *)validateRows;
- (NSDictionary *)validateRows {
    for (GSSection *section in self.sectionArray) {
        for (GSRow *row in section.rowArray) {
            if (!row.isHidden && row.valueValidateBlock) {
                NSDictionary *dic = row.valueValidateBlock(row.value);
                NSNumber *ret = dic[kValidateRetKey];
                NSAssert(ret, @"必须有结果参数");
                if (!ret) continue;
                if (!ret.boolValue) return dic;
            }
        }
    }
    
    return rowOK();
}

// 业务方的使用
/// 检查参数合法性,如不合法冒泡提醒
- (BOOL)validateParameters {
    NSDictionary *validate = [self.form validateRows];
    if (![validate[kValidateRetKey] boolValue]) {
        NSString *msg = validate[kValidateMsgKey]; // 错误提示信息
        [GSProgressHUD showWithTitle:msg inView:self.view];
        return NO;
    }
    return YES;
}

4.8 支持数据模型的类型完全自由自定义,可拆可合

某一行的业务数据可以独立存在 GSRow 的value中,也可以直接使用 控制器外部的属性/实例变量,根据实际的情况便利性决定; 同理,在配置请求参数时,也可以根据网络层设计的需要决定,如果是配置一个自定义Model,则事先在外部声明懒加载一个请求参数,在 httpConfigBlock 中对应属性进行设值,如果是配置一个 字典,则可以独立提供一个 字典又或者干脆对外部的一个可变字典设值。

4.9 支持设置row的白名单和黑名单及权限管理

在特定的场景下,只能编辑个别cell,这些可以编辑的cell应加入白名单;在另外一个特定的场景下,不能编辑个别cell,这些不能编辑的cell应加入黑名单,在白黑名单之上,可能还夹杂一些特定权限的控制,使得只有特定权限时才可以编辑。针对这类需求,通过在 cell 视图上层覆盖一个可操作性拦截按钮进行处理,通过配置 GSRow 的 enableValidateBlock 和 disableValidateBlock 属性进行实现。

/// GSForm
/// 传入此值实现全局禁用,此时点击事件的 block 
@property (nonatomic, copy) id(^disableBlock)(GSForm *);


/// GSRow 的黑名单
@property (nonatomic, copy) NSDictionary *(^disableValidateBlock)(id value, BOOL didClick);
/// GSRow的白名单
@property (nonatomic, copy) NSDictionary *(^enableValidateBlock)(id value, BOOL didClick);

延伸

经过在项目中的应用,这个框架基本成型,并具备相当高的定制能力和灵活性,在后续的功能开发上会进一步迭代。 以下是几个注意点:

  • 在一些 cell 不规则/规则的静态页面,也适合使用。
  • 此框架处处都是 block 的应用,因此应格外注意避免循环引用的发生,因为 控制器持有 GSForm 和 UITableView,所以在 GSRow 的 block 属性配置,以及内部 GSRow配置 cell 的 cellConfigBlock 内又有 cell.textChangeBlock 这类情况,需要进行双重的弱引用处理,比如:
    WEAK_SELF
    row.rowConfigBlock = ^(GSTCodeScanCell *cell, id value, NSIndexPath *indexPath) {
        STRONG_SELF
        cell.textChangeBlock = ^(NSString *text){
            value[kCellRightContent] = text;
        };
        
        /// 因为 cell 的block 是 强引用,所以这类需要再次设置弱引用。
        __weak typeof(strongSelf) weakWeakSelf = strongSelf; 
        cell.scanClickBlock = ^(){
            GSQRCodeController *scanVC = [[GSQRCodeController alloc] init];
            scanVC.returnScanBarCodeValue = ^(NSString *str) {
                value[kCellRightContent] = str;
                [weakWeakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
            };
            [weakWeakSelf.navigationController pushViewController:scanVC animated:YES];
        };
        
        cell.selectionStyle = UITableViewCellSelectionStyleNone;
    };
  • 此外也有许多其他方案可供学习:
  1. 最常提及的 XLForm@Github

分享

GitHub 和 Demo 下载

点击这里复制本文地址 以上内容由权冠洲的博客整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!

支持Ctrl+Enter提交

联系我们| 本站介绍| 留言建议 | 交换友链 | 域名展示
本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除

权冠洲的博客 © All Rights Reserved.  Copyright quanguanzhou.top All Rights Reserved
苏公网安备 32030302000848号   苏ICP备20033101号-1

联系我们