内容简介:轻量级,只有4个类,1个控制器通常,将一个页面需要编辑/录入多项信息的页面称为“表单页面”,以下称
轻量级,只有4个类,1个控制器 Controller
,3个视图模型 ViewModel
支持** iOS8 及以上 **
-
支持完全自定义单元格
cell
类型
-
支持自动布局
Autolayout
和固定行高 -
表单每行
row
数据和事件整合为一个model
,基本只需管理row
-
积木式组合
row
,支持section
和row
的隐藏,易于维护 - 支持传入外部数据
- 支持快速提取数据
- 支持参数的最终合法性校验
- 支持数据模型的类型完全自由自定义,可拆可合
-
支持设置
row
的白名单和黑名单及权限管理
2.背景
通常,将一个页面需要编辑/录入多项信息的页面称为“表单页面”,以下称 表单 ,以某注册页面为例:
在移动端进行表单的录入设计本身因为录入效率低,是尽量避免的,但对于特定的业务场景还是有存在的情况。通常基于 UITableView 进行开发,内容多有文本输入、日期(或者其他PickerView)、各类自定义的单元格 cell
(比如包含 UISwitch、UIStepper等)、以及一些需要前往二级页面获取信息后回调等元素。
表单的麻烦在于行与行之间数据往往没有特定的规律,上图中第二组数据中,姓名、性别、出生日期以及年龄,4个不同的 cell 则是 4个完全不同的交互方式来录入数据,依照传统的 UITableView 的代理模式来处理,有几个弊端:
tableView:cellForRowAtIndexPath: cell
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; }; 复制代码
- 此外也有许多其他方案可供学习:
- 最常提及的 XLForm@Github 。
- 简书J_Knight前不久的[基于MVVM,用于快速搭建设置页,个人信息页的框架]。( www.jianshu.com/p/1f89513f3… )
- @靛青K 的 iOS 上基于 RxSwift 的动态表单填写 。
- 收到新的反馈,适用于常规 TableView 的全面且经过考验的封装方案Haidora 的 HaidoraTableViewManager 。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
零售的哲学:7-Eleven便利店创始人自述
[日] 铃木敏文 / 顾晓琳 / 江苏文艺出版社 / 2014-12-1 / 36
全球最大的便利店连锁公司创始人——铃木敏文,结合40多年零售经验,为你讲述击中消费心理的零售哲学。铃木敏文的很多创新,现在已经成为商界常识,本书把那些不可思议的零售创新娓娓道来。关于零售的一切:选址、订货、销售、物流、管理……他一次又一次地在一片反对声中创造出零售界的新纪录。 翻开本书,看铃木敏文如何领导7-11冲破层层阻碍,成为世界第一的零售哲学。一起来看看 《零售的哲学:7-Eleven便利店创始人自述》 这本书的介绍吧!