iT邦幫忙

2024 iThome 鐵人賽

DAY 13
0

截至目前為止,雖然我們已經能處理手勢進行互動了,但這樣的應用程式用途還是極其有限。為了取得使用者的資料,我們會需要表單與相關組件。

Flutter 支援了各種輸入組件協助開發者取得各類型的資料,包括之前介紹的 TextFieldSelectorPicker 等。下面就讓我們進一步探討。

通過 onChanged 取得資料

TextField 組件可以讓使用者通過鍵盤輸入文字。TextFieldonChanged 參數可以接受一個函式並且監聽當前值的變化。這是追蹤 TextField 中,值變化最簡單的方式,類似之前按鈕的 onPressed

TextField(
	onChanged: (text) {
    print(text);
  }
)

一樣可以傳入一個匿名函式來處理對應的行為。到此我們學習了一個可以輸入文字互動的介面組件。但除了使用 onChanged 這種最簡單的方式外,另外還有一種可以追蹤值變化的方式 - 使用 controller

通過 Controller 取得資料

使用一般 TextField 組件時,我們也可以使用 controller 參數來存取當前 TextField 的值 - 也就是使用者目前輸入的資料。

除了上面介紹的 onChanged 參數,我們還可以使用 TextEditingController 類別。實例化一個控制器如下:

final _controller = TextEditingController();

又或者,可以使用工廠模式建構子同時設定一個初始化的值

final _controller = TextEditingController.fromValue(
	TextEditingValue(text: "初始值")
);

如上面範例工廠模式建構子可以接受一個 TextEditingValue 的參數。通過 TextEditingValue 設定 text 參數就可以設定初始值。

實例化 TextEditingController 之後,我們就可以把它傳入 TextFieldcontroller 參數,然後用這個控制器來控制組件:

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

Form 和 FormField

Flutter 提供了兩個組件協助進行表單處理包含資料儲存,檢查,提供反饋等 - 即 FormFormField 組件。

Form 組件負責保存在表單內欄位的狀態。也就是被 Form 包著的 FormField 們,由 Form 管理。FormField 作為一個基礎類別,可以用來擴展客製化的欄位組件,主要有以下幾個功能:

  • 協助設定、檢索當前值的流程
  • 檢查驗證當前值
  • 提供驗證反饋

很多 Flutter 內建的輸入資料組件都有對應 FormField 的實作。舉例來說 TextField 組件對應有一個表單專用的組件 - TextFormFieldTextFormField 協助我們存取 TextField 的值,然後加入表單相關的行為如驗證等。

雖然。FormField 組件通常會有一個 Form 包起來,但這不是必須的。例如:當我們只有一個 FormField 的時候,可能就不需要使用 Form 來管理更新。

我們先從獨立的 FormField 操作開始介紹,然後搭配 Form 的使用。

這裡讓我們要先釐清一下:

  • TextField 這是一個輸入框組件,只負責 UI 和輸入文字相關互動,它沒有保存狀態或檢查的能力。
  • Form 是一個容器,組織多個欄位組件。
  • FormField 這個類別本身並沒有太多實作,只是定義了表單欄位需要支援的相關功能如重置、驗證等,但它不負責處理 UI,主要是處理狀態資料。
  • TextFormField 繼承 FormField 並兼具 TextField 呈現 UI 的能力,也就是在 TextField 的基礎上增加了 FormField 的功能,一般搭配 Form 使用。

存取 FormField 狀態

當我們使用 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():觸發欄位的驗證邏輯,檢查值是否符合條件。若不符合則回傳錯誤資訊,正確者回傳 null
  • hasErrorerrorText :如果上面檢查錯誤,則屬性會顯示錯誤資訊。在 Material Design 的組件會在欄位附近呈現較小的文字說明錯誤。
  • save():觸發組件的 onSaved()
  • reset():將欄位組件回覆初始化的狀態並清楚錯誤資訊

關於 GlobalKey

在 Flutter 中每一個組件都可以有一個 Key,GlobalKey 特別之處在於它具有全域唯一的特性,能夠讓我們在整個應用範圍內存取和操作特定組件。換句話說,GlobalKey 可以用在全域識別一個元素,所謂元素就是組件加到樹狀結構之後建立的物件,負責管理組件的生命週期。而 GlobalKey 可以提供存取元素相關的物件,例如 BuildContextState 等。

⚠️ 當組件使用了 GlobalKey ,如果佈局需要從樹狀結構移動位置時,相對成本比較高。因為相關狀態都會被紀錄,搬移。

常見的用途:

  • 狀態管理:當希望從樹狀結構中一組件存取另一組件的狀態可以使用 GlobalKey
  • 控制組件:GlobalKey 還可以用來直接控制元件的行為,例如手動觸發狀態變更等
  • 保持組件狀態:在結構重新渲染時,GlobalKey 可以協助保持某些組件的狀態,避免狀態消失

Form

有了 FormField 組件我們可以存取和驗證欄位資訊。但實務上通常不會只有一個欄位。我們可能會有表單且包含一系列欄位。這時我們可以使用 Form 。邏輯上 Form 組織了 FormField 組件。讓我們可以有系統的執行操作。

Form 組件支援下列方法,可以更方便的操作內部的 FormField

  • save() :此方法會呼叫全部 FormFieldsave() 方法,一次儲存全部表單資料
  • validate():一樣,會呼叫全部 FormFieldvalidate() ,如此一來全部的錯誤會一次出現
  • reset():呼叫全部 FormFieldreset()

存取 Form 的狀態

應用程式會需要存取表單的狀態,類似我們上面存取 FormField 的狀態一樣,進行驗證和儲存或重置。這樣就可以通過介面進行操作,重點是不會侷限在表單組件本身,例如你可能有一個 FloatingActionButton 用來儲存或重置表單。Flutter 提供 2 種方式存取表單狀態。

使用 Key

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 內部也註冊了相關事件來更新狀態

使用 InheritedWidget

Form 組件附帶了一個實用的類別,可以不用加入 Key 也可以得到 Key 的好處。

每一個 Form 組件內部都有一個關聯的 InheritedWidgetForm 和其他許多組件都通過靜態方法 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 來渲染 TextButtonBuilder 組件單純給我們一個簡單的方式讓我們在樹狀結構中特定位置取得 BuildContext

如我們之前看學習的, InheritedWidget 可以被放到樹狀結構中被搜尋。

Form.of 傳入 context,然後底層在用 BuildContext 的功能去搜尋 InheritedWidget

在使用 Form.of(subContext) 的時候會使用 BuilderBuildContext ,它的階層比 Form 低,因此才能夠搜尋到 Form。如果是直接 Form.of(context) 會無法取得 Form ,因為它會從 Form 往上找。這也是為什麼我們要多加入一個 Builder 組件。

兩個BuildContext之間沒有直接關係,它們都可以對應整個樹狀結構進行搜尋,重點在於該階層位置往上搜尋。

檢查輸入值

檢查使用者輸入的資料也是 Form 一個主要的功能。為了確保輸入的資料符合規範,這點很重要因為使用者可能不知道欄位值有些限制,或者可能填錯資料。

Form 組件結合 FormField 物件,可以協助在欄位輸入錯誤資料的時候顯示錯誤訊息,在執行 save() 之前提示使用者修正。下面讓我們來看看表單的驗證流程:

  1. 首先必須使用 FormFormField
  2. 接著需要為每一個欄位定義驗證的邏輯,方式是通過傳入一個驗證的函式如下:
TextFormField(
	validator: (String value) {
    return value.isEmpty ? '不得為空' : null;
  },
)
  1. 當使用者提交表單的時候,通過表單的 Key 或者 Form.of呼叫 FormStatevalidate() 方法。
  2. 每一個表單下的 FormField 將會呼叫自己的 validate()。當驗證失敗的時候會回傳錯誤訊息字串,然後顯示在該 FormField 讓使用者知道需要修正。如果驗證成果則回傳 null。
  3. 如果驗證都正確,呼叫 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 請不要把它想成單純的識別,可以把它想成一個識別兼關聯相關物件的物件,這樣一來使用泛型也就很自然了。

到此,我們了解了基本的表單使用,接著讓我們進一步自訂表單。

自訂輸入和 FormField

截至目前為止,我們學習了 FormFormField 組件協助處理輸入資料和驗證。我們也知道 Flutter 提供了許多繼承 FormField 延伸的輸入組件,它們都包含 save()validate() 功能。

但 Flutter 的可擴展性和靈活性體現在各個地方,因此自訂輸入欄位也是可以的。

建立自訂輸入欄位

在 Flutter 中建立自訂的欄位(Input)就跟建立一般組件一樣簡單,只需用到前面描述的方法即可。我們通常利用繼承 FormField<InputType> 來實作,而 InputType 就是欄位值的型別。因此情況下的流程如下:

  1. 先通過繼承 StatefulWidget 建立狀態組件,這是為了保存狀態。然後利用封裝另一個輸入組件例如 TextField 來處理 UI 互動。
  2. 建立繼承 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 類別的組件,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();
}

這些也是非常重要的方法,必須要覆寫或實作的:

  • disposeinitState 相反,這裡是用來停止監聽和釋放資源的。
  • 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,
        ),
      ),
    );
  }
}

進階學習

若這篇文章仍無法讓你完全理解可以參考:


上一篇
Day 12 深入狀態組件的生命週期
下一篇
Day 14 樣式與佈局
系列文
Flutter 開發實戰 - 30 天逃離新手村26
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言