写一个iOS复杂表单的正确姿势

前言

这几天项目的新需求中有个复杂的表单界面,在做的过程中发现要比想象中复杂很多,有好多问题需要处理。有很多东西值得写下来好好梳理下。


需求分析:

6创建网店1.png

上图便是UI根据需求给的高保真, 我们先根据这张图片来描述一下具体需求,明确一下我们都需要干些什么。

创建网店这个界面是一个复杂的表单,有“网店名称”、“网店主标签”、“网店简介”、“网店地址”、“网店座机”、“email”、“网店LOGO”、“网店封面图”这些项。大部分都是输入框,但也有几项有所不同。“网店地址”项,当被点击后会弹出一个pickView来选择“市&区”;“网店LOGO”和“网店封面图”是一样的,是选取图片的控件,要求既可以通过相册选取图片,也可以现场拍照选择。当被点击后,弹出一个ActionSheet来是以“拍照”或以“相册”来选取图片。当选取成功后拍照的背景图片变为被选取的图片,并在右上角出现一个删除按钮,可以删除还原再次选取。

表单中除了“email”外所有的项目都是必填的,且“网店名称”、“网店主标签”、“网店简介”和“网店座机”分别有30、20、500、15字的长度限制。“email”虽然为选填,但若填写了则会进行邮箱格式校验。对字数长度的限制要在输入过程中进行监听,若输入时超过限制,则输入框出现红色边框并出现提示文字。等最后点击了“提交”按钮后要进行数据校验,所有该填但未填,所有格式不正确的项都会出现红框和提示文字,当所有数据都合法后才可以提交给服务器。

需求大体就是如此。

这个界面我们还是以tableView来实现,由cell视图来表示图中所需填写的项目。那我们得先分析下这个界面需要写哪几种样式的cell

该界面总共有4种样式的cell。4种样式的cell样式也有共同点,每个cell左边部分均为表示该行所要填写的项目名称,右边部分则为填写或者选取的内容值,这些值的显示形式有所不同。 CreateShopTFCellCreateShopTVCell其实非常类似,右边首先是一个灰色的背景视图,只不过在灰色背景之上的前者是textField,而后者是textViewCreateShopPickCell右边则是两个灰色背景视图,点击之后便弹出一个pickView供你选取“市&区”;CreateShopUploadPicCell右边则是一个UIImageView,无图片被选取时默认是一个相机的图片,当被点击后弹出ActionSheet供你选择拍照还是从相册选取照片,选好照片后UIImageView的图片被替换,并在右上角出现红色的删除按钮。

如下图所示:

6创建网店.png

正确地将视图和数据绑定:

我们假设已经写好了上面4种样式cell的代码,现在我们在控制器里为其填充数据。

我们首先定义一个表示cell数据的CreateShopModel。该model是为了给cell填充数据,可以看到它里面的属性就是cell上对应应该显示的数据项。
同时,我们在开头也定义了一个枚举CreateShopCellType来代表4种不同样式的cell,用于在tableView返回cell的代理方法里根据枚举值来返回相应样式的cell

#import <Foundation/Foundation.h>

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。准确地来说,这些数据是表单未填写之前的死数据,所以需要我们手动地给装入数据源数组中。而在输入框输入或者选取而得的数据则需要我们在输入之后将其捕获存储下来,以等到提交时提交给服务器,这个也有需要注意的坑点,后面再说。

- (void)intDataSource
{
    _tableViewData = [NSMutableArray array];
    
    CreateShopModel *nameModel = [[CreateShopModel alloc] init];
    nameModel.title = @"网店名称";
    nameModel.placeholder = @"请输入网店名称";
    nameModel.key = @"groupName";
    nameModel.cellType = CreateShopCellType_TF;
    nameModel.maxInputLength = 30;
    [_tableViewData addObject:nameModel];
    
    CreateShopModel *mainTagModel = [[CreateShopModel alloc] init];
    mainTagModel.title = @"网店主标签";
    mainTagModel.placeholder = @"请输入网店主标签";
    mainTagModel.key = @"tag";
    mainTagModel.cellType = CreateShopCellType_TF;
    mainTagModel.maxInputLength = 20;
    [_tableViewData addObject:mainTagModel];
    
    CreateShopModel *descModel = [[CreateShopModel alloc] init];
    descModel.title = @"网店简介";
    descModel.placeholder = @"请输入网店简介";
    descModel.key = @"introduction";
    descModel.cellType = CreateShopCellType_TV;
    descModel.maxInputLength = 500;
    [_tableViewData addObject:descModel];
    
    CreateShopModel *addressModel = [[CreateShopModel alloc] init];
    addressModel.title = @"网店地址";
    addressModel.placeholder = @"";
    addressModel.key = @"regionId";
    addressModel.cellType = CreateShopCellType_PICK;
    [_tableViewData addObject:addressModel];
    
    CreateShopModel *doorIDModel = [[CreateShopModel alloc] init];
    doorIDModel.title = @"";
    doorIDModel.placeholder = @"请输入详细门牌号";
    doorIDModel.key = @"address";
    doorIDModel.cellType = CreateShopCellType_TF;
    [_tableViewData addObject:doorIDModel];
    
    CreateShopModel *phoneModel = [[CreateShopModel alloc] init];
    phoneModel.title = @"网店座机";
    phoneModel.placeholder = @"请输入网店座机";
    phoneModel.key = @"telephone";
    phoneModel.cellType = CreateShopCellType_TF;
    phoneModel.maxInputLength = 15;
    [_tableViewData addObject:phoneModel];
    
    CreateShopModel *emailModel = [[CreateShopModel alloc] init];
    emailModel.title = @"email";
    emailModel.placeholder = @"请输入email(选填)";
    emailModel.key = @"contactMail";
    emailModel.cellType = CreateShopCellType_TF;
    [_tableViewData addObject:emailModel];
    
    CreateShopModel *logoModel = [[CreateShopModel alloc] init];
    logoModel.title = @"网店LOGO";
    logoModel.placeholder = @"";
    logoModel.key = @"logo";
    logoModel.urlKey = @"logoUrl";
    logoModel.cellType = CreateShopCellType_PIC;
    [_tableViewData addObject:logoModel];
    
    CreateShopModel *coverPicModel = [[CreateShopModel alloc] init];
    coverPicModel.title = @"网店封面图";
    coverPicModel.placeholder = @"";
    coverPicModel.key = @"cover";
    coverPicModel.urlKey = @"coverUrl";
    coverPicModel.cellType = CreateShopCellType_PIC;
    [_tableViewData addObject:coverPicModel];
    
    if(_tableView){
        [_tableView reloadData];
    }
}

现在我们的数据源准备好了,但是tableView还没做处理呢,要等tableView也配套完成后再刷新tableView就OK了。我们来看tableView代理方法。

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return _tableViewData.count;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    CreateShopModel *createModel = _tableViewData[indexPath.row];
    if(createModel.cellType == CreateShopCellType_TF){
        return [CreateShopTFCell cellHeight:createModel];
    }else if(createModel.cellType == CreateShopCellType_TV){
        return [CreateShopTVCell cellHeight:createModel];
    }else if(createModel.cellType == CreateShopCellType_PICK){
        return [CreateShopPickCell cellHeight:createModel];
    }else if(createModel.cellType == CreateShopCellType_PIC){
        return [CreateShopUploadPicCell cellHeight: createModel];
    }
    return 50.f;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    CreateShopModel *createModel = _tableViewData[indexPath.row];
    
    if(createModel.cellType == CreateShopCellType_TF)
    {
        static NSString *tfCellId = @"tfCellId";
        CreateShopTFCell *cell = [tableView dequeueReusableCellWithIdentifier:tfCellId];
        if(cell==nil)
        {
            cell = [[CreateShopTFCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:tfCellId];
            cell.cellDelegate = self;
        }
        [cell refreshContent:createModel formModel:_shopFormModel];
        
        return cell;
    }
    else if(createModel.cellType == CreateShopCellType_TV)
    {
        static NSString *tvCellId = @"tvCellId";
        CreateShopTVCell *cell = [tableView dequeueReusableCellWithIdentifier:tvCellId];
        if(cell==nil)
        {
            cell = [[CreateShopTVCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:tvCellId];
        }
        [cell refreshContent:createModel formModel:_shopFormModel];
        
        return cell;
    }
    else if(createModel.cellType == CreateShopCellType_PICK)
    {
        static NSString *pickCellId = @"pickCellId";
        CreateShopPickCell *cell = [tableView dequeueReusableCellWithIdentifier:pickCellId];
        if(cell==nil)
        {
            cell = [[CreateShopPickCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:pickCellId];
        }
        NSString *valueStr = [_shopFormModel valueForKey:createModel.key];
        if(valueStr.length>0){
            createModel.errText = @"";
        }
        
        [cell refreshContent:createModel formModel:_shopFormModel];
        
        return cell;
    }
    
    else if(createModel.cellType == CreateShopCellType_PIC)
    {
        static NSString *picCellId = @"picCellId";
        CreateShopUploadPicCell *cell = [tableView dequeueReusableCellWithIdentifier:picCellId];
        if(cell==nil)
        {
            cell = [[CreateShopUploadPicCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:picCellId];
        }
        
        id value = [_shopFormModel valueForKey:createModel.key];
        if([value isKindOfClass:[NSString class]]){
            NSString *valueStr = (NSString *)value;
            if(valueStr.length>0){
                createModel.errText = @"";
            }
        }
        else if([value isKindOfClass:[UIImage class]]){
            UIImage *valueImg = (UIImage *)value;
            if(valueImg){
                createModel.errText = @"";
            }
        }

        __weak CreateShopViewController *weakSelf = self;
        [cell refreshContent:createModel formModel:_shopFormModel editBlock:^(CreateShopModel *shop) {
            if (shop) {
                _shopFormModel.indexPath = indexPath;
                _shopFormModel.indexPathObj = shop;
                [weakSelf iconActionSheet];
            }
        }];
        
        return cell;
    }
    return nil;
}

首先比较简单的,在设置行高的代理方法里,根据该行数据所表示的cellType类型来设置相应的行高。
然后在返回cell的代理方法里,同样以cellType来判断返回相应样式的cell,并给该cell赋相应的数据model。但是我们注意到,给cell赋值的方法,除了传入我们前面说定义的CreateShopModel类型的createModel外,还有个名叫_shopFormModel参数被传入。_shopFormModel是什么,它代表什么意思?

_shopFormModelCreateShopFormModel类型的一个实例对象,它用来表示这个表单需要提交的数据,它里面的每个属性基本上对应着表单提交给服务器的字段。我们最后不是要将表单数据作为参数去请求提交的接口吗?表单数据从哪里来,就从_shopFormModel中来。那_shopFormModel中的数据从哪里来?

#import <Foundation/Foundation.h>

@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对象在控制器被传入cellrefreshContent:formModel:方法,在该方法内部,将参数formModel赋给成员变量_formModel需要格外注意的是,_shopFormModelformModel_ formModel是同一个对象,指向的是同一块内存地址。方法传递对象参数时只是“引用拷贝”,拷贝了一份对象的引用。既然这样,我们可以预想到,我们在cell内部,将textField输入的值赋给_formModel所指向的对象后,也即意味着控制器里的_shopFormModel也有数据了,因为它们本来就是同一个对象嘛!

事实正是如此。
可以看到我们在给textField添加的通知的回调方法textFiledEditChanged:里,将textField输入的值以KVC的方式赋值给了_formModel。此时_formModel的某属性,即该cell对应的表单的字段已经有了数据。同样的,在控制器中与_formModel指向同一块内存地址的_shopFormModel也有了数据。

- (void)clearCellData
{
    _titleLab.text = @"";
    _textField.text = @"";
    _textField.placeholder = @"";
    _checkTipLab.text = @"";
}

- (void)refreshContent:(CreateShopModel *)createModel formModel:(CreateShopFormModel *)formModel
{
    [self clearCellData];
    if(!createModel){
        return;
    }
    _createModel = createModel;
    _formModel = formModel;
    _titleLab.text = createModel.title;
    _textField.placeholder = createModel.placeholder;
    _textField.text = [_formModel valueForKey:createModel.key]; // 将_formModel的值以KVC的方式赋给textField
    
    if(createModel.errText.length>0){
        _bgView.layer.borderColor = HexColor(0xcc2929).CGColor;
        _checkTipLab.text = createModel.errText;
    }else{
        _bgView.layer.borderColor = PDColor_Dividing_line.CGColor;
        _checkTipLab.text = @"";
    }
}

- (void)textFiledEditChanged:(NSNotification *)obj
{
    UITextField *textField = (UITextField *)obj.object;
    NSString *toBeString = textField.text;
    [_formModel setValue:textField.text forKey:_createModel.key]; // 将textField中的值赋给_formModel
    
    if(_createModel.maxInputLength&&toBeString.length>0&&toBeString.length>_createModel.maxInputLength){
        _bgView.layer.borderColor = HexColor(0xcc2929).CGColor;
        _checkTipLab.text = [NSString stringWithFormat:@"最多%d个字",(int)_createModel.maxInputLength];
    }else{
        _bgView.layer.borderColor = PDColor_Dividing_line.CGColor;
        _checkTipLab.text = @"";
    }

    if([_createModel.key isEqualToString:@"contactMail"]){
        _createModel.errText = @"";
    }else{
        NSString *valueStr = [_formModel valueForKey:_createModel.key];
        if(valueStr.length>0){
            _createModel.errText = @"";
        }
    }
}

我们看到在refreshContent:formModel:方法中,cell上的死数据是被CreateShopModel的实例对象createModel赋值的,而在其后我们又以KVC的方式又将_shopFormModel的某属性的值赋给了textField。这是因为我们为了防止cell在复用的过程中出现数据错乱的问题,而在给cell赋值前先将每个视图上的数据都清空了(即clearCellData方法),需要我们重新赋过。(不过,如果你没清空数据的情况下,不再次给textField赋值好像也是没问题的。不会出现数据错乱和滑出屏幕再滑回来时从复用池取出cell后赋值时数据消失的问题。)


输入长度的限制:

需求中要求“网店名称”、“网店主标签”、“网店简介”、“网店座机”都有输入长度的限制,分别为30、20、500、15字数的限制。其实我们在上面初始化数据源的时候已经为每行的数据源model设置过字数限制了,即maxInputLength属性。

我们还是以CreateShopTFCell为例。
要在开始输入的时候监听输入的长度,若字数超过最大限制,则要出现红框,并且显示提示信息。那我们就得给textField开始输入时添加valueChange的观察,在textField输入结束时移除观察。

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    [self clearNotification];
}

- (void)textFiledEditChanged:(NSNotification *)obj
{
    UITextField *textField = (UITextField *)obj.object;
    NSString *toBeString = textField.text;
    [_formModel setValue:textField.text forKey:_createModel.key]; // 将textField中的值赋给_formModel
    
    if(_createModel.maxInputLength&&toBeString.length>0&&toBeString.length>_createModel.maxInputLength){
        _bgView.layer.borderColor = HexColor(0xcc2929).CGColor;
        _checkTipLab.text = [NSString stringWithFormat:@"最多%d个字",(int)_createModel.maxInputLength];
    }else{
        _bgView.layer.borderColor = PDColor_Dividing_line.CGColor;
        _checkTipLab.text = @"";
    }

    if([_createModel.key isEqualToString:@"contactMail"]){
        _createModel.errText = @"";
    }else{
        NSString *valueStr = [_formModel valueForKey:_createModel.key];
        if(valueStr.length>0){
            _createModel.errText = @"";
        }
    }
}

-(void)addNotification
{
    [[NSNotificationCenter defaultCenter]addObserver:self selector:@selector(textFiledEditChanged:)
                                                name:@"UITextFieldTextDidChangeNotification"
                                              object:nil];
}

-(void)clearNotification{
    [[NSNotificationCenter defaultCenter]removeObserver:self
                                                   name:@"UITextFieldTextDidChangeNotification"
                                                 object:nil];
}

另外,可以看到在textField开始输入的回调方法里,调用了该cell的代理方法。该cell为什么要调用这个代理方法,它需要代理给别人来干什么?...其实这个和键盘遮挡的处理有关,下面我们慢慢解释。


处理键盘遮挡问题:

这个界面有很多行输入框,在自然情况下,下面的几个输入框肯定是在键盘弹出后高度之下的,也即会被键盘遮挡住,我们没法输入。这时就一定处理键盘遮挡问题了。
关于键盘遮挡问题,其实我在以前的一篇笔记中就写过了:UITextField一箩筐——输入长度限制、自定义placeholder、键盘遮挡问题

我们要处理键盘遮挡问题,也就是要实现当键盘弹出时,被遮挡住的输入框能上移到键盘高度之上;当键盘收回时,输入框又能移回原来的位置。那么首先第一步,我们得能获取到键盘弹出或者收回这个动作的时机,在这个时机我们再按需要移动输入框的位置。系统提供了表示键盘弹出和收回的两个观察的key,分别为UIKeyboardWillShowNotificationUIKeyboardWillHideNotification。注册这两个观察者,然后在两者的回调方法里实现输入框位移就大功告成了。

因为键盘遮挡的处理有可能是比较普遍的需求,所以在公司的项目架构设计里是把上面两个关于键盘的观察是注册在APPDelegate.m中的,并定义了一个有关键盘遮挡处理的协议,协议里定义了一个方法。具体需要具体处理,由需要处理键盘遮挡问题的控制器来实现该协议方法,具体实现怎么移动界面元素来使键盘不遮挡输入框。这么说现在CreateShopViewController控制器需要处理键盘遮挡问题,那么就需要设置它为APPDelegate的代理,并由它实现所定义的协议吗?其实不用,公司项目所有的控制器都是继承于基类CommonViewController,在基类中实现了比较基本和普遍的功能,其实在基类中便定义了下面的方法来设置控制器为APPDelegate的代理,不过需要属性isListensKeyboardYES。下面这个方法在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**

#pragma mark - keyboard delegate
- (void)keyboardChangeStatus:(KeyboardChangeType)changeType
                  beginFrame:(CGRect)beginFrame
                    endFrame:(CGRect)endFrame
                    duration:(CGFloat)duration
                    userInfo:(NSDictionary *)info
{
    if(changeType == KeyboardWillShow)
    {
        CGFloat keyBoard_h = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue].size.height;
        CGFloat newSizeh = _tableView.contentSize.height + keyBoard_h;
        [UIView animateWithDuration:duration animations:^{
            [_tableView setContentSize:CGSizeMake(PDWidth_mainScreen, newSizeh)];
            CGFloat set_y = _inputY+50.f+keyBoard_h-_tableView.bounds.size.height;
            if(set_y>0){
                [_tableView setContentOffset:CGPointMake(0, set_y)];
            }
        }];
    }
    else if(changeType == KeyboardWillHide)
    {
        [UIView animateWithDuration:duration animations:^{
            [_tableView setContentSize:CGSizeMake(PDWidth_mainScreen, _tableView.contentSize.height)];
        }];
    }
}

可以看到在该代理方法的实现里。当键盘弹出时,我们首先将tableViewcontentSize在原来的基础上增加了键盘的高度keyBoard_h。然后将tableViewcontentOffset值变为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坐标值。

可以看到,处理键盘遮挡问题,其实也不是改变输入框的坐标位置,而是变动tableViewcontentSizecontentOffset属性。


选取地址的实现:

CreateShopPickCell实现里地址的选取和显示。有左右两个框框,点击任何一个将会从屏幕下方弹出一个选取器,选取器有“市”和“区”两列数据对应两个框框,选取器左上方是“取消”按钮,右上方是“确定”按钮。点击“取消”,选取器弹回,并不进行选取;点击“确定”,选取器弹回,选取选择的数据。

WechatIMG1.png

CreateShopPickCell的界面元素布局没什么可说的,值得一说的是弹出的pickView视图,是在cell的填充数据的方法中创建的。

- (void)refreshContent:(CreateShopModel *)createModel formModel:(CreateShopFormModel *)formModel;
{
    [self clearCellData];
    if(!createModel){
        return;
    }
    
    [self createPickerView]; // 创建pickView

    _createModel = createModel;
    _formModel = formModel;

    _titleLab.text = createModel.title;
    
    if(formModel.regionId.length>0){
        ShopAddressModel *area=[[ShopAddressModel alloc]init];
        area.addresssId=formModel.regionId;
        [_pickView dafaultArea:area];
    }else{
        _cityLab.text = @"市";
        _cityLab.textColor = HexColor(0xc8c8c8);
        _areaLab.text = @"区";
        _areaLab.textColor = HexColor(0xc8c8c8);
    }
    
    if(createModel.errText.length>0){
        _cityBgView.layer.borderColor = HexColor(0xcc2929).CGColor;
        _areaBgView.layer.borderColor = HexColor(0xcc2929).CGColor;
        _checkTipLab.text = createModel.errText;
    }else{
        _cityBgView.layer.borderColor = PDColor_Dividing_line.CGColor;
        _areaBgView.layer.borderColor = PDColor_Dividing_line.CGColor;
        _checkTipLab.text = @"";
    }
}

这里只是创建了pickView的对象,并设置了数据源items,已经点击之后的回调block,而并未将其添加在父视图上。
要将选取的“市&区”的结果从CustomPickView中以block回调到cell来,将数据赋给_formModel。并且当有了数据后UILabel的文本颜色也有变化。

-(void)createPickerView
{
    if (!_pickView) {
        _pickView= [[CustomPickView alloc] init];
    }
    [_pickView setItems:[ShopAddressModel cityAddressArr]];
    [_pickView SelectedBlock:^(ShopAddressModel *city, ShopAddressModel *area) {
        if (city) {
            [_formModel setValue:city.addresssId forKey:_createModel.key];
            _cityLab.text = city.name;
            _cityLab.textColor = PDColor_Title_Black;
        }
        if (area) {
            [_formModel setValue:area.addresssId forKey:_createModel.key];
            _areaLab.text = area.name;
            _areaLab.textColor = PDColor_Title_Black;
        }
        
        if(city){
            _cityBgView.layer.borderColor = PDColor_Dividing_line.CGColor;
            _areaBgView.layer.borderColor = PDColor_Dividing_line.CGColor;
            _checkTipLab.text = @"";
            _createModel.errText=@"";
            
        }else{
            _cityBgView.layer.borderColor = HexColor(0xcc2929).CGColor;
            _areaBgView.layer.borderColor = HexColor(0xcc2929).CGColor;
            _checkTipLab.text = _createModel.errText;
        }
    }];
}

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作为数据源。

+(NSArray*)cityAddressArr
{
    NSArray *arr=[ShopAddressModel addressArr];
    ShopAddressModel *pro=[arr firstObject];
    if (pro.subArr.count>0) {
        return pro.subArr;
    }
    
    return nil;
}

+(NSArray*)addressArr
{
    NSData *data = [[NSUserDefaults standardUserDefaults] valueForKey:@"saveAddressArr"];
    NSArray *addrssArr=[NSKeyedUnarchiver unarchiveObjectWithData:data];
    
    if(addrssArr.count==0)
    {
        return nil;
    }
    
    NSMutableArray *areas=[[NSMutableArray alloc]init];
    for (int i=0; i<addrssArr.count; i++) {//省
        ShopAddressModel *prov=[ShopAddressModel  addressModelFromDict:addrssArr[i]];
        
        if (!prov) {
            continue;
        }
    
        NSMutableArray *cArr=[[NSMutableArray alloc]init];
        for (int j=0; j<prov.subArr.count; j++) {//市
            ShopAddressModel *city=[ShopAddressModel  addressModelFromDict:prov.subArr[j]];
            if (!city) {
                continue;
            }
            NSMutableArray *aArr=[[NSMutableArray alloc]init];
            for (int a=0; a<city.subArr.count; a++) {//区
                ShopAddressModel *are=[ShopAddressModel  addressModelFromDict:city.subArr[a]];
                if (!are) {
                    continue;
                }
                [aArr addObject:are];
            }
            
            
            if (aArr.count>0) {//市
                city.subArr=[NSArray arrayWithArray:aArr];
            }
            [cArr addObject:city];
        }
        
        
        if (cArr.count>0) {//省
            prov.subArr=[NSArray arrayWithArray:cArr];
        }
        
       [areas addObject:prov];
        
    }
    
    return areas;
}

注意:这也是为什么把创建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 **

#import "QNUploadManager.h"

#define kImageFilePath(name) [NSTemporaryDirectory() stringByAppendingPathComponent:name] // 图片路径

@implementation QNUploadPicManager

- (void)uploadImage:(UIImage *)image successBlock:(void(^)(NSString *urlStr))successBlock failureBlock:(void(^)(NSError *error))failureBlock
{
    NSString *logoFileName = [self fileNameWithPicture:image]; // fileName
    
    [self requestUploadToken:logoFileName successBlock:^(NSDictionary *dict) {
        [self uploadPicOnQNParameters:dict fileName:logoFileName complete:^(NSString *key, NSDictionary *resp) {
            [self getPictureUrlOnQN:key successBlock:^(NSString *urlStr) {
                successBlock(urlStr); // 成功回调
                
            } failure:^(NSError *error) {
                failureBlock(error);
            }];
        }];
        
    } failure:^(NSError *error) {
        failureBlock(error); // token获取失败回调
    }];
}


// get token
- (void)requestUploadToken:(NSString *)fileName successBlock:(void(^)(NSDictionary *dict))successBlock failure:(void(^)(NSError *error))failureBlock
{
    NSDictionary * parameters=[[NSDictionary alloc] initWithObjectsAndKeys:
                               @(1), @"count",
                               nil];
    
    NSString *url = [NSString stringWithFormat:@"%@/cbs/%@/upload/token",HTTPSURLEVER,Interface_Version];
    url =[url stringByReplacingOccurrencesOfString:@"http" withString:@"https"];
    AFHTTPRequestOperationManager *mgr = [AFHTTPRequestOperationManager manager];
    [mgr.requestSerializer setValue:[[REDUserModel shareInstance] token] forHTTPHeaderField:@"x-auth-token"];
    [mgr.requestSerializer setValue:@"ios" forHTTPHeaderField:@"_c"];
    [mgr.securityPolicy setAllowInvalidCertificates:YES];
    
    [mgr GET:url parameters:parameters success:^(AFHTTPRequestOperation *operation, id responseObject) {
        // key:5425734430926807040
        successBlock(DealWithJSONValue(responseObject[@"b"][0]));
        
    } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
        failureBlock(error);
    }];
}

// upload on QN
- (void)uploadPicOnQNParameters:(NSDictionary *)parameters fileName:(NSString *)fileName complete:(void(^)(NSString *key, NSDictionary *resp))complete
{
    QNUploadManager *uploader = [[QNUploadManager alloc] init];
    
    // 异步多线程
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        
        NSString *token = parameters[@"uploadToken"];
        NSString *key = parameters[@"key"];
        
        [uploader putFile:kImageFilePath(fileName) key:key token:token complete:^(QNResponseInfo *info, NSString *key, NSDictionary *resp) {
            // key:5425734430926807040
            complete(key, resp);
        } option:nil];
        
    });
}

//获取上传图片的url
- (void)getPictureUrlOnQN:(NSString *)token successBlock:(void(^)(NSString *urlStr))successBlock failure:(void(^)(NSError *error))failureBlock
{
    if(!token){
        token = @"";
    }
    NSDictionary * parameters=[[NSDictionary alloc] initWithObjectsAndKeys:
                               token, @"token",
                               nil];
    NSString *url = [NSString stringWithFormat:@"%@/cbs/%@/upload/url",HTTPSURLEVER,Interface_Version];
    url =[url stringByReplacingOccurrencesOfString:@"http" withString:@"https"];
    
    AFHTTPRequestOperationManager *mgr = [AFHTTPRequestOperationManager manager];
    
    [mgr.requestSerializer setValue:[[REDUserModel shareInstance] token] forHTTPHeaderField:@"x-auth-token"];
    [mgr.requestSerializer setValue:@"ios" forHTTPHeaderField:@"_c"];
    [mgr.securityPolicy setAllowInvalidCertificates:YES];
    
    [mgr GET:url parameters:parameters success:^(AFHTTPRequestOperation *operation, id responseObject) {
        if (!responseObject[@"b"] || [responseObject[@"b"] isEqual:[NSNull null]]) {
            return;
        }
        successBlock(DealWithJSONStringValue(responseObject[@"b"][@"url"]));
        
    } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
        failureBlock(error);
    }];
    
}

// save in file
- (NSString *)fileNameWithPicture:(UIImage *)image
{
    if(!image){
        return @"";
    }
    UIImage *newImage = [PubicClassMethod imageWithImageSimple:image scaledToSize:CGSizeMake(80, 80)]; // 图片压缩
    NSData *imageData = UIImageJPEGRepresentation(newImage, 1);
    NSString *fileName = [NSString stringWithFormat:@"%d.png",arc4random()];
    BOOL isWrited = [imageData writeToFile:kImageFilePath(fileName) atomically:YES];
    if(isWrited){
        return fileName;
    }
    return @"";
}

@end

总结:

这个界面比较核心的一个问题就是:要在控制器里提交表单,那怎样把在UITableViewCell里的textField输入的数据传递给控制器? 另外一个问题是一个逻辑比较复杂的界面,控制器势必会很庞大,应该有意的给控制器瘦身,不能把所有的逻辑都写在控制器里。有关视图显示的就考虑放入UITableViewCell,有关数据的就考虑放入model。这样既为控制器瘦身,也使代码职责变清晰,耦合度降低。

另外,今天2016最后一天班了,周日就坐车回家过年了。提前祝各位新春快乐。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,595评论 4 59
  • 文/郎溪县地税局 胡毓昕 收规“新鲜人”,展开雏鹰起飞的翅膀吧 很荣幸能参加此次宣城市国、地税局...
    地税青年小喇叭阅读 537评论 0 0
  • 一 列车终于缓慢的驶入了杭州东站。天刚亮,窗外的景色有点朦胧,一大早就出现了这种不真实感。我晃了晃脑袋驱赶困意,努...
    白牙厚先生阅读 372评论 0 0
  • 身边的可爱的老人一个个故去,时常想写一篇小文纪念他们,但是这个念头持续了几个年头,还是懒惰不曾写一个字,又一年过去...
    十年北山阅读 547评论 0 1
  • 日清——付文豪——7月13日 地址:上海 一、昨日工作 1.深化安亭万科的图纸和方案。 2.整理金山村委光伏图纸方...
    A文豪阅读 204评论 0 0