Flutter 入門與實戰(九):開發一個常用的登入頁面

島上碼農發表於2021-06-01

這是我參與更文挑戰的第1天,活動詳情檢視: 更文挑戰

登入頁面在 App 開發中非常常見,本篇借登入頁面的開發介紹了文字框 TextField元件的使用,同時使用文字框的裝飾屬性實現了個性化文字框設定。

今天是“六一”兒童節,朋友圈的大小朋友刷了個熱熱鬧鬧的朋友圈,看到一句很有感觸的話:“小時候,快樂總是來得那麼簡單;長大後,簡單才能帶來更多快樂”。對於簡單的程式設計師來說,相信在程式碼的世界裡能夠找到不少快樂,祝大家節日快樂(特地將導航欄換了)!

業務邏輯

為了演示登入跳轉,在分類瀏覽先做了一個簡單的按鈕,點選跳轉到登入頁面。實際的 App 中,通常會是觸發某些需要登入才能檢視的操作後再跳轉到登入介面。

佈局分析

login.jpg 介面如上圖所示,從介面上看,整體內容區域是居中的,內容的佈局是一個簡單的列式佈局,包括了頂部的一個 Logo(通常是 App圖示),再往下是兩個文字輸入框,最後是登入按鈕。整體佈局比較簡單,使用 Center 下嵌一個Column 進行列布局即可。

圖片圓形裁剪

在 Flutter 中實行圖片圓形裁剪有兩個方式,一是使用外層的容器,通過將正方形的按圓形裁剪即可;二是使用內建的 CircleAvatar。不過從名字上看 CircleAvatar 用於頭像的,因此這裡使用容器的來實現圓形裁剪。封裝一個獲取圓形圖片的方法_getRoundImage,傳入圖片資源名稱和正方形邊長,程式碼如下所示:

Widget _getRoundImage(String imageName, double size) {
    return Container(
      width: size,
      height: size,
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.all(Radius.circular(size / 2)),
      ),
      child: Image.asset(
        imageName,
        fit: BoxFit.fitWidth,
      ),
    );
  }
複製程式碼

這裡使用了 BoxDecoration 將邊框設定為圓形的邊框,半徑為邊長的一半,這樣就達到邊框是圓形的效果了。但是,需要額外設定一個屬性就是 clipBehavior,這是邊緣裁剪型別,預設是不裁剪的。這裡使用了 Clip.antiAlias(抗鋸齒)的方式進行裁剪,這種方式的裁剪效果最好,但是更耗資源,其他的裁剪方式如下:

  • Clip.hardEdge:從名字就知道,這種方式很粗糙,但是裁剪的效率最快;
  • Clip.antiAliasSaveLayer:最為精細的裁剪,但是非常慢,不建議使用;
  • Clip.none:預設值,如果內容區沒有超出容器邊界的話,不會做任何裁剪。內容超出邊界的話需要使用別的裁剪方式防止內容溢位。

圓形扁平按鈕

這裡需要提一下, Flutter 2.0以前的扁平按鈕是FlatButton,使用起來很簡單,但是很多場合不太滿足,因此2.0以後引入了 TextButton 替代。TextButton 多了一個 style來裝飾按鈕樣式。具體可以看官方的文件。這裡我們的按鈕需要設定背景色為主題色,然後按鈕文字顏色為白色,同時需要切成圓角,因此還是使用 Container 的邊界圓弧來實現。需要注意的是,預設按鈕的寬度是根據內容來的,因此為了讓按鈕撐滿螢幕,我們設定了 Container 的寬度為 double.infinity。程式碼如下所示:

Widget _getLoginButton() {
    return Container(
      height: 50,
      width: double.infinity,
      margin: EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: Theme.of(context).primaryColor,
        borderRadius: BorderRadius.circular(4.0),
      ),
      child: TextButton(
        style: ButtonStyle(
          foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
          backgroundColor:
              MaterialStateProperty.all<Color>(Theme.of(context).primaryColor),
        ),
        child: Text(
          '登入',
        ),
        onPressed: () {
          print(
              'Login: username=${_username.trim()}, password=${_password.trim()}');
        },
      ),
    );
  }
複製程式碼

按鈕點選回撥事件為 onPressed,這裡只是簡單地列印了表單的內容。

TextField 文字框

TextField 是 Flutter 提供的文字輸入框,TextField 的屬性非常多,常用的屬性如下:

  • keyboardType:鍵盤型別,可以指定是數字、字母、電話號碼、郵箱、日期等多種方式,通過與表單內容匹配的鍵盤型別可以提供輸入效率,進而改善使用者體驗。
  • controller:TextEditingController 物件,TextEditingController 主要用於控制文字框的初始值,清除內容的操作。
  • obscureText:是否需要隱藏輸入內容,如果為 true,則輸入內容會使用圓點顯示,通常用與密碼。
  • decoration:文字框的裝飾,屬性也很多,可以指定前置圖示,邊框型別、後置元件等多種屬性,因此可以通過 decoration 獲得想要的文字框樣式。
  • focusNode:聚焦點,可以通過這個來控制文字框是否獲取焦點,從而實現類似上一個下一個的輸入控制。
  • onChanged:輸入值改變事件回撥,通常用這個方法實現雙向繫結。

在這個案例中,我們使用了一個前置圖示用來表示輸入內容的型別,比如使用手機圖示代表輸入手機號,使用鎖代表代表密碼。同時使用了一個 Offstage作為後置的元件,用於在輸入內容後可以點選清除內容。Offstage 元件是通過一個屬性offstage來控制元件是否顯示,這樣我們可以在沒有內容的時候隱藏它,有輸入內容的時候再顯示。

為了提高程式碼複用性,使用了一個方法獲取通用的文字框,這裡主要是使用了 Container包裹以控制邊距和文字框下的分隔線:

Widget _getInputTextField(
    TextInputType keyboardType, {
    FocusNode focusNode,
    controller: TextEditingController,
    onChanged: Function,
    InputDecoration decoration,
    bool obscureText = false,
    height = 50.0,
  }) {
    return Container(
      height: height,
      margin: EdgeInsets.all(10.0),
      child: Column(
        children: [
          TextField(
            keyboardType: keyboardType,
            focusNode: focusNode,
            obscureText: obscureText,
            controller: controller,
            decoration: decoration,
            onChanged: onChanged,
          ),
          Divider(
            height: 1.0,
            color: Colors.grey[400],
          ),
        ],
      ),
    );
  }
複製程式碼

完整程式碼

class _LoginPageState extends State<LoginPage> {
  //TextEditingController可以使用 text 屬性指定初始值
  TextEditingController _usernameController = TextEditingController();
  TextEditingController _passwordController = TextEditingController();
  String _username = '', _password = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('登入'),
        brightness: Brightness.dark,
      ),
      body: Center(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            _getRoundImage('images/logo.png', 100.0),
            SizedBox(
              height: 60,
            ),
            _getUsernameInput(),
            _getPasswordInput(),
            SizedBox(
              height: 10,
            ),
            _getLoginButton(),
          ],
        ),
      ),
    );
  }

  Widget _getUsernameInput() {
    return _getInputTextField(
      TextInputType.number,
      controller: _usernameController,
      decoration: InputDecoration(
        hintText: "輸入手機號",
        icon: Icon(
          Icons.mobile_friendly_rounded,
          size: 20.0,
        ),
        border: InputBorder.none,
        //使用 GestureDetector 實現手勢識別
        suffixIcon: GestureDetector(
          child: Offstage(
            child: Icon(Icons.clear),
            offstage: _username == '',
          ),
          //點選清除文字框內容
          onTap: () {
            this.setState(() {
              _username = '';
              _usernameController.clear();
            });
          },
        ),
      ),
      //使用 onChanged 完成雙向繫結
      onChanged: (value) {
        this.setState(() {
          _username = value;
        });
      },
    );
  }

  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;
        });
      },
    );
  }

  //省略了上述列舉的程式碼

}
複製程式碼

頁面跳轉

在上層面的登入按鈕上,我們增加了一個點選事件,點選後再跳到登入頁,按鈕的響應程式碼如下所示。這是頁面跳轉的最簡單的方式,使用 Navigator 導航器的 push方法實現頁面跳轉,後續會介紹如何通過路由實現頁面跳轉,那種方式更為優雅。

//...
onPressed: () {
  Navigator.of(context).push(
    MaterialPageRoute(builder: (context) => LoginPage()),
  );
},
//...
複製程式碼

總結

從程式碼上看,功能雖然實現了,但是構建使用者名稱和密碼的程式碼十分相似,有沒有辦法進一步提高程式碼複用率,構建一個更為通用的表單元件呢?下篇我們將介紹如何來封裝。

相關文章