1. 簡介
Flutter 是 Google 的 UI 工具包,可讓您根據單一程式碼集,打造出適合在行動、網路和桌機環境中執行的應用程式。在本程式碼研究室中,您將建構下列 Flutter 應用程式:
應用程式會產生聽起來很酷的名稱,例如「newstay」、「lightstream」、「mainbrake」或「graypine」。使用者可以要求下一個名稱、將目前的名稱設為最愛,並在另一個頁面查看最愛的名稱清單。應用程式可配合不同的螢幕大小做出回應。
課程內容
- Flutter 運作方式的基本概念
- 在 Flutter 中建立版面配置
- 將使用者互動 (例如按下按鈕) 連結至應用程式行為
- 讓 Flutter 程式碼井然有序
- 讓應用程式能回應 (適用於不同螢幕)
- 打造一致的應用程式外觀和風格
您將從基本架構開始,直接跳到有趣的部分。
接下來,請看 Filip 為您介紹整個程式碼研究室!
按一下「下一步」開始實驗室。
2. 設定 Flutter 環境
編輯者
為了讓本程式碼研究室盡可能簡單明瞭,我們假設您會使用 Visual Studio Code (VS Code) 做為開發環境。這項服務免費提供,且支援所有主要平台。
當然,您也可以使用任何喜歡的編輯器:Android Studio、其他 IntelliJ IDE、Emacs、Vim 或 Notepad++。這些編輯器都支援 Flutter。
我們建議您使用 VS Code 進行本程式碼研究室,因為操作說明預設為 VS Code 專屬的捷徑。比起「在編輯器中執行適當動作來執行 X」,說「按這鍵」或「按這裡」會更容易。
選擇開發目標
Flutter 是多平台工具包。您的應用程式可在下列任一作業系統上執行:
- iOS
- Android
- Windows
- macOS
- Linux
- 網路
不過,一般來說,您應該選擇一個主要開發作業系統。這就是您的「開發目標」:應用程式在開發期間執行的作業系統。
舉例來說,假設您使用 Windows 筆電開發 Flutter 應用程式,如果選擇 Android 做為開發目標,通常會使用 USB 傳輸線將 Android 裝置連接至 Windows 筆電,然後在該連結的 Android 裝置上執行開發中的應用程式。不過,您也可以選擇 Windows 做為開發目標,這表示開發中的應用程式會以 Windows 應用程式形式執行,並與編輯器搭配使用。
您可能會想選取網路做為開發目標。這個選項的缺點是,您將失去 Flutter 最實用的開發功能之一:具狀態的熱載重載。Flutter 無法熱重載網頁應用程式。
立即做出選擇。提醒您:您隨時可以稍後在其他作業系統上執行應用程式。只是有明確的開發目標,後續步驟就能更順利。
安裝 Flutter
如需最新的 Flutter SDK 安裝操作說明,請前往 docs.flutter.dev。
Flutter 網站上的操作說明涵蓋 SDK 本身的安裝作業,以及與開發目標相關的工具和編輯器外掛程式。請注意,您只需要為本程式碼研究室安裝以下項目:
- Flutter SDK
- 搭配 Flutter 外掛程式的 Visual Studio Code
- 所選開發目標所需的軟體 (例如:針對 Windows 的 Visual Studio,或針對 macOS 的 Xcode)
在下一節中,您將建立第一個 Flutter 專案。
如果您目前遇到問題,這些 StackOverflow 問題和解答可能有助於排解問題。
常見問題
3. 建立專案
建立第一個 Flutter 專案
啟動 Visual Studio Code 並開啟指令面板 (使用 F1
或 Ctrl+Shift+P
或 Shift+Cmd+P
)。開始輸入「flutter new」選取「Flutter:新專案」指令。
接著,選取「Application」,然後選取要建立專案的資料夾。這可能是您的主目錄,或類似 C:\src\
的內容。
最後,為專案命名。例如 namer_app
或 my_awesome_namer
。
Flutter 會建立專案資料夾,並由 VS Code 開啟。
您現在將使用應用程式的基礎架構覆寫 3 個檔案的內容。
複製並貼上初始應用程式
在 VS Code 的左側面板中,確認已選取「Explorer」,然後開啟 pubspec.yaml
檔案。
將這個檔案的內容替換成以下內容:
pubspec.yaml
name: namer_app
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 0.0.1+1
environment:
sdk: ^3.6.0
dependencies:
flutter:
sdk: flutter
english_words: ^4.0.0
provider: ^6.1.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
pubspec.yaml
檔案會指定應用程式的基本資訊,例如目前版本、依附元件,以及要一併發布的資產。
接著,在專案 analysis_options.yaml
中開啟另一個設定檔。
將內容取代為以下內容:
analysis_options.yaml
include: package:flutter_lints/flutter.yaml
linter:
rules:
avoid_print: false
prefer_const_constructors_in_immutables: false
prefer_const_constructors: false
prefer_const_literals_to_create_immutables: false
prefer_final_fields: false
unnecessary_breaks: true
use_key_in_widget_constructors: false
這個檔案會決定 Flutter 在分析程式碼時的嚴格程度。由於這是您首次接觸 Flutter,因此您要告訴分析器放輕鬆。您之後隨時可以調整這項設定。事實上,如果您即將發布實際的正式版應用程式,幾乎可以肯定,您會想讓分析器的規則更嚴格。
最後,開啟 lib/
目錄下的 main.dart
檔案。
將這個檔案的內容替換成以下內容:
lib/main.dart
import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Namer App',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: MyHomePage(),
),
);
}
}
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
return Scaffold(
body: Column(
children: [
Text('A random idea:'),
Text(appState.current.asLowerCase),
],
),
);
}
}
這 50 行程式碼就是目前應用程式的全部內容。
在下一節中,您將以偵錯模式執行應用程式並開始開發。
4. 新增按鈕
這個步驟會新增「Next」按鈕,以產生新的字詞配對。
啟動應用程式
首先,請開啟 lib/main.dart
,並確認已選取目標裝置。在 VS Code 的右下角,您會看到一個顯示目前目標裝置的按鈕。按一下即可變更。
lib/main.dart
開啟後,請找出 VS Code 視窗右上角的「播放」 按鈕,然後按一下。
大約一分鐘後,應用程式就會以偵錯模式啟動。目前看起來不怎麼樣:
第一次熱重載
在 lib/main.dart
底部,將內容新增至第一個 Text
物件的字串,然後使用 Ctrl+S
或 Cmd+S
儲存檔案。例如:
lib/main.dart
// ...
return Scaffold(
body: Column(
children: [
Text('A random AWESOME idea:'), // ← Example change.
Text(appState.current.asLowerCase),
],
),
);
// ...
請注意,應用程式會立即變更,但隨機字詞會保持不變。這就是 Flutter 著名的具狀態熱重載功能。儲存來源檔案的變更時,系統會觸發熱重新載入功能。
常見問題
新增按鈕
接著,在 Column
底部 (第二個 Text
例項下方) 新增按鈕。
lib/main.dart
// ...
return Scaffold(
body: Column(
children: [
Text('A random AWESOME idea:'),
Text(appState.current.asLowerCase),
// ↓ Add this.
ElevatedButton(
onPressed: () {
print('button pressed!');
},
child: Text('Next'),
),
],
),
);
// ...
儲存變更後,應用程式會再次更新:畫面上會顯示一個按鈕,點選該按鈕後,VS Code 中的「Debug Console」會顯示「button pressed!」訊息。
5 分鐘內完成 Flutter 速成課程
雖然觀看偵錯主控台很有趣,但您可能希望按鈕能發揮更實用的功能。不過,在開始之前,請先仔細查看 lib/main.dart
中的程式碼,瞭解其運作方式。
lib/main.dart
// ...
void main() {
runApp(MyApp());
}
// ...
檔案最上方會顯示 main()
函式。在目前的形式中,它只會指示 Flutter 執行 MyApp
中定義的應用程式。
lib/main.dart
// ...
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Namer App',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: MyHomePage(),
),
);
}
}
// ...
MyApp
類別會擴充 StatelessWidget
。小工具是您用來建構每個 Flutter 應用程式的元素。如您所見,應用程式本身也是小工具。
MyApp
中的程式碼會設定整個應用程式,包括建立應用程式層級狀態 (稍後會進一步說明)、為應用程式命名、定義視覺主題,以及設定「home」小工具 (應用程式的起點)。
lib/main.dart
// ...
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
}
// ...
接著,MyAppState
類別會定義應用程式的狀態。這是您首次接觸 Flutter,因此本程式碼研究室會簡化內容,讓您專注於學習 Flutter。在 Flutter 中,您可以透過許多強大的功能管理應用程式狀態。最容易解釋的 ChangeNotifier
是這個應用程式採用的方法。
MyAppState
定義應用程式運作所需的資料。目前只包含單一變數,其中包含目前的隨機字詞組合。您稍後會加入其他內容。- 狀態類別會擴充
ChangeNotifier
,也就是說,它可以通知其他人其變更。舉例來說,如果目前的字詞組合有所變更,應用程式中的部分小工具就需要知道。 - 系統會使用
ChangeNotifierProvider
建立狀態,並提供給整個應用程式 (請參閱MyApp
中的程式碼)。這樣一來,應用程式中的任何小工具都能取得狀態。
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) { // ← 1
var appState = context.watch<MyAppState>(); // ← 2
return Scaffold( // ← 3
body: Column( // ← 4
children: [
Text('A random AWESOME idea:'), // ← 5
Text(appState.current.asLowerCase), // ← 6
ElevatedButton(
onPressed: () {
print('button pressed!');
},
child: Text('Next'),
),
], // ← 7
),
);
}
}
// ...
最後,還有 MyHomePage
,也就是您已修改過的小工具。下方每個編號行都對應至上述程式碼中的行號註解:
- 每個小工具都會定義
build()
方法,這個方法會在小工具的情況變更時自動呼叫,讓小工具隨時保持最新狀態。 MyHomePage
會使用watch
方法追蹤應用程式目前狀態的變更。- 每個
build
方法都必須傳回小工具,或 (更常見的) 巢狀的小工具樹狀結構。在本例中,頂層小工具是Scaffold
。您不會在本程式碼研究室中使用Scaffold
,但這是一個實用的資訊方塊,在大多數實際的 Flutter 應用程式中都會用到。 Column
是 Flutter 中最基本的版面配置小工具之一。它會將任意數量的子項從上到下放入一欄中。根據預設,欄會將子項視覺化置於頂端。您很快就會變更這項設定,讓資料欄居中顯示。- 您在第一步驟中變更了這個
Text
小工具。 - 這個第二個
Text
小工具會採用appState
,並存取該類別的唯一成員current
(即WordPair
)。WordPair
提供多個實用的 getter,例如asPascalCase
或asSnakeCase
。我們在這裡使用asLowerCase
,但如果您偏好其他選項,現在可以變更。 - 請注意,Flutter 程式碼大量使用尾隨逗號。這個逗號不必放在這裡,因為
children
是這個特定Column
參數清單的最後一個 (也是唯一) 成員。不過,使用尾隨逗號通常是個不錯的做法:這樣一來,新增更多成員就會變得簡單,而且還可做為 Dart 自動格式化工具的提示,讓該工具在該處插入換行符號。詳情請參閱「程式碼格式設定」。
接下來,您將連結按鈕和狀態。
您的第一個行為
捲動至 MyAppState
,然後新增 getNext
方法。
lib/main.dart
// ...
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
// ↓ Add this.
void getNext() {
current = WordPair.random();
notifyListeners();
}
}
// ...
新的 getNext()
方法會使用新的隨機 WordPair
重新指派 current
。它也會呼叫 notifyListeners()
(ChangeNotifier)
的一種方法,可確保任何觀看 MyAppState
的使用者都能收到通知)。
剩下的工作就是從按鈕的回呼呼叫 getNext
方法。
lib/main.dart
// ...
ElevatedButton(
onPressed: () {
appState.getNext(); // ← This instead of print().
},
child: Text('Next'),
),
// ...
儲存並立即試用應用程式。每次按下「Next」按鈕時,程式都會產生新的隨機字詞組合。
在下一節中,您將改善使用者介面。
5. 讓應用程式更美觀
應用程式目前的樣貌如下。
不太好。應用程式的重點 (隨機產生的字詞組合) 應更醒目。畢竟,這也是使用者使用這個應用程式的主要原因!此外,應用程式內容會奇怪地偏離中心,整個應用程式也只有單調的黑白色。
本節將透過調整應用程式設計來解決這些問題。本節的最終目標如下:
擷取小工具
負責顯示目前字詞組的程式碼行現在會像這樣:Text(appState.current.asLowerCase)
。如要將這條線變得更複雜,建議您將這條線提取到另一個小工具中。為 UI 的不同邏輯部分使用不同的小工具,是管理 Flutter 複雜度的一大要訣。
Flutter 提供重構輔助程式,可用於擷取小工具,但在使用前,請確認要擷取的程式碼行只會存取所需內容。目前,該行會存取 appState
,但其實只需要知道目前的字詞組合為何。
因此,請將 MyHomePage
小工具重寫為以下內容:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current; // ← Add this.
return Scaffold(
body: Column(
children: [
Text('A random AWESOME idea:'),
Text(pair.asLowerCase), // ← Change to this.
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
);
}
}
// ...
真棒!Text
小工具不再參照整個 appState
。
接著,請叫用「Refactor」選單。在 VS Code 中,您可以透過以下任一方式執行此操作:
- 在您要重構的程式碼片段 (本例為
Text
) 上按一下滑鼠右鍵,然後從下拉式選單中選取「Refactor...」。
或
- 將游標移至要重構的程式碼片段 (在本例中為
Text
),然後按下Ctrl+.
(Win/Linux) 或Cmd+.
(Mac)。
在「Refactor」選單中,選取「Extract Widget」。指派名稱 (例如「BigCard」BigCard),然後按一下 Enter
。
這會在目前檔案的結尾自動建立新類別 BigCard
。類別如下所示:
lib/main.dart
// ...
class BigCard extends StatelessWidget {
const BigCard({
super.key,
required this.pair,
});
final WordPair pair;
@override
Widget build(BuildContext context) {
return Text(pair.asLowerCase);
}
}
// ...
請注意,即使進行重構,應用程式仍可正常運作。
新增信用卡
接下來,我們要將這個新小工具變成我們在本節開頭所構想的醒目 UI 元素。
找出 BigCard
類別和其中的 build()
方法。如同先前所述,請在 Text
小工具上叫用「Refactor」選單。不過,這次您不會擷取小工具。
請改為選取「Wrap with Padding」。這會在 Text
小工具 (名為 Padding
) 周圍建立新的父項小工具。儲存後,您會發現隨機字詞的空間已經變大。
將邊框增加至 8.0
的預設值。例如,使用 20
等元素來增加間距。
接著,再往上一層。將滑鼠游標移到 Padding
小工具上,拉出「Refactor」選單,然後選取「Wrap with widget...」。
這樣您就能指定父項小工具。輸入「卡片」,然後按下 Enter 鍵。
這會使用 Card
小工具包裝 Padding
小工具,因此也包裝 Text
。
主題和樣式
如要讓卡片更醒目,請使用更鮮豔的顏色進行繪製。由於一貫使用一致的色彩配置是明智的做法,請使用應用程式的 Theme
選擇顏色。
對 BigCard
的 build()
方法進行下列變更。
lib/main.dart
// ...
@override
Widget build(BuildContext context) {
final theme = Theme.of(context); // ← Add this.
return Card(
color: theme.colorScheme.primary, // ← And also this.
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(pair.asLowerCase),
),
);
}
// ...
這兩行新程式碼會執行許多工作:
- 首先,程式碼會使用
Theme.of(context)
要求應用程式的目前主題。 - 接著,程式碼會將資訊卡的顏色定義為與主題的
colorScheme
屬性相同。色彩配置包含許多顏色,其中primary
是應用程式最顯眼、最具代表性的顏色。
資訊卡現在會以應用程式的主要顏色繪製:
如要變更此顏色和整個應用程式的色彩配置,請向上捲動至 MyApp
,然後變更該處 ColorScheme
的種子顏色。
請注意顏色動畫的流暢程度。這稱為隱含動畫。許多 Flutter 小工具會在值之間平滑內插,以免使用者介面在狀態之間「跳躍」。
資訊卡下方的浮動按鈕也會變色。這就是使用應用程式全域 Theme
而非硬式編碼值的優勢。
TextTheme
但這張資訊卡仍有問題:文字太小,且顏色難以閱讀。如要修正這個問題,請對 BigCard
的 build()
方法進行下列變更。
lib/main.dart
// ...
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// ↓ Add this.
final style = theme.textTheme.displayMedium!.copyWith(
color: theme.colorScheme.onPrimary,
);
return Card(
color: theme.colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(20),
// ↓ Change this line.
child: Text(pair.asLowerCase, style: style),
),
);
}
// ...
異動原因:
- 使用
theme.textTheme,
即可存取應用程式的字型主題。這個類別包含bodyMedium
(用於標準中型文字)、caption
(用於圖片說明) 或headlineLarge
(用於大型標題) 等成員。 displayMedium
屬性是用於顯示文字的大型樣式。這裡的「顯示」一詞是指排版,例如「顯示字體」。displayMedium
的說明文件指出,「顯示樣式應保留給簡短且具重要性的文字」,這正是我們的使用情境。- 主題的
displayMedium
屬性理論上可以是null
。Dart 是您用來編寫此應用程式的程式設計語言,具有空值安全性,因此不會讓您呼叫可能為null
的物件方法。不過,在這種情況下,您可以使用!
運算子 (「驚嘆號運算子」) 讓 Dart 知道您正在執行的操作。(displayMedium
在這種情況下絕對不是空值。不過,我們知道這不在本程式碼研究室的範圍內。) - 在
displayMedium
上呼叫copyWith()
會傳回文字樣式的副本,並包含您定義的變更。在本例中,您只需變更文字顏色即可。 - 如要取得新顏色,請再次存取應用程式主題。色彩配置的
onPrimary
屬性會定義適合用於應用程式主要顏色的顏色。
應用程式現在應如下所示:
如有需要,請進一步修改資訊卡。不妨參考下列建議:
copyWith()
可讓您變更文字樣式的更多內容,而非僅限於顏色。如要取得可變更的完整屬性清單,請將游標放在copyWith()
的括號內,然後按下Ctrl+Shift+Space
(Win/Linux) 或Cmd+Shift+Space
(Mac)。- 同樣地,您也可以變更更多
Card
小工具的設定。舉例來說,您可以增加elevation
參數的值,藉此放大資訊卡的陰影。 - 嘗試使用不同的顏色。除了
theme.colorScheme.primary
之外,還有.secondary
、.surface
和其他許多旗標。所有這些顏色都有onPrimary
對應項目。
改善無障礙功能
Flutter 會預設讓應用程式可供存取。舉例來說,每個 Flutter 應用程式都會正確向 TalkBack 和 VoiceOver 等螢幕閱讀器顯示應用程式中的所有文字和互動元素。
不過,有時仍需要進行一些工作。就這個應用程式而言,螢幕閱讀器可能無法正確發音某些產生的字詞組合。雖然人類可以輕鬆辨識 cheaphead 中的兩個字,但螢幕閱讀器可能會將字詞中間的 ph 唸成 f。
簡單的解決方法是將 pair.asLowerCase
替換為 "${pair.first} ${pair.second}"
。後者則使用字串插補,根據 pair
中包含的兩個字詞建立字串 (例如 "cheap head"
)。使用兩個單字而非複合字,可確保螢幕閱讀器正確辨識這些字詞,並為視障人士提供更優質的使用體驗。
不過,您可能會想要保留 pair.asLowerCase
的視覺簡潔性。使用 Text
的 semanticsLabel
屬性,將文字小工具的視覺內容覆寫為更適合螢幕閱讀器的語意內容:
lib/main.dart
// ...
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.displayMedium!.copyWith(
color: theme.colorScheme.onPrimary,
);
return Card(
color: theme.colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(20),
// ↓ Make the following change.
child: Text(
pair.asLowerCase,
style: style,
semanticsLabel: "${pair.first} ${pair.second}",
),
),
);
}
// ...
如今,螢幕閱讀器可正確朗讀每個產生的字詞組合,但 UI 仍維持不變。在裝置上使用螢幕閱讀器,試試這個功能。
將 UI 置中
隨機字詞組合已以足夠的視覺效果呈現,現在是時候將其置於應用程式視窗/畫面的中央了。
首先,請注意 BigCard
是 Column
的一部分。根據預設,欄會將子項堆疊到頂端,但我們可以輕鬆覆寫這項設定。前往 MyHomePage
的 build()
方法,並進行以下變更:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center, // ← Add this.
children: [
Text('A random AWESOME idea:'),
BigCard(pair: pair),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
);
}
}
// ...
這會將 Column
內的子項沿著主 (垂直) 軸置中。
子項已沿著欄的交叉軸對齊 (也就是已水平對齊)。但 Column
本身並未置中於 Scaffold
內。我們可以使用小工具檢查器來驗證這項資訊。
Widget Inspector 本身不在本程式碼研究室的說明範圍內,但您可以看到,當 Column
醒目顯示時,它不會佔用應用程式的整個寬度,只會佔用子項所需的水平空間。
您可以將資料欄置中。將游標移至 Column
,叫出「Refactor」選單 (使用 Ctrl+.
或 Cmd+.
),然後選取「Wrap with Center」。
應用程式現在應如下所示:
如有需要,您可以進一步調整這項設定。
- 您可以移除
BigCard
上方的Text
小工具。有人認為,既然 UI 即使沒有說明文字也能讓人理解,因此說明文字 (「A random AWESOME idea:」) 已無必要。這樣會比較整齊。 - 您也可以在
BigCard
和ElevatedButton
之間新增SizedBox(height: 10)
小工具。這樣一來,兩個小工具之間的間距就會稍微增加。SizedBox
小工具只會佔用空間,不會自行算繪任何內容。通常用於建立視覺「間距」。
在加入選用變更後,MyHomePage
包含以下程式碼:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
),
);
}
}
// ...
應用程式如下所示:
在下一節中,您將新增將產生的字詞設為「喜歡」的功能。
6. 新增功能
應用程式運作正常,偶爾甚至會提供有趣的字詞組合。但只要使用者點選「下一步」,每個字詞組合就會永久消失。建議你提供一種「記住」最佳建議的方式,例如「喜歡」按鈕。
新增商業邏輯
捲動至 MyAppState
,然後加入下列程式碼:
lib/main.dart
// ...
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
void getNext() {
current = WordPair.random();
notifyListeners();
}
// ↓ Add the code below.
var favorites = <WordPair>[];
void toggleFavorite() {
if (favorites.contains(current)) {
favorites.remove(current);
} else {
favorites.add(current);
}
notifyListeners();
}
}
// ...
查看變更:
- 您已在
MyAppState
中新增名為favorites
的屬性。這個屬性會使用空白清單[]
進行初始化。 - 您也使用泛型指定清單只能包含字詞組合:
<WordPair>[]
。這有助於讓應用程式更健全。如果您嘗試在應用程式中加入WordPair
以外的任何內容,Dart 甚至會拒絕執行應用程式。反過來說,您可以使用favorites
清單,知道其中不會有任何不必要的物件 (例如null
) 隱藏在其中。
- 您也新增了
toggleFavorite()
方法,可從最愛詞組清單中移除目前的詞組組合 (如果已存在),或新增詞組組合 (如果尚未存在)。無論是哪種情況,程式碼都會在之後呼叫notifyListeners();
。
新增按鈕
完成「商業邏輯」後,現在是時候再次處理使用者介面了。如要將「喜歡」按鈕放在「下一步」按鈕的左側,就必須使用 Row
。Row
小工具是 Column
的水平等價,您先前已看到這個小工具。
首先,請將現有的按鈕包裝在 Row
中。前往 MyHomePage
的 build()
方法,將游標放在 ElevatedButton
上,使用 Ctrl+.
或 Cmd+.
叫出「Refactor」選單,然後選取「Wrap with Row」。
儲存後,您會發現 Row
的運作方式與 Column
類似,預設會將子項堆疊在左側。(Column
將子項堆疊到頂端)。如要修正這個問題,您可以使用與先前相同的方法,但使用 mainAxisAlignment
。不過,如果是為了教學目的,請使用 mainAxisSize
。這會告訴 Row
不要佔用所有可用的水平空間。
進行下列變更:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min, // ← Add this.
children: [
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
],
),
),
);
}
}
// ...
使用者介面會恢復到先前的狀態。
接著,新增「Like」按鈕,並將其連結至 toggleFavorite()
。為了挑戰一下,請先嘗試自行完成這項操作,不要先查看下方的程式碼區塊。
您不必完全按照下方說明操作,事實上,除非你想挑戰自己,否則不必擔心愛心圖示。
失敗也沒關係,畢竟這是您第一次接觸 Flutter。
以下是將第二個按鈕新增至 MyHomePage
的一種方法。這次使用 ElevatedButton.icon()
建構函式建立含有圖示的按鈕。在 build
方法頂端,視目前的字詞組是否已加入我的最愛,選擇適當的圖示。此外,請注意再次使用 SizedBox
,以便將兩個按鈕稍微分開。
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
// ↓ Add this.
IconData icon;
if (appState.favorites.contains(pair)) {
icon = Icons.favorite;
} else {
icon = Icons.favorite_border;
}
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// ↓ And this.
ElevatedButton.icon(
onPressed: () {
appState.toggleFavorite();
},
icon: Icon(icon),
label: Text('Like'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
],
),
),
);
}
}
// ...
應用程式應如下所示:
很抱歉,使用者無法查看收藏內容。接下來,我們要為應用程式新增一個獨立的畫面。敬請期待下一節!
7. 新增導覽邊欄
大多數應用程式無法將所有內容都塞進單一畫面。這個特定應用程式可能可以,但為了教學目的,您將為使用者的最愛建立個別畫面。如要切換這兩個畫面,您將實作第一個 StatefulWidget
。
為了盡快進入這個步驟的重點,請將 MyHomePage
分割成 2 個獨立的小工具。
選取所有 MyHomePage
並刪除,然後替換為以下程式碼:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: 0,
onDestinationSelected: (value) {
print('selected: $value');
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: GeneratorPage(),
),
),
],
),
);
}
}
class GeneratorPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
IconData icon;
if (appState.favorites.contains(pair)) {
icon = Icons.favorite;
} else {
icon = Icons.favorite_border;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () {
appState.toggleFavorite();
},
icon: Icon(icon),
label: Text('Like'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
],
),
);
}
}
// ...
儲存後,您會看到 UI 的視覺效果已準備就緒,但無法正常運作。點選導覽列中的 ♥︎ (愛心圖示) 不會有任何效果。
查看變更。
- 首先,請注意
MyHomePage
的整個內容會擷取到新的GeneratorPage
小工具中。舊版MyHomePage
小工具中唯一未擷取的部分是Scaffold
。 - 新的
MyHomePage
包含一個具有兩個子項的Row
。第一個小工具是SafeArea
,第二個是Expanded
小工具。 SafeArea
可確保其子項不會遭硬體凹口或狀態列遮蔽。在這個應用程式中,小工具會包裝在NavigationRail
中,以免導覽按鈕遭到行動裝置狀態列遮蔽。- 您可以將 NavigationRail 中的
extended: false
行變更為true
。這樣一來,圖示旁就會顯示標籤。在後續步驟中,您將瞭解如何在應用程式有足夠水平空間時自動執行這項操作。 - 導覽列有兩個目的地 (主畫面和我的最愛),並顯示相應的圖示和標籤。並定義目前的
selectedIndex
。選取索引 0 會選取第一個目的地,選取索引 1 會選取第二個目的地,以此類推。目前,這項值已硬式編碼為零。 - 導覽列也會定義使用者選取
onDestinationSelected
時的結果。目前,應用程式只會使用print()
輸出要求的索引值。 Row
的第二個子項是Expanded
小工具。在列和欄中,展開的小工具非常實用,可讓您表示某些子項只占用所需空間 (在本例中為SafeArea
),而其他小工具則應盡可能占用剩餘空間 (在本例中為Expanded
)。Expanded
小工具的一種特性是「貪婪」。如要進一步瞭解這個小工具的角色,請嘗試將SafeArea
小工具包裝在另一個Expanded
中。產生的版面配置如下所示:
- 兩個
Expanded
小工具會將所有可用的水平空間平均分配,即使導覽列只需要左側的一小部分。 Expanded
小工具內有著色Container
,容器內則有GeneratorPage
。
無狀態與有狀態的小工具
到目前為止,MyAppState
已涵蓋所有狀態需求。因此,您到目前為止編寫的所有小工具都是無狀態。不含任何可變動的狀態。所有小工具都無法變更自身,必須透過 MyAppState
才能變更。
這項規定即將有所變動。
您需要設法保留導覽軌道 selectedIndex
的值。您也希望能夠在 onDestinationSelected
回呼中變更這個值。
您可以將 selectedIndex
新增為 MyAppState
的另一個屬性。而且會正常運作。但您可以想見,如果每個小工具都儲存其值,應用程式狀態很快就會變得過大。
某些狀態只與單一小工具相關,因此應保留在該小工具中。
輸入 StatefulWidget
,這是一種包含 State
的小工具。首先,將 MyHomePage
轉換為具狀態的小工具。
將滑鼠游標移至 MyHomePage
的第一行 (以 class MyHomePage...
開頭的那一行),然後使用 Ctrl+.
或 Cmd+.
叫出「Refactor」選單。然後選取「Convert to StatefulWidget」。
IDE 會為您建立新類別 _MyHomePageState
。這個類別會擴充 State
,因此可以管理自己的值。(可變更自身)。另請注意,來自舊版無狀態小工具的 build
方法已移至 _MyHomePageState
(而非留在小工具中)。它是逐字移至 build
方法中,因此 build
方法內部沒有任何變更。而現在則是位於其他位置。
setState
新的有狀態小工具只需要追蹤一個變數:selectedIndex
。對 _MyHomePageState
進行下列 3 項變更:
lib/main.dart
// ...
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0; // ← Add this property.
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: selectedIndex, // ← Change to this.
onDestinationSelected: (value) {
// ↓ Replace print with this.
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: GeneratorPage(),
),
),
],
),
);
}
}
// ...
查看變更:
- 您會引入新的變數
selectedIndex
,並將其初始化為0
。 - 您可以在
NavigationRail
定義中使用這個新變數,而非目前的硬式編碼0
。 - 呼叫
onDestinationSelected
回呼時,請將新值指派給setState()
呼叫中的selectedIndex
,而非只將新值列印到主控台。這個呼叫與先前使用的notifyListeners()
方法類似,可確保 UI 更新。
導覽列現在會回應使用者互動。但右側的展開區域保持不變。這是因為程式碼並未使用 selectedIndex
來判斷要顯示哪個畫面。
使用 selectedIndex
將下列程式碼放在 _MyHomePageState
的 build
方法最上方,緊接在 return Scaffold
之前:
lib/main.dart
// ...
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
// ...
請查看這段程式碼:
- 程式碼會宣告
Widget
類型的新變數page
。 - 接著,switch 陳述式會根據
selectedIndex
的目前值,將畫面指派給page
。 - 由於目前還沒有
FavoritesPage
,請使用Placeholder
;這個實用的小工具可在您放置的位置繪製交叉矩形,將該部分的 UI 標示為未完成。
- 套用快速失敗原則,如果
selectedIndex
不是 0 或 1,則切換語句也會確保擲回錯誤。這有助於避免日後發生錯誤。如果您曾在導覽列中新增目的地,但忘了更新這段程式碼,程式就會在開發階段當機 (而不是讓您猜測為何無法運作,或讓您將有錯誤的程式碼發布至正式環境)。
page
包含您要顯示在右側的小工具,您應該可以猜到需要進行哪些其他變更。
以下是 _MyHomePageState
在進行單一剩餘變更後的內容:
lib/main.dart
// ...
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page, // ← Here.
),
),
],
),
);
}
}
// ...
應用程式現在會在 GeneratorPage
和預留位置之間切換,後者很快就會成為「我的最愛」頁面。
積極回應
接下來,讓導覽邊欄具備回應性。也就是說,當有足夠空間時,系統會自動顯示標籤 (使用 extended: true
)。
Flutter 提供多種小工具,可讓應用程式自動回應。舉例來說,Wrap
是類似 Row
或 Column
的小工具,當垂直或水平空間不足時,會自動將子項換行至下一個「行」(稱為「run」)。FittedBox
是一種小工具,可根據您的規格自動將子項放入可用空間。
但 NavigationRail
無法自動在有足夠空間時顯示標籤,因為它無法在每個情境中判斷「足夠」的空間是什麼。這項決定由開發人員自行決定。
假設您決定只在 MyHomePage
寬度至少為 600 像素時顯示標籤。
在本例中,要使用的小工具是 LayoutBuilder
。您可以根據可用空間大小變更小工具樹狀結構。
再次提醒,請在 VS Code 中使用 Flutter 的「Refactor」選單,進行必要的變更。但這次的情況稍微複雜一點:
- 在
_MyHomePageState
的build
方法中,將游標放在Scaffold
上。 - 使用
Ctrl+.
(Windows/Linux) 或Cmd+.
(Mac) 叫出「Refactor」選單。 - 選取「Wrap with Builder」,然後按下 Enter 鍵。
- 將新新增的
Builder
名稱修改為LayoutBuilder
。 - 將回呼參數清單從
(context)
修改為(context, constraints)
。
每當限制條件變更時,系統就會呼叫 LayoutBuilder
的 builder
回呼。例如:
- 使用者調整應用程式視窗大小
- 使用者將手機從直向模式轉為橫向模式,或反之
MyHomePage
旁邊的部分小工具會變大,導致MyHomePage
的限制變小- 依此類推
程式碼現在可以查詢目前的 constraints
,決定是否要顯示標籤。對 _MyHomePageState
的 build
方法進行下列單行變更:
lib/main.dart
// ...
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
return LayoutBuilder(builder: (context, constraints) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: constraints.maxWidth >= 600, // ← Here.
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page,
),
),
],
),
);
});
}
}
// ...
應用程式現在會根據環境 (例如螢幕大小、螢幕方向和平台) 做出回應!換句話說,它是回應式!
接下來只需將 Placeholder
替換為實際的「我的最愛」畫面。下一節會說明這項功能。
8. 新增頁面
還記得我們用來取代「我的最愛」頁面的 Placeholder
小工具嗎?
是時候修正這個問題了。
如果您想嘗試,可以自行執行這項步驟。您的目標是在新的無狀態小工具 FavoritesPage
中顯示 favorites
清單,然後顯示該小工具,而非 Placeholder
。
以下提供幾個提示:
- 如要讓
Column
捲動,請使用ListView
小工具。 - 請記住,您可以使用
context.watch<MyAppState>()
從任何小工具存取MyAppState
例項。 - 如果您也想試試新的小工具,
ListTile
有title
(通常用於文字)、leading
(用於圖示或顯示圖片) 和onTap
(用於互動) 等屬性。不過,您可以使用熟悉的小工具達到類似效果。 - Dart 允許在集合文字常值中使用
for
迴圈。舉例來說,如果messages
包含字串清單,您可以使用以下程式碼:
另一方面,如果您較熟悉函式程式設計,Dart 也能讓您編寫 messages.map((m) => Text(m)).toList()
這類程式碼。當然,您隨時可以建立小工具清單,並在 build
方法中強制新增。
自行新增「我的最愛」頁面的優點是,您可以透過自行做出決策來學習更多知識。缺點是,您可能會遇到自己無法解決的問題。請記住:失敗沒關係,這是學習過程中最重要的元素之一。沒有人會期待您在第一小時內就掌握 Flutter 開發技巧,您也不應如此。
以下只是一種實作「我的最愛」頁面的方式。這項功能的實作方式 (希望如此) 會激發您嘗試使用程式碼,改善 UI 並將其視為自己的程式碼。
以下是新的 FavoritesPage
類別:
lib/main.dart
// ...
class FavoritesPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
if (appState.favorites.isEmpty) {
return Center(
child: Text('No favorites yet.'),
);
}
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Text('You have '
'${appState.favorites.length} favorites:'),
),
for (var pair in appState.favorites)
ListTile(
leading: Icon(Icons.favorite),
title: Text(pair.asLowerCase),
),
],
);
}
}
小工具的功能如下:
- 取得應用程式的目前狀態。
- 如果最愛清單為空白,系統會在中央顯示以下訊息:「尚未收藏任何內容」*。
- 否則,系統會顯示 (可捲動) 清單。
- 清單開頭會顯示摘要 (例如「你有 5 個收藏項目」*)。
- 接著,程式碼會逐一檢查所有收藏項目,並為每個收藏項目建構
ListTile
小工具。
接下來,您只需要將 Placeholder
小工具替換為 FavoritesPage
。大功告成!
您可以在 GitHub 的 codelab 存放區中取得這個應用程式的最終程式碼。
9. 後續步驟
恭喜!
你好厲害,您已將含有 Column
和兩個 Text
小工具的非功能性架構,改造成回應迅速且令人滿意的簡易應用程式。
涵蓋內容
- Flutter 運作方式的基本概念
- 在 Flutter 中建立版面配置
- 將使用者互動 (例如按下按鈕) 連結至應用程式行為
- 讓 Flutter 程式碼井然有序
- 讓應用程式提供良好回應
- 打造一致的應用程式外觀和風格
接下來該怎麼做?
- 請進一步嘗試在本實驗室中編寫的應用程式。
- 查看同一個應用程式的進階版本程式碼,瞭解如何新增動畫清單、漸層、交叉淡出等效果。
- 如要瞭解學習歷程,請前往 flutter.dev/learn。