Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

beforeold/GSForm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

11 Commits

Repository files navigation

GSForm

simple but powerful lib for Form

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 下载

个人博客

欢迎你加入我的圈子讨论。

About

simple but powerful lib for Form

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

AltStyle によって変換されたページ (->オリジナル) /