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

發表於2017-01-26

前言

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


需求分析:

11988593-c5a11178cace2ad4
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的圖片被替換,並在右上角出現紅色的刪除按鈕。

如下圖所示:

12988593-a8afd30b9960c8ef
6建立網店.png

正確地將檢視和資料繫結:

我們假設已經寫好了上面4種樣式cell的程式碼,現在我們在控制器裡為其填充資料。

我們首先定義一個表示cell資料的CreateShopModel。該model是為了給cell填充資料,可以看到它裡面的屬性就是cell上對應應該顯示的資料項。
同時,我們在開頭也定義了一個列舉CreateShopCellType來代表4種不同樣式的cell,用於在tableView返回cell的代理方法里根據列舉值來返回相應樣式的cell

我們在將tableView建立並新增在控制器的view上後便可以初始化資料來源了。該介面tableView的資料來源是_tableViewData陣列,資料的每項元素是代表cell顯示資料的CreateShopModel型別的model。準確地來說,這些資料是表單未填寫之前的死資料,所以需要我們手動地給裝入資料來源陣列中。而在輸入框輸入或者選取而得的資料則需要我們在輸入之後將其捕獲儲存下來,以等到提交時提交給伺服器,這個也有需要注意的坑點,後面再說。

現在我們的資料來源準備好了,但是tableView還沒做處理呢,要等tableView也配套完成後再重新整理tableView就OK了。我們來看tableView代理方法。

首先比較簡單的,在設定行高的代理方法裡,根據該行資料所表示的cellType型別來設定相應的行高。
然後在返回cell的代理方法裡,同樣以cellType來判斷返回相應樣式的cell,並給該cell賦相應的資料model。但是我們注意到,給cell賦值的方法,除了傳入我們前面說定義的CreateShopModel型別的createModel外,還有個名叫_shopFormModel引數被傳入。_shopFormModel是什麼,它代表什麼意思?

_shopFormModelCreateShopFormModel型別的一個例項物件,它用來表示這個表單需要提交的資料,它裡面的每個屬性基本上對應著表單提交給伺服器的欄位。我們最後不是要將表單資料作為引數去請求提交的介面嗎?表單資料從哪裡來,就從_shopFormModel中來。那_shopFormModel中的資料從哪裡來?

CreateShopTFCell為例,它所表示的欄位的資料是我們在輸入框輸入的,也就是說資料來自textField_shopFormModel物件在控制器被傳入cellrefreshContent:formModel:方法,在該方法內部,將引數formModel賦給成員變數_formModel需要格外注意的是,_shopFormModelformModel_ 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,分別為UIKeyboardWillShowNotificationUIKeyboardWillHideNotification。註冊這兩個觀察者,然後在兩者的回撥方法裡實現輸入框位移就大功告成了。

因為鍵盤遮擋的處理有可能是比較普遍的需求,所以在公司的專案架構設計裡是把上面兩個關於鍵盤的觀察是註冊在APPDelegate.m中的,並定義了一個有關鍵盤遮擋處理的協議,協議裡定義了一個方法。具體需要具體處理,由需要處理鍵盤遮擋問題的控制器來實現該協議方法,具體實現怎麼移動介面元素來使鍵盤不遮擋輸入框。這麼說現在CreateShopViewController控制器需要處理鍵盤遮擋問題,那麼就需要設定它為APPDelegate的代理,並由它實現所定義的協議嗎?其實不用,公司專案所有的控制器都是繼承於基類CommonViewController,在基類中實現了比較基本和普遍的功能,其實在基類中便定義了下面的方法來設定控制器為APPDelegate的代理,不過需要屬性isListensKeyboardYES。下面這個方法在CommonViewController中是在viewWillAppear:方法中呼叫的。那我們在子類CreateShopViewController中需要做的僅僅只要在viewWillAppear之前設定isListensKeyboard屬性為YES,便會自動設定將自己設為APPDelegate的代理。然後在CreateShopViewController控制器裡實現協議所定義的方法,實現具體的輸入框移動問題。

CommonViewController.m

CreateShopViewController.m

可以看到在該代理方法的實現裡。當鍵盤彈出時,我們首先將tableViewcontentSize在原來的基礎上增加了鍵盤的高度keyBoard_h。然後將tableViewcontentOffset值變為set_y,這個set_y的值是通過計算而來,但是計算它的_inputY這個變數代表什麼意思?

我們可以回過頭去看看tableView返回cell的代理方法中,當為CreateShopTFCell時,我們設定了當前控制器為其cell的代理。

並且我們的控制器CreateShopViewController也實現了該cell的協議CreateShopTFCellDelegate,並且也實現了協議定義的方法。

原來上面的_intputY變數就是該協議方法從cell裡的呼叫處傳遞而來的orginY引數值。我們回過頭看上面的程式碼,該協議方法是在textField的開始輸入的回撥方法裡呼叫的,給協議方法傳入的引數是self.frame.origin.y,即被點選的textField在手機螢幕內所在的Y座標值。

可以看到,處理鍵盤遮擋問題,其實也不是改變輸入框的座標位置,而是變動tableViewcontentSizecontentOffset屬性。


選取地址的實現:

CreateShopPickCell實現裡地址的選取和顯示。有左右兩個框框,點選任何一個將會從螢幕下方彈出一個選取器,選取器有“市”和“區”兩列資料對應兩個框框,選取器左上方是“取消”按鈕,右上方是“確定”按鈕。點選“取消”,選取器彈回,並不進行選取;點選“確定”,選取器彈回,選取選擇的資料。

13988593-a0c43e787b6c7fba
WechatIMG1.png

CreateShopPickCell的介面元素佈局沒什麼可說的,值得一說的是彈出的pickView檢視,是在cell的填充資料的方法中建立的。

這裡只是建立了pickView的物件,並設定了資料來源items,已經點選之後的回撥block,而並未將其新增在父檢視上。
要將選取的“市&區”的結果從CustomPickView中以block回撥到cell來,將資料賦給_formModel。並且當有了資料後UILabel的文字顏色也有變化。

pickView的物件已經建立好,但是還未到彈出顯示的時機。所謂時機,就是當左右兩個框框被點選後。
可以看到pickView是被新增在window上的。並且呼叫了pickView的介面方法showPickerView方法,讓其從螢幕底部彈出來。

前面程式碼中給pickView設定資料來源時,它的資料來源有點特別,呼叫了ShopAddressModel的類方法cityAddressArr來返回有關地址的資料來源陣列。這是因為這裡的地址資料雖然是從伺服器介面請求的,但是一般情況不會改變,最好是從伺服器拿到資料後快取在本地,當請求失敗或者無網路時仍不受影響。

ShopAddressModel類定義瞭如下幾個屬性和方法。

當我們我們從伺服器拿到返回而來的地址資料後,呼叫saveAddressArr:方法,將資料快取在本地。

當建立好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


上傳圖片到七牛:

當點選了“提交”按鈕後,先校驗資料,若所填寫的資料不合格,則給出提示資訊,讓使用者繼續完善資料;若資料無問題,校驗通過,則開始提交表單。但是,這裡有圖片,圖片我們是上傳到七牛伺服器的,提交表單是圖片項提交的應該是圖片在七牛的一個url。這個邏輯我在以前的這篇筆記已經捋過了APP上傳圖片至七牛的邏輯梳理

但是當時所有的邏輯都是寫在控制器裡的。我們這個“建立網店”的控制器已經很龐大了,寫在控制器裡不太好。所以在這裡我將上傳圖片的邏輯拆分了出去,新建了一個類`QNUploadPicManager。只暴露一個允許傳入UIImage引數的介面方法,便可以通過successBlock來返回上傳到七牛成功後的url。以及通過failureBlock來返回上傳失敗後的error資訊。而將所有的邏輯封裝在QNUploadPicManager內部,這樣控制器裡便精簡了不少程式碼,清爽了許多。

QNUploadPicManager.h

QNUploadPicManager.m


總結:

這個介面比較核心的一個問題就是:要在控制器裡提交表單,那怎樣把在UITableViewCell裡的textField輸入的資料傳遞給控制器? 另外一個問題是一個邏輯比較複雜的介面,控制器勢必會很龐大,應該有意的給控制器瘦身,不能把所有的邏輯都寫在控制器裡。有關檢視顯示的就考慮放入UITableViewCell,有關資料的就考慮放入model。這樣既為控制器瘦身,也使程式碼職責變清晰,耦合度降低。

另外,今天2016最後一天班了,週日就坐車回家過年了。提前祝各位新春快樂。

相關文章