寫一個iOS復雜表單的正確姿勢

tbnk8996 7年前發布 | 19K 次閱讀 iOS開發 移動開發

前言

這幾天項目的新需求中有個復雜的表單界面,在做的過程中發現要比想象中復雜很多,有好多問題需要處理。有很多東西值得寫下來好好梳理下。

需求分析:

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。這樣既為控制器瘦身,也使代碼職責變清晰,耦合度降低。

 

來自:http://mobile.51cto.com/iphone-529941.htm

 

 本文由用戶 tbnk8996 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!