截至目前為止,雖然我們已經能處理手勢進行互動了,但這樣的應用程式用途還是極其有限。為了取得使用者的資料,我們會需要表單與相關組件。
Flutter 支援了各種輸入組件協助開發者取得各類型的資料,包括之前介紹的 TextField
, Selector
和 Picker
等。下面就讓我們進一步探討。
TextField
組件可以讓使用者通過鍵盤輸入文字。TextField
的 onChanged
參數可以接受一個函式並且監聽當前值的變化。這是追蹤 TextField
中,值變化最簡單的方式,類似之前按鈕的 onPressed
TextField(
onChanged: (text) {
print(text);
}
)
一樣可以傳入一個匿名函式來處理對應的行為。到此我們學習了一個可以輸入文字互動的介面組件。但除了使用 onChanged
這種最簡單的方式外,另外還有一種可以追蹤值變化的方式 - 使用 controller
。
使用一般 TextField
組件時,我們也可以使用 controller
參數來存取當前 TextField
的值 - 也就是使用者目前輸入的資料。
除了上面介紹的 onChanged
參數,我們還可以使用 TextEditingController
類別。實例化一個控制器如下:
final _controller = TextEditingController();
又或者,可以使用工廠模式建構子同時設定一個初始化的值
final _controller = TextEditingController.fromValue(
TextEditingValue(text: "初始值")
);
如上面範例工廠模式建構子可以接受一個 TextEditingValue
的參數。通過 TextEditingValue
設定 text
參數就可以設定初始值。
實例化 TextEditingController
之後,我們就可以把它傳入 TextField
的 controller
參數,然後用這個控制器來控制組件:
TextField(
controller: _controller,
);
每當 TextField
有新值的時候就會通知 TextEditingController
。然後為了監聽變化,我們還需要幫 _controller
加入監聽事件:
_controller.addListener(() {
print(_controller.text);
});
進階補充:
controller
現在就像躲在TextField
背後一樣,它會得知TextField
的狀態。但外層的組件並不會知道,因此我們需要像網頁程式綁定事件一樣使用addListener()
,如此一來當controller
收到變化的時候會執行我們註冊的事件。如此外層結構就可以進行對應的操作。另外一個設計不同的慣例,如果是網頁你可以使用
addEventListener('click', () => {})
註冊不同事件,但在 Flutter 通常會由一個物件處理一個任務。例如:若我們想處理focus
那麼就會FocusNode focus = FocusNode(); focus.addListener(() {});
基本上
onChanged
可以處理大部分的情境,但遇上一些情況例如我們需要動態決定輸入值的時候,又或者例如實現自動完成功能需要監聽指標的位置和選擇範圍時我們就會需要搭配controller
。
每當 TextField
組件發生變化就會執行上面加入的監聽事件。在這個例子,我們只是單純使用 print
展示我們可以通過 _controller.text
來取得 TextField
目前的值。
接著,監聽器必須要在 build
方法渲染組件之前註冊,這樣才能確保監聽所有的事件。而組件生命週期最適合的就是 initState
方法:
@override
void initState() {
super.initState();
_controller.addListener(() {
print(_controller.text);
});
}
通常在呼叫 super.initState()
之後進行註冊。現在我們已經構建了 _controller
,加入監聽器,也把控制器傳入 TextField
。最後一件事情就是,當組件移除的時候我們也需要移除監聽。
使用任何控制器都需要注意:當組件移除的時候,控制器和監聽事件也要釋放才不會佔用資源或繼續監聽進而嘗試去存取變更一個已經被移除的組件。最適合的生命週期就是 dispose
。
@override
void dispose() {
_controller.dispose();
super.dispose();
}
到此我們學習了兩個作法,不管控制器還是 onChanged
參數的作法,也適用於其他輸入組件。
但大多數情況下,你可能不會只是建立一個欄位,而是一個包含一系列輸入欄位組件的表單,並且支援驗證和反饋的功能。下面讓我們來看看 Form
。
Flutter 提供了兩個組件協助進行表單處理包含資料儲存,檢查,提供反饋等 - 即 Form
和 FormField
組件。
Form
組件負責保存在表單內欄位的狀態。也就是被 Form
包著的 FormField
們,由 Form
管理。FormField
作為一個基礎類別,可以用來擴展客製化的欄位組件,主要有以下幾個功能:
很多 Flutter 內建的輸入資料組件都有對應 FormField
的實作。舉例來說 TextField
組件對應有一個表單專用的組件 - TextFormField
。TextFormField
協助我們存取 TextField
的值,然後加入表單相關的行為如驗證等。
雖然。FormField
組件通常會有一個 Form
包起來,但這不是必須的。例如:當我們只有一個 FormField
的時候,可能就不需要使用 Form
來管理更新。
我們先從獨立的 FormField
操作開始介紹,然後搭配 Form
的使用。
這裡讓我們要先釐清一下:
TextField
這是一個輸入框組件,只負責 UI 和輸入文字相關互動,它沒有保存狀態或檢查的能力。Form
是一個容器,組織多個欄位組件。FormField
這個類別本身並沒有太多實作,只是定義了表單欄位需要支援的相關功能如重置、驗證等,但它不負責處理 UI,主要是處理狀態資料。TextFormField
繼承 FormField
並兼具 TextField
呈現 UI 的能力,也就是在 TextField
的基礎上增加了 FormField
的功能,一般搭配 Form
使用。當我們使用 TextFormField
組件時,可以通過一個特別的方式來管理和存取 FormField
的狀態就是使用 GlobalKey<FormFieldState<String>>
。
final _key = GlobalKey<FormFieldState<String>>();
TextFormField(
key: _key,
);
將 _key
傳入 TextFormField
後續可以用於存取組件當前的狀態 _key.currentState
,該屬性會有最新更新的欄位值。
在前面我們已經使用過 Key ,當時的需求是要用 Key 來區分相同的組件,例如列表中的項目組件,Key 的用途是作為組件的唯一識別,處理表單的時候,能夠讓我們針對特定欄位進行操作。
而這種「指定型別的 Key」其實表示這 Key 是為了處理特定型別資料而設計的,也就是在告訴 Flutter 這個 Key 是專門用來處理特定型別資料的。上面例子中 TextFormField
,用途是讓使用者輸入文字,因此和這個 TextFormField
關聯的 Key 就是專門用來處理文字的 - FormFieldState<String>
。換句話說,Key 要處理的是文字輸入,因此它的「型別」就是 String
。
除了基本的存取,FormFieldState<String>
也支援了其他輔助功能來協助處理表單欄位:
validate()
:觸發欄位的驗證邏輯,檢查值是否符合條件。若不符合則回傳錯誤資訊,正確者回傳 nullhasError
和 errorText
:如果上面檢查錯誤,則屬性會顯示錯誤資訊。在 Material Design 的組件會在欄位附近呈現較小的文字說明錯誤。save()
:觸發組件的 onSaved()
reset()
:將欄位組件回覆初始化的狀態並清楚錯誤資訊在 Flutter 中每一個組件都可以有一個 Key,GlobalKey 特別之處在於它具有全域唯一的特性,能夠讓我們在整個應用範圍內存取和操作特定組件。換句話說,GlobalKey 可以用在全域識別一個元素,所謂元素就是組件加到樹狀結構之後建立的物件,負責管理組件的生命週期。而 GlobalKey 可以提供存取元素相關的物件,例如 BuildContext
、 State
等。
⚠️ 當組件使用了 GlobalKey ,如果佈局需要從樹狀結構移動位置時,相對成本比較高。因為相關狀態都會被紀錄,搬移。
常見的用途:
有了 FormField
組件我們可以存取和驗證欄位資訊。但實務上通常不會只有一個欄位。我們可能會有表單且包含一系列欄位。這時我們可以使用 Form
。邏輯上 Form
組織了 FormField
組件。讓我們可以有系統的執行操作。
Form
組件支援下列方法,可以更方便的操作內部的 FormField
save()
:此方法會呼叫全部 FormField
的 save()
方法,一次儲存全部表單資料validate()
:一樣,會呼叫全部 FormField
的 validate()
,如此一來全部的錯誤會一次出現reset()
:呼叫全部 FormField
的 reset()
應用程式會需要存取表單的狀態,類似我們上面存取 FormField
的狀態一樣,進行驗證和儲存或重置。這樣就可以通過介面進行操作,重點是不會侷限在表單組件本身,例如你可能有一個 FloatingActionButton
用來儲存或重置表單。Flutter 提供 2 種方式存取表單狀態。
Form
組件必須使用 FormState
型別的 Key,這裡注意和上面單一欄位的 Key 不一樣。
GlobalKey<FormFieldState<String>>
是控制單一欄位因此型別會多了欄位資料的型別。而 GlobalKey<FormState>
是用來管理一個表單。FormState
包含一系列輔助函式來協助管理所有表單內的 FormField
:
final _key = GlobalKey<FormState>();
Form(
key: _key,
child: Column(
children: [
TextFormField(name: 'name'),
TextFormField(name: 'email'),
]
)
)
這裡我們使用了 GlobalKey 來關聯 Form
並且間接的包含兩個 TextFormField
。我們可以使用 Key 來檢索 Form
的狀態也可以使用 _key.currentState.validate()
驗證。狀態的部分可以用 _key.currentState
搭配 name
來取得。
String? email = _key.currentState!.fields['email']?.value as String?;
這通常是比較推薦存取表單狀態的方式,但如果你的組件結構比較複雜,那麼可以採用另一種方式。
補充:在底層原始碼的實作機制 Form 利用 InheritedWidget 將 FormState 和內部的 FormField 關聯起來,因此能夠存取。同時 FormField 內部也註冊了相關事件來更新狀態
Form
組件附帶了一個實用的類別,可以不用加入 Key 也可以得到 Key 的好處。
每一個 Form
組件內部都有一個關聯的 InheritedWidget
。Form
和其他許多組件都通過靜態方法 of()
來公開這個對應關係。這個of
方法可以傳入 BuildContext
作為參數然後會向上搜尋我們想要的狀態。了解這個機制之後,如果我們需要在樹狀結構的子層級存取 Form
組件,便可以使用 Form.of()
方法,就像使用 key 屬性一樣取得相關功能。
Widget build(BuildContext context) {
return Form(
child: Column(
children: [
TextFormField(),
TextFormField(),
Builder(
builder: (BuildContext subContext) => TextButton(
onPressed: () {
final valid = Form.of(subContext).validate();
print("valid: $valid");
},
child: Text("驗證")
)
)
]
),
);
}
在這個例子中,我們加入了 Builder
來渲染 TextButton
。Builder
組件單純給我們一個簡單的方式讓我們在樹狀結構中特定位置取得 BuildContext
。
如我們之前看學習的, InheritedWidget
可以被放到樹狀結構中被搜尋。
Form.of
傳入 context,然後底層在用BuildContext
的功能去搜尋InheritedWidget
。
在使用 Form.of(subContext)
的時候會使用 Builder
的 BuildContext
,它的階層比 Form
低,因此才能夠搜尋到 Form
。如果是直接 Form.of(context)
會無法取得 Form
,因為它會從 Form
往上找。這也是為什麼我們要多加入一個 Builder
組件。
兩個BuildContext
之間沒有直接關係,它們都可以對應整個樹狀結構進行搜尋,重點在於該階層位置往上搜尋。
檢查使用者輸入的資料也是 Form
一個主要的功能。為了確保輸入的資料符合規範,這點很重要因為使用者可能不知道欄位值有些限制,或者可能填錯資料。
Form
組件結合 FormField
物件,可以協助在欄位輸入錯誤資料的時候顯示錯誤訊息,在執行 save()
之前提示使用者修正。下面讓我們來看看表單的驗證流程:
Form
和 FormField
TextFormField(
validator: (String value) {
return value.isEmpty ? '不得為空' : null;
},
)
Form.of
呼叫 FormState
的 validate()
方法。FormField
將會呼叫自己的 validate()
。當驗證失敗的時候會回傳錯誤訊息字串,然後顯示在該 FormField
讓使用者知道需要修正。如果驗證成果則回傳 null。save()
來儲存表單資料。梳理整個表單組件的關係就是一個 Form
搭配了 FormState
負責管理表單狀態。然後 Form
裡面使用 TextFormField
這類繼承 FormField
功能的欄位組件。Form
本身可以依靠自己的 Key 管理狀態。而如果內部欄位組件需要操作狀態時,使用 Form.of
來取得 Form
的相關功能,過程中需要注意可能需要 Builder
協助取得適合階層的 BuildContext
以符合 Form.of
需要的參數(必須用以下階層)。
如果是表單外部則也可以使用 GlobalKey 來進行存取,例如 key.currentState
。
欄位的部分由 TextField
這類組件來負責 UI 的功能,而 FormField
則補充狀態和表單需要的功能如 validate
等。也就是還需要注意一個地方就是 FormField
也有內部狀態,當欄位的值改變時,FormField
會通過 onChanged
更新內部狀態,但如果需要更新整個表單的狀態),則需手動使用 FormState
的方法。
另外,關於 GlobalKey 請不要把它想成單純的識別,可以把它想成一個識別兼關聯相關物件的物件,這樣一來使用泛型也就很自然了。
到此,我們了解了基本的表單使用,接著讓我們進一步自訂表單。
截至目前為止,我們學習了 Form
和 FormField
組件協助處理輸入資料和驗證。我們也知道 Flutter 提供了許多繼承 FormField
延伸的輸入組件,它們都包含 save()
、validate()
功能。
但 Flutter 的可擴展性和靈活性體現在各個地方,因此自訂輸入欄位也是可以的。
在 Flutter 中建立自訂的欄位(Input)就跟建立一般組件一樣簡單,只需用到前面描述的方法即可。我們通常利用繼承 FormField<InputType>
來實作,而 InputType
就是欄位值的型別。因此情況下的流程如下:
StatefulWidget
建立狀態組件,這是為了保存狀態。然後利用封裝另一個輸入組件例如 TextField
來處理 UI 互動。FormField
的組件呈現 Input 組件並補充狀態和相關功能。假設使用者輸入電話送出後收到傳送驗證碼,接著需要輸入 6 碼驗證碼的例子。我們想要自訂一個只能輸入 6 碼數字的欄位組件。
首先,從簡單的 6 個數字輸入組件開始,然後進一步整合 FormField
組件提供 save()
、reset()
和 validate()
功能。
這裡我們先建立一個狀態組件,並設計一些使用時傳入的參數:
class VerificationCodeInput extends StatefulWidget {
VerificationCodeInput({ required this.borderSide, required this.onChanged, required this.controller });
final BorderSide borderSide;
final Function(String) onChanged;
final TextEditingController controller;
@override
State<VerificationCodeInput> createState() => _VerificationCodeInputState();
}
由於後續我們會需要從 FormField
層來控制,因此這裡比較重要的參數是 controller
。
接著,我們來建立這個狀態組件對應的狀態和介面部分:
class _VerificationCodeInputState extends State<VerificationCodeInput> {
@override
Widget build(BuildContext context) {
return TextField(
controller: widget.controller,
onChanged: widget.onChanged,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp("[0-9]")),
LengthLimitingTextInputFormatter(6),
],
textAlign: TextAlign.center,
decoration: InputDecoration(
border: OutlineInputBorder(
borderSide: widget.borderSide,
),
),
keyboardType: TypeInputType.number,
);
}
}
如你所見,這個狀態單純的在 build
方法回傳 TextField
搭配一些預先定義好的選項:
FilteringTextInputFormatter
支援欄位設定允許或拒絕條件的正規式。搭配 .allow
或 .deny
建構子可以建立過濾條件。上面範例我們使用了 .allow
建構子,而正規式設定只允許數字。不符合keyboardType
搭配 TextInputType
可以設定適合的輸入鍵盤,這裡我們顯示數字鍵盤,因為我們的欄位只需要數字,顯示完整鍵盤對使用者來說沒有幫助。LengthLimitingTextInputFormatter
指定最大字元數量OutlineInputBorder
範例中最重要的部分是 widget.controller
,通過 controller
我們可以從外部控制 TextField
。
為了將我們的自訂組件進一步支援 FormField
的功能,我們需要先建立一個繼承 FormField
類別的組件,FormField
是一個帶有表單相關功能如 validate
方法的狀態組件。
這次,我們直接從新組件對應的狀態物件開始,也就是 FormField
對應的 FormFieldState
:
class _VerificationCodeFormFieldState extends FormFieldState<String> {
final TextEditingController _controller = TextEditingController(text: "");
@override
void initState() {
super.initState();
_controller.addListener(_controllerChanged);
}
// ...
}
從上面程式碼,你可以看到包含一個 _controller
屬性,它將協助 FormField
控制自訂組件內部的 TextField
。然後初始化狀態,加入監聽事件。因此現在當值發生變動的時候會呼叫 _controllerChanged
。
別忘了這個組件還有下面的方法:
void _controllerChanged() {
didChange(_controller.text);
}
@override
void reset() {
super.reset();
_controller.text = "";
}
@override
void dispose() {
_controller?.removeListener(_controllerChanged);
super.dispose();
}
這些也是非常重要的方法,必須要覆寫或實作的:
dispose
和 initState
相反,這裡是用來停止監聽和釋放資源的。reset
需要覆寫,我們在這裡重置 _controller.text
為空,清楚欄位資料。_controllerChanged
通過 didChange
通知 FormFieldState
發生變化,如此一來 FormFieldState
就可以通過 setState
來變更狀態並通知 Form
它發生了變更。現在我們來看看 FormField
組件的程式碼看看是如何實作:
class VerificationCodeFormField extends FormField<String> {
VerificationCodeFormField({
super.key,
FormFieldSetter<String>? onSaved,
FormFieldValidator<String>? validator,
}) : super(
validator: validator,
onSaved: onSaved,
builder: (FormFieldState<String> field) {
_VerificationCodeFormFieldState state =
field as _VerificationCodeFormFieldState;
return VerificationCodeInput(
controller: state._controller,
onChanged: (_) => print(_),
borderSide: const BorderSide(
color: Colors.grey,
));
});
@override
FormFieldState<String> createState() => _VerificationCodeFormFieldState();
}
上面程式中重點在建構子的部分,主要是因為我們希望沿用 FormField
大部分的功能。 FormField
組件包含 builder
參數,它可以用來構建我們的自訂組件。同時通過傳入當前物件的狀態,可以讓我們自訂的組件保留當前的資訊。如您所見,我們使用這種方式來傳遞在狀態中的state._controller
因此即使欄位重新渲染,它也會繼續存在。這就是我們如何保持元件和狀態同步,並且和 Form
整合的方式。
初學 Flutter 的時候確實會需要花點時間習慣各種物件、組件之間的關聯和搭配。上面的步驟我們從下而上,先實作了最底層負責 UI 的組件,然後才是 FormField
類的組件。下面我們通過完整的程式碼重新來檢視一遍,這次我們的順序是由 FormField
開始往下。習慣之間的關係有助於你後續的開發。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
VerificationCodeFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return "請輸入驗證碼";
}
return null;
},
),
],
),
)),
),
));
}
}
class VerificationCodeFormField extends FormField<String> {
VerificationCodeFormField({
super.key,
FormFieldSetter<String>? onSaved,
FormFieldValidator<String>? validator,
}) : super(
validator: validator,
onSaved: onSaved,
builder: (FormFieldState<String> field) {
_VerificationCodeFormFieldState state =
field as _VerificationCodeFormFieldState;
return VerificationCodeInput(
controller: state._controller,
// ignore: avoid_print
onChanged: (_) => print(_),
borderSide: const BorderSide(
color: Colors.grey,
));
});
@override
FormFieldState<String> createState() => _VerificationCodeFormFieldState();
}
class _VerificationCodeFormFieldState extends FormFieldState<String> {
final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.addListener(_controllerChanged);
}
@override
void reset() {
super.reset();
_controller.text = "";
}
@override
void dispose() {
_controller.removeListener(_controllerChanged);
super.dispose();
}
void _controllerChanged() {
didChange(_controller.text);
}
}
class VerificationCodeInput extends StatefulWidget {
const VerificationCodeInput({
super.key,
required this.borderSide,
required this.onChanged,
required this.controller,
});
final BorderSide borderSide;
final Function(String) onChanged;
final TextEditingController controller;
@override
State<VerificationCodeInput> createState() => _VerificationCodeInputState();
}
class _VerificationCodeInputState extends State<VerificationCodeInput> {
@override
Widget build(BuildContext context) {
return TextField(
controller: widget.controller,
onChanged: widget.onChanged,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp("[0-9]")),
LengthLimitingTextInputFormatter(6),
],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
decoration: InputDecoration(
border: OutlineInputBorder(
borderSide: widget.borderSide,
),
),
);
}
}
若這篇文章仍無法讓你完全理解可以參考: