編寫你的第一個 Flutter 網頁應用
本教程可以幫助你你完成第一個 Flutter Web 應用,如果你熟悉面對物件、變數、迴圈以及條件判斷等概念,就可以完成本教程,而無需要 Dart、行動開發和 Web 開發經驗。
內容概覽
你將實現一個只顯示登入頁面的簡單 Web 應用,這個頁面包含了三個文字輸入框:名字、姓氏和使用者名。當用戶向輸入框輸入內容時,在登入區域頂部顯示一個進度條動畫效果。當用戶完成輸入時,綠色的進度條將會跟隨著充滿整個登入區域的頂部,而且 Sign up 按鈕狀態變成可點選,點選 Sign up 按鈕從螢幕下方彈出一個歡迎頁面。
右側的動圖展示了完成該教程後程序的執行效果。
第 0 步: 建立初始化 Web 應用
你將從我們為你提供的簡單 Web 應用開始學習。
-
Enable web development.
啟用 Web 開發。在指令行觀察輸出內容,你應該可以看到如下類似的內容,說明 Flutter 安裝的沒問題:
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel master, 3.4.0-19.0.pre.254, on macOS 12.6 21G115 darwin-arm64, locale en) [✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0) [✓] Xcode - develop for iOS and macOS (Xcode 14.0) [✓] Chrome - develop for the web [✓] Android Studio (version 2021.2) [✓] VS Code (version 1.71.1) [✓] Connected device (4 available) [✓] HTTP Host Availability • No issues found!
如果你看到提示是 “flutter: command not found”,那麼就需要確保 Flutter SDK 已經正確地安裝,並且在環境變數中做好了設定。
如上所示,顯示我們缺少 Android 工具、Android Studio 和 Xcode,如果我們只用於 Web 開發,這些都不是必要的。後續如果你想用於行動端開發,你將需要安裝設定這些工具。
-
查詢裝置清單。
透過查詢裝置清單來驗證已支援 Web 開發。你將看到如下的類似內容:$ flutter devices 4 connected devices: sdk gphone64 arm64 (mobile) • emulator-5554 • android-arm64 • Android 13 (API 33) (emulator) iPhone 14 Pro Max (mobile) • 45A72BE1-2D4E-4202-9BB3-D6AE2601BEF8 • ios • com.apple.CoreSimulator.SimRuntime.iOS-16-0 (simulator) macOS (desktop) • macos • darwin-arm64 • macOS 12.6 21G115 darwin-arm64 Chrome (web) • chrome • web-javascript • Google Chrome 105.0.5195.125
Chrome 瀏覽器會自動啟動並啟用 Flutter 開發者工具。
-
執行程式將在 DartPad 中顯示。 import 'package:flutter/material.dart'; void main() => runApp(const SignUpApp()); class SignUpApp extends StatelessWidget { const SignUpApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( routes: { '/': (context) => const SignUpScreen(), }, ); } } class SignUpScreen extends StatelessWidget { const SignUpScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.grey[200], body: const Center( child: SizedBox( width: 400, child: Card( child: SignUpForm(), ), ), ), ); } } class SignUpForm extends StatefulWidget { const SignUpForm({super.key}); @override State<SignUpForm> createState() => _SignUpFormState(); } class _SignUpFormState extends State<SignUpForm> { final _firstNameTextController = TextEditingController(); final _lastNameTextController = TextEditingController(); final _usernameTextController = TextEditingController(); double _formProgress = 0; @override Widget build(BuildContext context) { return Form( child: Column( mainAxisSize: MainAxisSize.min, children: [ LinearProgressIndicator(value: _formProgress), Text('Sign up', style: Theme.of(context).textTheme.headlineMedium), Padding( padding: const EdgeInsets.all(8), child: TextFormField( controller: _firstNameTextController, decoration: const InputDecoration(hintText: 'First name'), ), ), Padding( padding: const EdgeInsets.all(8), child: TextFormField( controller: _lastNameTextController, decoration: const InputDecoration(hintText: 'Last name'), ), ), Padding( padding: const EdgeInsets.all(8), child: TextFormField( controller: _usernameTextController, decoration: const InputDecoration(hintText: 'Username'), ), ), TextButton( style: ButtonStyle( foregroundColor: MaterialStateProperty.resolveWith((states) { return states.contains(MaterialState.disabled) ? null : Colors.white; }), backgroundColor: MaterialStateProperty.resolveWith((states) { return states.contains(MaterialState.disabled) ? null : Colors.blue; }), ), onPressed: null, child: const Text('Sign up'), ), ], ), ); } }
-
執行程式碼範例。
點選 Run 按鈕來執行範例程式碼。你就可以在文字框中輸入內容,但是 Sign up 按鈕是停用狀態的。 -
複製程式碼。
點選程式碼區域右上角的複製圖示複製 Dart 程式碼。 -
建立一個新的 Flutter 工程。
使用 IDE、編輯器或者指令行,建立一個名稱為signin_example
的新專案,更多內容可以參考文件 Flutter 開發體驗初探。 -
使用上面我們複製的內容替換
lib/main.dart
檔案的內容。
觀察和分析
-
完整的範例程式碼都位於
lib/main.dart
檔案中。 -
如果你瞭解 Java ,那 Dart 也會給你一種熟悉的感覺。
-
應用程式的所有的 UI 的都是透過 Dart 建立的。你可以透過文件 宣告式 UI 介紹 瞭解到更多的訊息。
-
應用的 UI 遵循 Material Design 的設計規範,這是一種在任何裝置和平台都可以執行的視覺化設計語言。而且你也有其他選擇,Flutter 也提供了一款 iOS 設計風格的 Cupertino widget 函式庫。當然你也可以建立自己的自定義 widget 函式庫。
-
在 Flutter 的世界,萬物皆 Widget,甚至連應用本身都是 widget。應用的 UI 可以看作為 widget 樹。
第 1 步:顯示歡迎頁面
SignUpForm
類是一個 Stateful widget。這代表著 widget 的儲存訊息可動態改變,例如使用者輸入,或者傳遞的資料。由於 widget 本身是不可變的(一旦建立不可修改),所有 Flutter 的狀態訊息儲存在一種叫 State
的附加類別中。在這個程式碼範例中,所有的編輯將在一個 _SignUpFormState
的私有類別中實現。
首先,在 lib/main.dart
檔案中,在 SignUpScreen
類後面新增下面 WelcomeScreen
widget 的定義類:
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text(
'Welcome!',
style: Theme.of(context).textTheme.displayMedium,
),
),
);
}
}
接下來,你需要建立一個顯示方法,然後使用按鈕透過方法控制頁面的顯示。
-
找到
_SignUpFormState
類別的build()
方法。這部分程式碼是用來建立註冊按鈕的。注意,按鈕是如何定義:它是一個背景為藍色, Sign up 文字為白色的TextButton
按鈕,當我們點選它時,並未執行任何操作。 -
修改按鈕的
onPressed
屬性。
將按鈕的onPressed
屬性改為呼叫顯示歡迎頁面的方法(該方法在下一步建立)。將
onPressed: null
改為以下內容:onPressed: _showWelcomeScreen,
-
新增
_showWelcomeScreen
方法。
修復上述程式碼導致的編譯器提示錯誤:_showWelcomeScreen
is not defined. (未定義_showWelcomeScreen
)。在build()
方法上方新增下面的方法:void _showWelcomeScreen() { Navigator.of(context).pushNamed('/welcome'); }
-
新增
/welcome
頁面路由。
為新的頁面新增跳轉路由。在SignUpApp
類別的build()
方法中,在'/'
下面新增如下路由:'/welcome': (context) => const WelcomeScreen(),
-
執行該應用程式。
Sign up 按鈕現在應該可以點選了。單擊註冊按鈕跳轉到歡迎頁面。注意,歡迎頁面顯示是有一個從底部彈出的動畫。你可以很簡單的實現它。
觀察和分析
-
_showWelcomeScreen()
函式被當成回呼函式在build()
方法中被呼叫。在 Dart 中你會經常使用回呼函式,在這裡意味著『點選按鈕時調用該方法』。 -
建構式函式前面的
const
關鍵字至關重要,當 Flutter 遇到一個靜態 widget 時,它就會縮短引擎下的大部分重建工作,從而提高渲染效率。 -
Flutter 中僅存在一個
Navigator
物件。這個 widget 用來管理 Flutter 堆疊中的頁面(也可以被稱為路由 (routes) 或者頁面管理器 (pages))。當前顯示的頁面是堆疊中最上面的頁面,透過往堆疊中 push 新的頁面來切換新的頁面。這也是_showWelcomeScreen
函式向Navigator
堆疊中新增WelcomeScreen
頁面的原因。使用者點選按鈕,然後出現歡迎頁面。同樣,可以透過呼叫Navigator
的pop()
方法來回傳上一個頁面。因為 Flutter 的 navigation 已經整合到瀏覽器的導向中,所以當點選瀏覽器的回傳箭頭也會回傳到上一個頁面。
第 2 步:實現輸入進度監聽
在這個頁面有三個文字框。下一步,我們將實現監聽使用者輸入表單的進度,並且在表單完成後更新應用的 UI 。
-
新增一個用於更新進度
_formProgress
屬性的方法。在_SignUpFormState
類,新增一個名為_updateFormProgress()
的新方法:void _updateFormProgress() { var progress = 0.0; final controllers = [ _firstNameTextController, _lastNameTextController, _usernameTextController ]; for (final controller in controllers) { if (controller.value.text.isNotEmpty) { progress += 1 / controllers.length; } } setState(() { _formProgress = progress; }); }
這個方法根據非空輸入框的數量來更新
_formProgress
屬性。 -
表單改變時調用
_updateFormProgress
方法。
在_SignUpFormState
類別的build()
方法中,為Form
widget 的onChanged
引數新增回呼函式。注意註解為 NEW 的那行新新增的程式碼:return Form( onChanged: _updateFormProgress, // NEW child: Column(
-
再次更改按鈕的
onPressed
屬性。
還記得我們在第一步中,我們透過修改onPressed
屬性實現了點選 Sign up 按鈕跳轉到歡迎頁面嗎?現在,將它改成只有完成表單輸入時才可以點選按鈕跳轉到歡迎頁面。TextButton( style: ButtonStyle( foregroundColor: MaterialStateProperty.resolveWith((states) { return states.contains(MaterialState.disabled) ? null : Colors.white; }), backgroundColor: MaterialStateProperty.resolveWith((states) { return states.contains(MaterialState.disabled) ? null : Colors.blue; }), ), onPressed: _formProgress == 1 ? _showWelcomeScreen : null, // UPDATED child: const Text('Sign up'), ),
-
執行應用。
剛開啟頁面時 Sign up 按鈕是停用狀態,當為三個欄位輸入內容(任意內容)時將會變成可點選狀態。
觀察和分析
-
呼叫 widget 的
setState()
方法通知 Flutter 頁面上的 widget 需要重新建立。框架將銷燬之前的不可變 widget (上面說過 widget 一旦建立不可更改)(包含它的子級 widget),然後建立一個新的 widget (包含他的子級 widget 樹)並將新的 widget 渲染到頁面上。為了使應用執行順暢, Flutter 需要快速的銷燬和建立 widget。新創建的 widget 必須在不到 1/60 秒的時間渲染到頁面上,才能建立一個流暢的動畫效果。幸運的是 Flutter 就是這麼快。當然如果你願意的話,也可以使用文字編輯器。 -
progress
屬性定義為浮點值,並在_updateFormProgress
方法中更新。當三個輸入框都被輸入後,_formProgress
設定為 1.0 。當_formProgress
設定為 1.0 後,onPressed
的回呼函式將設定為_showWelcomeScreen
方法。當onPressed
引數變為非空時按鈕將會變成可點選。所有的 TextButton 在onPressed
和onLongPress
回呼為空時,預設也是無法點選的,與 Flutter 中其他 Material Design 的按鈕一致。 -
請注意,
_updateFormProgress
是透過傳遞一個函式呼叫setState()
。這種被稱為匿名函式,語法如下所示:methodName(() {...});
名為
methodName
的函式把匿名回呼函式作為引數。 -
最後一步顯示歡迎頁面的 Dart 語法如下所示:
_formProgress == 1 ? _showWelcomeScreen : null
Dart 三目運算語法如下:
condition ? expression1 : expression2
。如果_formProgress == 1
是正確的,則會取:
左側的值,在這個範例中會取_showWelcomeScreen
方法。
第 2.5 步:啟動 Dart 開發者工具
如何除錯 Flutter Web 應用?所有的 Flutter 應用除錯方法沒有很大的區別。你應該使用 Dart DevTools!(不要和 Chrome 開發者工具搞混淆了)
雖然我們的應用現在沒有 bug ,但是我們依然來驗證一下。下面的指引講明瞭 DevTools 使用的場景,如果你使用的是 IntelliJ 編輯器則會有更好的方式。可以透過檢視文件末尾的提示訊息獲取更多的訊息。
-
執行應用。
如果應用未啟動,啟動應用。從下拉選項中選擇 Chrome 裝置然後使用 IDE 啟動,或者在指令行中使用flutter run -d chrome
, -
獲取開發者工具(DevTools)的 socket 訊息。
在指令行或者 IDE 中你應該可以看下如下所示內容的訊息:Launching lib/main.dart on Chrome in debug mode... Building application for the web... 11.7s Attempting to connect to browser instance.. Debug service listening on ws://127.0.0.1:54998/pJqWWxNv92s=
複製粗體顯示的除錯服務的地址,你可以用這個地址啟動 DevTools 。
-
確認開發工具已被安裝。
你是否 已經安裝 DevTools 了呢?如果你使用的是編輯器 (IDE) ,先確認已經用 VS Code 和 Android Studio and IntelliJ 文件描述的方式安裝 Flutter 和 Dart 外掛。如果你使用的是指令行的方式,用 DevTools command line 文件說明的方式啟動開發者工具服務(DevTools server)。 -
連線到 DevTools。
當 DevTools 啟動時,你應該會看到如下類似的內容:Serving DevTools at http://127.0.0.1:9100
在 Chrome 瀏覽器中開啟上面 URL,你應該可以看到 DevTools 執行頁面。如下所示:
-
連線到執行的應用。
在 Connect to a running site 下面貼上你在上面第 2 步中複製的 ws 地址,然後點選連線。現在你應該可以看到 Dart DevTools 成功的執行在你的 Chrome 瀏覽器中,如下所示:恭喜,你已經成功執行 Dart 開發者工具!
-
設定斷點。
現在你以前啟動了開發者工具,在上面的藍色工具欄中選擇 Debugger 選項。在左下角出現除錯面板,可以檢視範例中使用的類別庫。選擇lib/main.dart
將在頁面中間顯示 Dart 程式碼。 -
設定斷點。
在 Dart 程式碼中,向下拉找到被修改的progress
,如下所示:for (final controller in controllers) { if (controller.value.text.isNotEmpty) { progress += 1 / controllers.length; } }
在 for 迴圈行的行數前面單擊設定斷點。這個斷點將顯示在視窗左側的 Breakpoints 欄中。
-
觸發斷點。
在正在執行的應用中,點選任意一個輸入框獲取焦點。應用會遇到斷點並暫停。在開發者工具頁面,你可以在左側看到progress
的值是 0 。這是正常的,因為你沒有輸入任何內容,遍歷 for 迴圈觀察應用的執行。 -
恢復應用程式。
在開發者工具視窗點選綠色的 Resume 按鈕來恢復應用程式。 -
刪除斷點。
再次點選斷點來刪除斷點和恢復程式。
這裡只是粗略的介紹開發者工具的使用方式,還有更多沒有講到。請參考 DevTools 文件 學習更多的內容。
第3步:為輸入進度新增動畫效果
是時候新增動畫效果了!在最後一步,我們將在登入區域上方建立一個進度條動畫,特效如下所述:
-
剛啟動時,登入區域的頂部顯示一條紅色的進度條。
-
當一個文字框被鍵入內容時,進度條從紅色變成橙色,並且進度條前進到距登入區域頂部 1/3 的位置。
-
當第二個文字框被鍵入內容時,進度條從橙色變為黃色,並且進度條前進到距登入區域頂部 2/3 的位置。
-
當三個文字框全部被輸入內容時,進度條從橙色變成綠色,並且逐漸充滿整個登入區域頂部。除此之外, Sign up 按鈕的狀態也變成可點選。
-
新增進度條動畫效果 (
AnimatedProgressIndicator
)
在檔案的下面,新增下面的 widget:class AnimatedProgressIndicator extends StatefulWidget { final double value; const AnimatedProgressIndicator({ super.key, required this.value, }); @override State<AnimatedProgressIndicator> createState() { return _AnimatedProgressIndicatorState(); } } class _AnimatedProgressIndicatorState extends State<AnimatedProgressIndicator> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<Color?> _colorAnimation; late Animation<double> _curveAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 1200), vsync: this, ); final colorTween = TweenSequence([ TweenSequenceItem( tween: ColorTween(begin: Colors.red, end: Colors.orange), weight: 1, ), TweenSequenceItem( tween: ColorTween(begin: Colors.orange, end: Colors.yellow), weight: 1, ), TweenSequenceItem( tween: ColorTween(begin: Colors.yellow, end: Colors.green), weight: 1, ), ]); _colorAnimation = _controller.drive(colorTween); _curveAnimation = _controller.drive(CurveTween(curve: Curves.easeIn)); } @override void didUpdateWidget(oldWidget) { super.didUpdateWidget(oldWidget); _controller.animateTo(widget.value); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) => LinearProgressIndicator( value: _curveAnimation.value, valueColor: _colorAnimation, backgroundColor: _colorAnimation.value?.withOpacity(0.4), ), ); } }
didUpdateWidget
方法會在AnimatedProgressIndicator
變化時更新AnimatedProgressIndicatorState
。 -
使用新的進度條。
然後,使用新的AnimatedProgressIndicator
widget 替換表單中的LinearProgressIndicator
widget,如下所示:child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedProgressIndicator(value: _formProgress), // NEW Text('Sign up', style: Theme.of(context).textTheme.headlineMedium), Padding(
該 widget 使用
AnimatedBuilder
為最新值實現了進度的動畫顯示。 -
執行應用。
在三個輸入框中輸入任意值來驗證動畫效果是否正常顯示,然後點選 Sign up 按鈕將彈出歡迎頁面。
完整的範例
import 'package:flutter/material.dart';
void main() => runApp(const SignUpApp());
class SignUpApp extends StatelessWidget {
const SignUpApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
routes: {
'/': (context) => const SignUpScreen(),
'/welcome': (context) => const WelcomeScreen(),
},
);
}
}
class SignUpScreen extends StatelessWidget {
const SignUpScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[200],
body: const Center(
child: SizedBox(
width: 400,
child: Card(
child: SignUpForm(),
),
),
),
);
}
}
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text(
'Welcome!',
style: Theme.of(context).textTheme.displayMedium,
),
),
);
}
}
class SignUpForm extends StatefulWidget {
const SignUpForm({super.key});
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
final _firstNameTextController = TextEditingController();
final _lastNameTextController = TextEditingController();
final _usernameTextController = TextEditingController();
double _formProgress = 0;
void _updateFormProgress() {
var progress = 0.0;
final controllers = [
_firstNameTextController,
_lastNameTextController,
_usernameTextController
];
for (final controller in controllers) {
if (controller.value.text.isNotEmpty) {
progress += 1 / controllers.length;
}
}
setState(() {
_formProgress = progress;
});
}
void _showWelcomeScreen() {
Navigator.of(context).pushNamed('/welcome');
}
@override
Widget build(BuildContext context) {
return Form(
onChanged: _updateFormProgress,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedProgressIndicator(value: _formProgress),
Text('Sign up', style: Theme.of(context).textTheme.headlineMedium),
Padding(
padding: const EdgeInsets.all(8),
child: TextFormField(
controller: _firstNameTextController,
decoration: const InputDecoration(hintText: 'First name'),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: TextFormField(
controller: _lastNameTextController,
decoration: const InputDecoration(hintText: 'Last name'),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: TextFormField(
controller: _usernameTextController,
decoration: const InputDecoration(hintText: 'Username'),
),
),
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.resolveWith((states) {
return states.contains(MaterialState.disabled)
? null
: Colors.white;
}),
backgroundColor: MaterialStateProperty.resolveWith((states) {
return states.contains(MaterialState.disabled)
? null
: Colors.blue;
}),
),
onPressed: _formProgress == 1 ? _showWelcomeScreen : null,
child: const Text('Sign up'),
),
],
),
);
}
}
class AnimatedProgressIndicator extends StatefulWidget {
final double value;
const AnimatedProgressIndicator({
super.key,
required this.value,
});
@override
State<AnimatedProgressIndicator> createState() {
return _AnimatedProgressIndicatorState();
}
}
class _AnimatedProgressIndicatorState extends State<AnimatedProgressIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Color?> _colorAnimation;
late Animation<double> _curveAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
final colorTween = TweenSequence([
TweenSequenceItem(
tween: ColorTween(begin: Colors.red, end: Colors.orange),
weight: 1,
),
TweenSequenceItem(
tween: ColorTween(begin: Colors.orange, end: Colors.yellow),
weight: 1,
),
TweenSequenceItem(
tween: ColorTween(begin: Colors.yellow, end: Colors.green),
weight: 1,
),
]);
_colorAnimation = _controller.drive(colorTween);
_curveAnimation = _controller.drive(CurveTween(curve: Curves.easeIn));
}
@override
void didUpdateWidget(oldWidget) {
super.didUpdateWidget(oldWidget);
_controller.animateTo(widget.value);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) => LinearProgressIndicator(
value: _curveAnimation.value,
valueColor: _colorAnimation,
backgroundColor: _colorAnimation.value?.withOpacity(0.4),
),
);
}
}
觀察和分析
-
你可以使用
AnimationController
控制任何動畫效果。 -
當
Animation
的值改變時AnimatedBuilder
將重新建立 widget 樹。 -
使用動畫
Tween
,你還可以使用很多值,像這個範例中的Color
。
下一步,我們該做什麼?
恭喜!你已經使用 Flutter 建立了第一個 Web 應用!
如果你想繼續完善這個範例,或許你可以新增表單驗證。如何繼續的建議,請參考 Flutter cookbook 中的 Building a form with validation
有關 Web 應用、Dart 開發者工具以及 Flutter 動畫的更多訊息,請參考下面文件: