上一篇Flutter 開發常用的登錄頁面提到了文本框封裝度不夠,導致代碼複用性不高的問題。在實際開發過程中,往往開始是完成功能層面的開發,然而再考慮組件封裝和代碼優化。當然,組件封裝越早做越好,因爲這樣會提高整個團隊開發的規範性和效率。
UI組件封裝的考慮要點
封裝一個 UI 組件,通常需要考慮下面這三個點:
- 接口如何定義:即組件接收什麼輸入參數來控制組件的外觀和行爲;
- 與業務分離:UI 組件應當只負責界面,而不負責業務,具體的業務應當由業務層完成;
- 簡單易用:因爲是 UI 組件,要儘可能地簡單易用,方便使用者快速上手。
文本輸入框接口定義
首先先看一下我們上一篇使用文本框的代碼,這裏實際上只是調用了一個函數返回新的組件,之所以要這麼做是因爲用戶名、密碼使用了成員屬性,需要根據不同的成員屬性來設置不同的行爲,主要有:
- 文本框裝飾不同:包括佔位符、前置圖標,後置圖標的行爲綁定了成員屬性以及不同的 TextEditingCongtroller。
- onChanged 事件回調內容不同。
- 鍵盤類型和是否隱藏輸入內容不同。
- 對應表單的字段不同。
Widget _getPasswordInput() {
return _getInputTextField(
TextInputType.text,
obscureText: true,
controller: _passwordController,
decoration: InputDecoration(
hintText: "輸入密碼",
icon: Icon(
Icons.lock_open,
size: 20.0,
),
suffixIcon: GestureDetector(
child: Offstage(
child: Icon(Icons.clear),
offstage: _password == '',
),
onTap: () {
this.setState(() {
_password = '';
_passwordController.clear();
});
},
),
border: InputBorder.none,
),
onChanged: (value) {
this.setState(() {
_password = value;
});
},
);
}
而實際造成差別的原因是成員屬性之間的差異不同,如果是繼續使用成員屬性這種方式,無法解決這個問題。這時候我們可以考慮把整個表單放入一個 Map 中,Map裏配置不同字段對應的差異化屬性,然後就可以做到通用的接口了,我們可以定義封裝後的組件接口:
Widget _getInputTextFieldNew(
String formKey,
String value, {
TextInputType keyboardType = TextInputType.text,
FocusNode focusNode,
TextEditingController controller,
Function onChanged,
String hintText,
IconData prefixIcon,
Function onClear,
bool obscureText = false,
height = 50.0,
margin = 10.0,
}) {
//......
}
新增的參數如下:
- formKey:表示文本框對應的是表單Map的哪個鍵;
- value:當前表單的值,用於控制是否顯示清除按鈕
- onClear:定義右側清除按鈕的行爲響應
- onChanged:輸入內容變比回調
代碼實現
抽離後的代碼與業務頁面無關,因此需要抽離代碼,在 lib 目錄下新增一個 components 目錄,增加一個 form_util.dart 文件,用於存放通用的表單組件。實現的代碼如下所示:
class FormUtil {
static Widget textField(
String formKey,
String value, {
TextInputType keyboardType = TextInputType.text,
FocusNode focusNode,
TextEditingController controller,
Function onChanged,
String hintText,
IconData prefixIcon,
Function onClear,
bool obscureText = false,
height = 50.0,
margin = 10.0,
}) {
return Container(
height: height,
margin: EdgeInsets.all(margin),
child: Column(
children: [
TextField(
keyboardType: keyboardType,
focusNode: focusNode,
obscureText: obscureText,
controller: controller,
decoration: InputDecoration(
hintText: hintText,
icon: Icon(
prefixIcon,
size: 20.0,
),
border: InputBorder.none,
suffixIcon: GestureDetector(
child: Offstage(
child: Icon(Icons.clear),
offstage: value == null || value == '',
),
onTap: () {
onClear(formKey);
},
),
),
onChanged: (value) {
onChanged(formKey, value);
}),
Divider(
height: 1.0,
color: Colors.grey[400],
),
],
),
);
}
}
組件使用
首先是使用 Map 定義當前頁面的表單內容,以便控制不同表單字段的呈現形式。
Map<String, Map<String, Object>> _formData = {
'username': {
'value': '',
'controller': TextEditingController(),
'obsecure': false,
},
'password': {
'value': '',
'controller': TextEditingController(),
'obsecure': true,
},
};
其次是定義統一的文本框 onChanged 和 onClear 方法,對應爲 _handleTextFieldChanged和_handleClear。通過 formKey 回傳的字段,可以更新對應 _formData 的內容。這裏注意使用了 as用法用於將一個 Object 轉換爲TextEditingController。這種轉換如果 Object 對應的類型是TextEditingController的話能夠成功轉換,也能正常執行後面的 clear()方法。但是如果是 null,直接這時候執行 clear()方法,會報空指針異常。因此在轉換結果後面加了個問號,這個表示是如果是 null 後面的方法不會執行,從而不會出現空指針異常。這是 Flutter 2.0引入的 null safety 特性。其實這個特效在 PHP 7,Swift 語言早就有應用了。
_handleTextFieldChanged(String formKey, String value) {
this.setState(() {
_formData[formKey]['value'] = value;
});
}
_handleClear(String formKey) {
this.setState(() {
_formData[formKey]['value'] = '';
(_formData[formKey]['controller'] as TextEditingController)?.clear();
});
}
之後是在使用 textField 的地方使用 FormUtil.textField 方法直接使用封裝好的文本框:
//...
FormUtil.textField(
'username',
_formData['username']['value'],
controller: _formData['username']['controller'],
hintText: '請輸入手機號',
prefixIcon: Icons.mobile_friendly,
onChanged: _handleTextFieldChanged,
onClear: _handleClear,
),
FormUtil.textField(
'password',
_formData['password']['value'],
controller: _formData['password']['controller'],
obscureText: true,
hintText: '請輸入密碼',
prefixIcon: Icons.lock_open,
onChanged: _handleTextFieldChanged,
onClear: _handleClear,
),
//...
可以看到,username和 password 兩個表單字段複用了_handleTextFieldChanged和_handleClear。整個代碼長度也減少了近50%,而且封裝的 FormUtil.textField 文本框也可以用於其他表單頁面,整個代碼的維護性和複用性都相比前一篇的有了很大的提高。
踩坑記錄
在封裝文本框的時候,直接將 onClear 函數複製給了GesureDetector 的 onTap 屬性,結果每次輸入都會觸發onClear 自動清空文本框內容。後來發現實際應該是傳一個回調函數,下面列出了兩種方式的不同:
//...
//錯誤的方式
onTap:onClear,
//...
//...
//正確的方式
onTap:() {
onClear(formKey);
},
//...
總結
封裝UI 組件在實際開發過程中非常常見,一般來說當我們看到設計稿的時候,首先是將設計稿拆分成多個組件,然後考慮一下其中的一些組件是不是在其他場合也會用到。如果有可能用到,就可以考慮封裝。封裝的時候先考慮對外接口參數,然後注意UI 組件不應該涉及到業務,再就是儘可能地簡便(比如有一些默認值,減少必傳參數)。當然,如果公司在一開始就能夠由產品、設計和開發一起定一套組件,提前封裝將會使得後面的開發效率更高,但這取決於項目時間的緊急程度,時間充裕的話可以這麼考慮。