前言
这几天项目的新需求中有个复杂的表单界面,在做的过程中发现要比想象中复杂很多,有好多问题需要处理。有很多东西值得写下来好好梳理下。
需求分析:
6创建网店1.png
上图便是UI根据需求给的高保真, 我们先根据这张图片来描述一下具体需求,明确一下我们都需要干些什么。
创建网店这个界面是一个复杂的表单,有“网店名称”、“网店主标签”、“网店简介”、“网店地址”、“网店座机”、“email”、“网店LOGO”、“网店封面图”这些项。大部分都是输入框,但也有几项有所不同。“网店地址”项,当被点击后会弹出一个pickView来选择“市&区”;“网店LOGO”和“网店封面图”是一样的,是选取图片的控件,要求既可以通过相册选取图片,也可以现场拍照选择。当被点击后,弹出一个ActionSheet来是以“拍照”或以“相册”来选取图片。当选取成功后拍照的背景图片变为被选取的图片,并在右上角出现一个删除按钮,可以删除还原再次选取。
表单中除了“email”外所有的项目都是必填的,且“网店名称”、“网店主标签”、“网店简介”和“网店座机”分别有30、20、500、15字的长度限制。“email”虽然为选填,但若填写了则会进行邮箱格式校验。对字数长度的限制要在输入过程中进行监听,若输入时超过限制,则输入框出现红色边框并出现提示文字。等***点击了“提交”按钮后要进行数据校验,所有该填但未填,所有格式不正确的项都会出现红框和提示文字,当所有数据都合法后才可以提交给服务器。
需求大体就是如此。
这个界面我们还是以tableView来实现,由cell视图来表示图中所需填写的项目。那我们得先分析下这个界面需要写哪几种样式的cell。
该界面总共有4种样式的cell。4种样式的cell样式也有共同点,每个cell左边部分均为表示该行所要填写的项目名称,右边部分则为填写或者选取的内容值,这些值的显示形式有所不同。 CreateShopTFCell和CreateShopTVCell其实非常类似,右边首先是一个灰色的背景视图,只不过在灰色背景之上的前者是textField,而后者是textView;CreateShopPickCell右边则是两个灰色背景视图,点击之后便弹出一个pickView供你选取“市&区”;CreateShopUploadPicCell右边则是一个UIImageView,无图片被选取时默认是一个相机的图片,当被点击后弹出ActionSheet供你选择拍照还是从相册选取照片,选好照片后UIImageView的图片被替换,并在右上角出现红色的删除按钮。
如下图所示:
6创建网店.png
正确地将视图和数据绑定:
我们假设已经写好了上面4种样式cell的代码,现在我们在控制器里为其填充数据。
我们首先定义一个表示cell数据的CreateShopModel。该model是为了给cell填充数据,可以看到它里面的属性就是cell上对应应该显示的数据项。
同时,我们在开头也定义了一个枚举CreateShopCellType来代表4种不同样式的cell,用于在tableView返回cell的代理方法里根据枚举值来返回相应样式的cell。
- #import
- typedef enum : NSUInteger {
- CreateShopCellType_TF = 0, // textfield
- CreateShopCellType_TV, // textView
- CreateShopCellType_PICK, // picker
- CreateShopCellType_PIC, // upload picture
- } CreateShopCellType;
- @interface CreateShopModel : NSObject
- @property (nonatomic, copy)NSString *title; // 所要填写的项目名称
- @property (nonatomic, copy)NSString *placeholder;
- @property (nonatomic, copy)NSString *key; // 表单对应的字段
- @property (nonatomic, copy)NSString *errText; // 校验出错时的提示信息
- @property (nonatomic, strong)UIImage *image; // 所选取的图片
- @property (nonatomic, assign)CreateShopCellType cellType; // cell的类型
- @property (nonatomic, assign)NSInteger maxInputLength; // ***输入长度限制
- @end
我们在将tableView创建并添加在控制器的view上后便可以初始化数据源了。该界面tableView的数据源是_tableViewData数组,数据的每项元素是代表cell显示数据的CreateShopModel类型的model。准确地来说,这些数据是表单未填写之前的死数据,所以需要我们手动地给装入数据源数组中。而在输入框输入或者选取而得的数据则需要我们在输入之后将其捕获存储下来,以等到提交时提交给服务器,这个也有需要注意的坑点,后面再说。
现在我们的数据源准备好了,但是tableView还没做处理呢,要等tableView也配套完成后再刷新tableView就OK了。我们来看tableView代理方法。
首先比较简单的,在设置行高的代理方法里,根据该行数据所表示的cellType类型来设置相应的行高。
然后在返回cell的代理方法里,同样以cellType来判断返回相应样式的cell,并给该cell赋相应的数据model。但是我们注意到,给cell赋值的方法,除了传入我们前面说定义的CreateShopModel类型的createModel外,还有个名叫_shopFormModel参数被传入。_shopFormModel是什么,它代表什么意思?
_shopFormModel是CreateShopFormModel类型的一个实例对象,它用来表示这个表单需要提交的数据,它里面的每个属性基本上对应着表单提交给服务器的字段。我们***不是要将表单数据作为参数去请求提交的接口吗?表单数据从哪里来,就从_shopFormModel中来。那_shopFormModel中的数据从哪里来?
- #import
- @interface CreateShopFormModel : NSObject
- @property (nonatomic, copy)NSString *groupId;
- @property (nonatomic, copy)NSString *groupName;
- @property (nonatomic, copy)NSString *tag;
- @property (nonatomic, copy)NSString *introduction;
- @property (nonatomic, copy)NSString *regionId;
- @property (nonatomic, copy)NSString *cityId;
- @property (nonatomic, copy)NSString *address;
- @property (nonatomic, copy)NSString *telephone;
- @property (nonatomic, copy)NSString *contactMail;
- @property (nonatomic, copy)NSString *coverUrl;
- @property (nonatomic, copy)NSString *logoUrl;
- @property (nonatomic, strong)UIImage *logo;
- @property (nonatomic, strong)UIImage *cover;
- @property (nonatomic, strong)NSIndexPath *indexPath;
- @property (nonatomic, strong)id indexPathObj;
- + (CreateShopFormModel *)formModelFromDict:(NSDictionary *)dict;
- -(BOOL)submitCheck:(NSArray*)dataArr;
- @end
以CreateShopTFCell为例,它所表示的字段的数据是我们在输入框输入的,也就是说数据来自textField,_shopFormModel对象在控制器被传入cell的refreshContent:formModel:方法,在该方法内部,将参数formModel赋给成员变量_formModel。需要格外注意的是,_shopFormModel、formModel和_ formModel是同一个对象,指向的是同一块内存地址。方法传递对象参数时只是“引用拷贝”,拷贝了一份对象的引用。既然这样,我们可以预想到,我们在cell内部,将textField输入的值赋给_formModel所指向的对象后,也即意味着控制器里的_shopFormModel也有数据了,因为它们本来就是同一个对象嘛!
事实正是如此。
可以看到我们在给textField添加的通知的回调方法textFiledEditChanged:里,将textField输入的值以KVC的方式赋值给了_formModel。此时_formModel的某属性,即该cell对应的表单的字段已经有了数据。同样的,在控制器中与_formModel指向同一块内存地址的_shopFormModel也有了数据。
我们看到在refreshContent:formModel:方法中,cell上的死数据是被CreateShopModel的实例对象createModel赋值的,而在其后我们又以KVC的方式又将_shopFormModel的某属性的值赋给了textField。这是因为我们为了防止cell在复用的过程中出现数据错乱的问题,而在给cell赋值前先将每个视图上的数据都清空了(即clearCellData方法),需要我们重新赋过。(不过,如果你没清空数据的情况下,不再次给textField赋值好像也是没问题的。不会出现数据错乱和滑出屏幕再滑回来时从复用池取出cell后赋值时数据消失的问题。)
输入长度的限制:
需求中要求“网店名称”、“网店主标签”、“网店简介”、“网店座机”都有输入长度的限制,分别为30、20、500、15字数的限制。其实我们在上面初始化数据源的时候已经为每行的数据源model设置过字数限制了,即maxInputLength属性。
我们还是以CreateShopTFCell为例。
要在开始输入的时候监听输入的长度,若字数超过***限制,则要出现红框,并且显示提示信息。那我们就得给textField开始输入时添加valueChange的观察,在textField输入结束时移除观察。
另外,可以看到在textField开始输入的回调方法里,调用了该cell的代理方法。该cell为什么要调用这个代理方法,它需要代理给别人来干什么?…其实这个和键盘遮挡的处理有关,下面我们慢慢解释。
处理键盘遮挡问题:
这个界面有很多行输入框,在自然情况下,下面的几个输入框肯定是在键盘弹出后高度之下的,也即会被键盘遮挡住,我们没法输入。这时就一定处理键盘遮挡问题了。
关于键盘遮挡问题,其实我在以前的一篇笔记中就写过了:UITextField一箩筐——输入长度限制、自定义placeholder、键盘遮挡问题
我们要处理键盘遮挡问题,也就是要实现当键盘弹出时,被遮挡住的输入框能上移到键盘高度之上;当键盘收回时,输入框又能移回原来的位置。那么首先***步,我们得能获取到键盘弹出或者收回这个动作的时机,在这个时机我们再按需要移动输入框的位置。系统提供了表示键盘弹出和收回的两个观察的key,分别为UIKeyboardWillShowNotification和UIKeyboardWillHideNotification。注册这两个观察者,然后在两者的回调方法里实现输入框位移就大功告成了。
因为键盘遮挡的处理有可能是比较普遍的需求,所以在公司的项目架构设计里是把上面两个关于键盘的观察是注册在APPDelegate.m中的,并定义了一个有关键盘遮挡处理的协议,协议里定义了一个方法。具体需要具体处理,由需要处理键盘遮挡问题的控制器来实现该协议方法,具体实现怎么移动界面元素来使键盘不遮挡输入框。这么说现在CreateShopViewController控制器需要处理键盘遮挡问题,那么就需要设置它为APPDelegate的代理,并由它实现所定义的协议吗?其实不用,公司项目所有的控制器都是继承于基类CommonViewController,在基类中实现了比较基本和普遍的功能,其实在基类中便定义了下面的方法来设置控制器为APPDelegate的代理,不过需要属性isListensKeyboard为YES。下面这个方法在CommonViewController中是在viewWillAppear:方法中调用的。那我们在子类CreateShopViewController中需要做的仅仅只要在viewWillAppear之前设置isListensKeyboard属性为YES,便会自动设置将自己设为APPDelegate的代理。然后在CreateShopViewController控制器里实现协议所定义的方法,实现具体的输入框移动问题。
CommonViewController.m
- -(void)initListensKeyboardNotificationDelegate
- {
- if (!self.isListensKeyboard) {
- return;
- }
- if (!self.appDelegate) {
- self.appDelegate=(AppDelegate*)[[UIApplication sharedApplication] delegate];
- }
- [self.appDelegate setKeyboardDelegate:self];
- }
CreateShopViewController.m
可以看到在该代理方法的实现里。当键盘弹出时,我们首先将tableView的contentSize在原来的基础上增加了键盘的高度keyBoard_h。然后将tableView的contentOffset值变为set_y,这个set_y的值是通过计算而来,但是计算它的_inputY这个变量代表什么意思?
我们可以回过头去看看tableView返回cell的代理方法中,当为CreateShopTFCell时,我们设置了当前控制器为其cell的代理。
- cell.cellDelegate = self;
并且我们的控制器CreateShopViewController也实现了该cell的协议CreateShopTFCellDelegate,并且也实现了协议定义的方法。
- #pragma mark - tfCell delegate
- - (void)cellBeginInputviewY:(CGFloat)orginY
- {
- _inputY = orginY;
- }
原来上面的_intputY变量就是该协议方法从cell里的调用处传递而来的orginY参数值。我们回过头看上面的代码,该协议方法是在textField的开始输入的回调方法里调用的,给协议方法传入的参数是self.frame.origin.y,即被点击的textField在手机屏幕内所在的Y坐标值。
可以看到,处理键盘遮挡问题,其实也不是改变输入框的坐标位置,而是变动tableView的contentSize和contentOffset属性。
选取地址的实现:
CreateShopPickCell实现里地址的选取和显示。有左右两个框框,点击任何一个将会从屏幕下方弹出一个选取器,选取器有“市”和“区”两列数据对应两个框框,选取器左上方是“取消”按钮,右上方是“确定”按钮。点击“取消”,选取器弹回,并不进行选取;点击“确定”,选取器弹回,选取选择的数据。
WechatIMG1.png
CreateShopPickCell的界面元素布局没什么可说的,值得一说的是弹出的pickView视图,是在cell的填充数据的方法中创建的。
这里只是创建了pickView的对象,并设置了数据源items,已经点击之后的回调block,而并未将其添加在父视图上。
要将选取的“市&区”的结果从CustomPickView中以block回调到cell来,将数据赋给_formModel。并且当有了数据后UILabel的文本颜色也有变化。
pickView的对象已经创建好,但是还未到弹出显示的时机。所谓时机,就是当左右两个框框被点击后。
可以看到pickView是被添加在window上的。并且调用了pickView的接口方法showPickerView方法,让其从屏幕底部弹出来。
- - (void)cityGestureHandle:(UITapGestureRecognizer *)tapGesture
- {
- [_superView endEditing:YES];
- [self showPicker];
- }
- - (void)areaGestureHandle:(UITapGestureRecognizer *)tapGesture
- {
- [_superView endEditing:YES];
- [self showPicker];
- }
- -(void)showPicker
- {
- [[PubicClassMethod getCurrentWindow] addSubview:_pickView];
- [_pickView showPickerView];
- }
前面代码中给pickView设置数据源时,它的数据源有点特别,调用了ShopAddressModel的类方法cityAddressArr来返回有关地址的数据源数组。这是因为这里的地址数据虽然是从服务器接口请求的,但是一般情况不会改变,***是从服务器拿到数据后缓存在本地,当请求失败或者无网络时仍不受影响。
ShopAddressModel类定义了如下几个属性和方法。
- @interface ShopAddressModel : NSObject
- @property (nonatomic, copy)NSString *addresssId;
- @property (nonatomic, copy)NSString *name;
- @property (nonatomic, strong)NSArray *subArr;
- #pragma mark - 地址缓存
- + (void)saveAddressArr:(NSArray *)addressArr;
- +(NSArray*)cityAddressArr;
- +(NSArray*)addressArr;
- #pragma mark - 解析
- + (ShopAddressModel *)addressModelFromDict:(NSDictionary *)dict;
- @end
当我们我们从服务器拿到返回而来的地址数据后,调用saveAddressArr:方法,将数据缓存在本地。
- + (void)saveAddressArr:(NSArray *)addressArr
- {
- if (addressArr && addressArr.count > 0) {
- NSData *data = [NSKeyedArchiver archivedDataWithRootObject:addressArr];
- [[NSUserDefaults standardUserDefaults] setObject:data forKey:@"saveAddressArr"];
- }else
- {
- [[NSUserDefaults standardUserDefaults]setObject:nil forKey:@"saveAddressArr"];
- }
- [[NSUserDefaults standardUserDefaults] synchronize];
- }
当创建好pickView后以下面方法将本地缓存数据读出,赋给items作为数据源。
注意:这也是为什么把创建pickView的代码放在了填充cell数据的refreshContent:formModel:里,而不在创建cell界面元素时一气创建pickView。因为那样当用户***次打开这个界面,有可能数据来的比较慢,当代码执行到赋数据源items时,本地还没有被缓存上数据呢!这样用户***次进入这个界面时弹出的pickView是空的,没有数据。而放在refreshContent:formModel:中是安全稳妥的原因是,每次从接口拿到数据后我们会刷新tableView,便会执行refreshContent:formModel:方法。它能保证先拿到数据,再设置数据源的顺序。
提交表单时校验数据:
在将表单数据提交前,要先校验所填写的表单是否有问题,该填的是否都填了,已填的数据格式是否是对的。若有问题,则要出现红框和提示信息提醒用户完善,等数据无误后才可以提交给服务器。
数据校验代码很繁长,写在控制器里不太好。因为它是对表单数据的校验,那我们就写在CreateShopFormModel里,这样既可以给控制器瘦身,也可以降低耦合度,数据的归数据,逻辑的归逻辑。
从前面CreateShopFormModel.h的代码里我们其实已经看到了这个校验方法:submitCheck:。若某条CreateShopFormModel实例的数据不达要求,则在相应的CreateShopModel数据源对象的errText属性赋值,意为提示信息。该方法的返回值类型为BOOL值,有数据不合格则返回NO。此时,在调用该方法的外部,应该将tableView重新加载,因为此时在该方法内部,已将数据格式不合格的提示信息赋值给了相应的数据源model。
- - (BOOL)submitCheck:(NSArray*)dataArr
- {
- BOOL isSubmit=YES;
- if(self.groupName.length==0){
- if (dataArr.count>0) {
- CreateShopModel *cellObj=dataArr[0];
- cellObj.errText=@"网店名不能为空";
- }
- isSubmit=NO;
- }
- if(self.groupName.length>0){
- if(dataArr.count>0){
- if(self.groupName.length>30){
- CreateShopModel *cellObj=dataArr[0];
- cellObj.errText=@"最多30个字";
- isSubmit=NO;
- }
- }
- }
- if(self.tag.length==0){
- if (dataArr.count>1) {
- CreateShopModel *cellObj=dataArr[1];
- cellObj.errText=@"标签不能为空";
- }
- isSubmit=NO;
- }
- if(self.introduction.length==0){
- if (dataArr.count>2) {
- CreateShopModel *cellObj=dataArr[2];
- cellObj.errText=@"简介不能为空";
- }
- isSubmit=NO;
- }
- if(self.introduction.length>0){
- if(dataArr.count>2){
- if(self.introduction.length>30){
- CreateShopModel *cellObj=dataArr[2];
- cellObj.errText=@"最多500个字";
- isSubmit=NO;
- }
- }
- }
- if(self.regionId.length==0){
- if (dataArr.count>3) {
- CreateShopModel *cellObj=dataArr[3];
- cellObj.errText=@"市区不能为空";
- }
- isSubmit=NO;
- }
- if(self.address.length==0){
- if (dataArr.count>4) {
- CreateShopModel *cellObj=dataArr[4];
- cellObj.errText=@"地址不能为空";
- }
- isSubmit=NO;
- }
- if(self.telephone.length==0){
- if (dataArr.count>5) {
- CreateShopModel *cellObj=dataArr[5];
- cellObj.errText=@"电话不能为空";
- }
- isSubmit=NO;
- }
- if (self.contactMail.length>0) {
- if (dataArr.count>6) {
- CreateShopModel *cellObj=dataArr[6];
- if(![PubicClassMethod isValidateEmail:self.contactMail]){
- cellObj.errText=@"邮箱格式不合法";
- isSubmit=NO;
- }
- }
- }
- if(self.logoUrl.length==0&&!self.logo){
- if (dataArr.count>7) {
- CreateShopModel *cellObj=dataArr[7];
- cellObj.errText=@"logo不能为空";
- }
- isSubmit=NO;
- }
- if(self.coverUrl.length==0&&!self.cover){
- if (dataArr.count>8) {
- CreateShopModel *cellObj=dataArr[8];
- cellObj.errText=@"封面图不能为空";
- }
- isSubmit=NO;
- }
- return isSubmit;
- }
上传图片到七牛:
当点击了“提交”按钮后,先校验数据,若所填写的数据不合格,则给出提示信息,让用户继续完善数据;若数据无问题,校验通过,则开始提交表单。但是,这里有图片,图片我们是上传到七牛服务器的,提交表单是图片项提交的应该是图片在七牛的一个url。这个逻辑我在以前的这篇笔记已经捋过了APP上传图片至七牛的逻辑梳理。
但是当时所有的逻辑都是写在控制器里的。我们这个“创建网店”的控制器已经很庞大了,写在控制器里不太好。所以在这里我将上传图片的逻辑拆分了出去,新建了一个类`QNUploadPicManager。只暴露一个允许传入UIImage参数的接口方法,便可以通过successBlock来返回上传到七牛成功后的url。以及通过failureBlock来返回上传失败后的error信息。而将所有的逻辑封装在QNUploadPicManager内部,这样控制器里便精简了不少代码,清爽了许多。
QNUploadPicManager.h
- @interface QNUploadPicManager : NSObject
- - (void)uploadImage:(UIImage *)image successBlock:(void(^)(NSString *urlStr))successBlock failureBlock:(void(^)(NSError *error))failureBlock;
- @end
QNUploadPicManager.m
总结:
这个界面比较核心的一个问题就是:要在控制器里提交表单,那怎样把在UITableViewCell里的textField输入的数据传递给控制器? 另外一个问题是一个逻辑比较复杂的界面,控制器势必会很庞大,应该有意的给控制器瘦身,不能把所有的逻辑都写在控制器里。有关视图显示的就考虑放入UITableViewCell,有关数据的就考虑放入model。这样既为控制器瘦身,也使代码职责变清晰,耦合度降低。