您的第一個 Flutter 應用程式

1. 簡介

Flutter 是 Google 的 UI 工具包,可讓您根據單一程式碼集,打造出適合在行動、網路和桌機環境中執行的應用程式。在本程式碼研究室中,您將建構下列 Flutter 應用程式:

應用程式會產生聽起來很酷的名稱,例如「newstay」、「lightstream」、「mainbrake」或「graypine」。使用者可以要求下一個名稱、將目前的名稱設為最愛,並在另一個頁面查看最愛的名稱清單。應用程式可配合不同的螢幕大小做出回應。

課程內容

  • Flutter 運作方式的基本概念
  • 在 Flutter 中建立版面配置
  • 將使用者互動 (例如按下按鈕) 連結至應用程式行為
  • 讓 Flutter 程式碼井然有序
  • 讓應用程式能回應 (適用於不同螢幕)
  • 打造一致的應用程式外觀和風格

您將從基本架構開始,直接跳到有趣的部分。

e9c6b402cd8003fd.png

接下來,請看 Filip 為您介紹整個程式碼研究室!

按一下「下一步」開始實驗室。

2. 設定 Flutter 環境

編輯者

為了讓本程式碼研究室盡可能簡單明瞭,我們假設您會使用 Visual Studio Code (VS Code) 做為開發環境。這項服務免費提供,且支援所有主要平台。

當然,您也可以使用任何喜歡的編輯器:Android Studio、其他 IntelliJ IDE、Emacs、Vim 或 Notepad++。這些編輯器都支援 Flutter。

我們建議您使用 VS Code 進行本程式碼研究室,因為操作說明預設為 VS Code 專屬的捷徑。比起「在編輯器中執行適當動作來執行 X」,說「按這鍵」或「按這裡」會更容易。

228c71510a8e868.png

選擇開發目標

Flutter 是多平台工具包。您的應用程式可在下列任一作業系統上執行:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • 網路

不過,一般來說,您應該選擇一個主要開發作業系統。這就是您的「開發目標」:應用程式在開發期間執行的作業系統。

16695777c07f18e5.png

舉例來說,假設您使用 Windows 筆電開發 Flutter 應用程式,如果選擇 Android 做為開發目標,通常會使用 USB 傳輸線將 Android 裝置連接至 Windows 筆電,然後在該連結的 Android 裝置上執行開發中的應用程式。不過,您也可以選擇 Windows 做為開發目標,這表示開發中的應用程式會以 Windows 應用程式形式執行,並與編輯器搭配使用。

您可能會想選取網路做為開發目標。這個選項的缺點是,您將失去 Flutter 最實用的開發功能之一:具狀態的熱載重載。Flutter 無法熱重載網頁應用程式。

立即做出選擇。提醒您:您隨時可以稍後在其他作業系統上執行應用程式。只是有明確的開發目標,後續步驟就能更順利。

安裝 Flutter

如需最新的 Flutter SDK 安裝操作說明,請前往 docs.flutter.dev

Flutter 網站上的操作說明涵蓋 SDK 本身的安裝作業,以及與開發目標相關的工具和編輯器外掛程式。請注意,您只需要為本程式碼研究室安裝以下項目:

  1. Flutter SDK
  2. 搭配 Flutter 外掛程式的 Visual Studio Code
  3. 所選開發目標所需的軟體 (例如:針對 Windows 的 Visual Studio,或針對 macOS 的 Xcode)

在下一節中,您將建立第一個 Flutter 專案。

如果您目前遇到問題,這些 StackOverflow 問題和解答可能有助於排解問題。

常見問題

3. 建立專案

建立第一個 Flutter 專案

啟動 Visual Studio Code 並開啟指令面板 (使用 F1Ctrl+Shift+PShift+Cmd+P)。開始輸入「flutter new」選取「Flutter:新專案」指令。

接著,選取「Application」,然後選取要建立專案的資料夾。這可能是您的主目錄,或類似 C:\src\ 的內容。

最後,為專案命名。例如 namer_appmy_awesome_namer

260a7d97f9678005.png

Flutter 會建立專案資料夾,並由 VS Code 開啟。

您現在將使用應用程式的基礎架構覆寫 3 個檔案的內容。

複製並貼上初始應用程式

在 VS Code 的左側面板中,確認已選取「Explorer」,然後開啟 pubspec.yaml 檔案。

e2a5bab0be07f4f7.png

將這個檔案的內容替換成以下內容:

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 中開啟另一個設定檔。

a781f218093be8e0.png

將內容取代為以下內容:

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 檔案。

e54c671c9bb4d23d.png

將這個檔案的內容替換成以下內容:

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 視窗右上角的「播放」b0a5d0200af5985d.png 按鈕,然後按一下。

大約一分鐘後,應用程式就會以偵錯模式啟動。目前看起來不怎麼樣:

f96e7dfb0937d7f4.png

第一次熱重載

lib/main.dart 底部,將內容新增至第一個 Text 物件的字串,然後使用 Ctrl+SCmd+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 中的程式碼)。這樣一來,應用程式中的任何小工具都能取得狀態。d9b6ecac5494a6ff.png

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,也就是您已修改過的小工具。下方每個編號行都對應至上述程式碼中的行號註解:

  1. 每個小工具都會定義 build() 方法,這個方法會在小工具的情況變更時自動呼叫,讓小工具隨時保持最新狀態。
  2. MyHomePage 會使用 watch 方法追蹤應用程式目前狀態的變更。
  3. 每個 build 方法都必須傳回小工具,或 (更常見的) 巢狀的小工具樹狀結構。在本例中,頂層小工具是 Scaffold。您不會在本程式碼研究室中使用 Scaffold,但這是一個實用的資訊方塊,在大多數實際的 Flutter 應用程式中都會用到。
  4. Column 是 Flutter 中最基本的版面配置小工具之一。它會將任意數量的子項從上到下放入一欄中。根據預設,欄會將子項視覺化置於頂端。您很快就會變更這項設定,讓資料欄居中顯示。
  5. 您在第一步驟中變更了這個 Text 小工具。
  6. 這個第二個 Text 小工具會採用 appState,並存取該類別的唯一成員 current (即 WordPair)。WordPair 提供多個實用的 getter,例如 asPascalCaseasSnakeCase。我們在這裡使用 asLowerCase,但如果您偏好其他選項,現在可以變更。
  7. 請注意,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. 讓應用程式更美觀

應用程式目前的樣貌如下。

3dd8a9d8653bdc56.png

不太好。應用程式的重點 (隨機產生的字詞組合) 應更醒目。畢竟,這也是使用者使用這個應用程式的主要原因!此外,應用程式內容會奇怪地偏離中心,整個應用程式也只有單調的黑白色。

本節將透過調整應用程式設計來解決這些問題。本節的最終目標如下:

2bbee054d81a3127.png

擷取小工具

負責顯示目前字詞組的程式碼行現在會像這樣: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 中,您可以透過以下任一方式執行此操作:

  1. 在您要重構的程式碼片段 (本例為 Text) 上按一下滑鼠右鍵,然後從下拉式選單中選取「Refactor...」

  1. 將游標移至要重構的程式碼片段 (在本例中為 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

6031adbc0a11e16b.png

主題和樣式

如要讓卡片更醒目,請使用更鮮豔的顏色進行繪製。由於一貫使用一致的色彩配置是明智的做法,請使用應用程式的 Theme 選擇顏色。

BigCardbuild() 方法進行下列變更。

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 是應用程式最顯眼、最具代表性的顏色。

資訊卡現在會以應用程式的主要顏色繪製:

a136f7682c204ea1.png

如要變更此顏色和整個應用程式的色彩配置,請向上捲動至 MyApp,然後變更該處 ColorScheme 的種子顏色。

請注意顏色動畫的流暢程度。這稱為隱含動畫。許多 Flutter 小工具會在值之間平滑內插,以免使用者介面在狀態之間「跳躍」。

資訊卡下方的浮動按鈕也會變色。這就是使用應用程式全域 Theme 而非硬式編碼值的優勢。

TextTheme

但這張資訊卡仍有問題:文字太小,且顏色難以閱讀。如要修正這個問題,請對 BigCardbuild() 方法進行下列變更。

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 屬性會定義適合用於應用程式主要顏色的顏色。

應用程式現在應如下所示:

2405e9342d28c193.png

如有需要,請進一步修改資訊卡。不妨參考下列建議:

  • copyWith() 可讓您變更文字樣式的更多內容,而非僅限於顏色。如要取得可變更的完整屬性清單,請將游標放在 copyWith() 的括號內,然後按下 Ctrl+Shift+Space (Win/Linux) 或 Cmd+Shift+Space (Mac)。
  • 同樣地,您也可以變更更多 Card 小工具的設定。舉例來說,您可以增加 elevation 參數的值,藉此放大資訊卡的陰影。
  • 嘗試使用不同的顏色。除了 theme.colorScheme.primary 之外,還有 .secondary.surface 和其他許多旗標。所有這些顏色都有 onPrimary 對應項目。

改善無障礙功能

Flutter 會預設讓應用程式可供存取。舉例來說,每個 Flutter 應用程式都會正確向 TalkBack 和 VoiceOver 等螢幕閱讀器顯示應用程式中的所有文字和互動元素。

d1fad7944fb890ea.png

不過,有時仍需要進行一些工作。就這個應用程式而言,螢幕閱讀器可能無法正確發音某些產生的字詞組合。雖然人類可以輕鬆辨識 cheaphead 中的兩個字,但螢幕閱讀器可能會將字詞中間的 ph 唸成 f

簡單的解決方法是將 pair.asLowerCase 替換為 "${pair.first} ${pair.second}"。後者則使用字串插補,根據 pair 中包含的兩個字詞建立字串 (例如 "cheap head")。使用兩個單字而非複合字,可確保螢幕閱讀器正確辨識這些字詞,並為視障人士提供更優質的使用體驗。

不過,您可能會想要保留 pair.asLowerCase 的視覺簡潔性。使用 TextsemanticsLabel 屬性,將文字小工具的視覺內容覆寫為更適合螢幕閱讀器的語意內容:

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 置中

隨機字詞組合已以足夠的視覺效果呈現,現在是時候將其置於應用程式視窗/畫面的中央了。

首先,請注意 BigCardColumn 的一部分。根據預設,欄會將子項堆疊到頂端,但我們可以輕鬆覆寫這項設定。前往 MyHomePagebuild() 方法,並進行以下變更:

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 內的子項沿著主 (垂直) 軸置中。

b555d4c7f5000edf.png

子項已沿著欄的交叉軸對齊 (也就是已水平對齊)。但 Column 本身並未置中於 Scaffold 內。我們可以使用小工具檢查器來驗證這項資訊。

Widget Inspector 本身不在本程式碼研究室的說明範圍內,但您可以看到,當 Column 醒目顯示時,它不會佔用應用程式的整個寬度,只會佔用子項所需的水平空間。

您可以將資料欄置中。將游標移至 Column,叫出「Refactor」選單 (使用 Ctrl+.Cmd+.),然後選取「Wrap with Center」

應用程式現在應如下所示:

455688d93c30d154.png

如有需要,您可以進一步調整這項設定。

  • 您可以移除 BigCard 上方的 Text 小工具。有人認為,既然 UI 即使沒有說明文字也能讓人理解,因此說明文字 (「A random AWESOME idea:」) 已無必要。這樣會比較整齊。
  • 您也可以在 BigCardElevatedButton 之間新增 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'),
            ),
          ],
        ),
      ),
    );
  }
}

// ...

應用程式如下所示:

3d53d2b071e2f372.png

在下一節中,您將新增將產生的字詞設為「喜歡」的功能。

6. 新增功能

應用程式運作正常,偶爾甚至會提供有趣的字詞組合。但只要使用者點選「下一步」,每個字詞組合就會永久消失。建議你提供一種「記住」最佳建議的方式,例如「喜歡」按鈕。

e6b01a8c90df8ffa.png

新增商業邏輯

捲動至 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();

新增按鈕

完成「商業邏輯」後,現在是時候再次處理使用者介面了。如要將「喜歡」按鈕放在「下一步」按鈕的左側,就必須使用 RowRow 小工具是 Column 的水平等價,您先前已看到這個小工具。

首先,請將現有的按鈕包裝在 Row 中。前往 MyHomePagebuild() 方法,將游標放在 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'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ...

使用者介面會恢復到先前的狀態。

3d53d2b071e2f372.png

接著,新增「Like」按鈕,並將其連結至 toggleFavorite()。為了挑戰一下,請先嘗試自行完成這項操作,不要先查看下方的程式碼區塊。

e6b01a8c90df8ffa.png

您不必完全按照下方說明操作,事實上,除非你想挑戰自己,否則不必擔心愛心圖示。

失敗也沒關係,畢竟這是您第一次接觸 Flutter。

252f7c4a212c94d2.png

以下是將第二個按鈕新增至 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

f62c54f5401a187.png

為了盡快進入這個步驟的重點,請將 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 的視覺效果已準備就緒,但無法正常運作。點選導覽列中的 ♥︎ (愛心圖示) 不會有任何效果。

388bc25fe198c54a.png

查看變更。

  • 首先,請注意 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 中。產生的版面配置如下所示:

6bbda6c1835a1ae.png

  • 兩個 Expanded 小工具會將所有可用的水平空間平均分配,即使導覽列只需要左側的一小部分。
  • Expanded 小工具內有著色 Container,容器內則有 GeneratorPage

無狀態與有狀態的小工具

到目前為止,MyAppState 已涵蓋所有狀態需求。因此,您到目前為止編寫的所有小工具都是無狀態。不含任何可變動的狀態。所有小工具都無法變更自身,必須透過 MyAppState 才能變更。

這項規定即將有所變動。

您需要設法保留導覽軌道 selectedIndex 的值。您也希望能夠在 onDestinationSelected 回呼中變更這個值。

可以selectedIndex 新增為 MyAppState 的另一個屬性。而且會正常運作。但您可以想見,如果每個小工具都儲存其值,應用程式狀態很快就會變得過大。

e52d9c0937cc0823.jpeg

某些狀態只與單一小工具相關,因此應保留在該小工具中。

輸入 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(),
            ),
          ),
        ],
      ),
    );
  }
}

// ...

查看變更:

  1. 您會引入新的變數 selectedIndex,並將其初始化為 0
  2. 您可以在 NavigationRail 定義中使用這個新變數,而非目前的硬式編碼 0
  3. 呼叫 onDestinationSelected 回呼時,請將新值指派給 setState() 呼叫中的 selectedIndex,而非只將新值列印到主控台。這個呼叫與先前使用的 notifyListeners() 方法類似,可確保 UI 更新。

導覽列現在會回應使用者互動。但右側的展開區域保持不變。這是因為程式碼並未使用 selectedIndex 來判斷要顯示哪個畫面。

使用 selectedIndex

將下列程式碼放在 _MyHomePageStatebuild 方法最上方,緊接在 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');
}

// ...

請查看這段程式碼:

  1. 程式碼會宣告 Widget 類型的新變數 page
  2. 接著,switch 陳述式會根據 selectedIndex 的目前值,將畫面指派給 page
  3. 由於目前還沒有 FavoritesPage,請使用 Placeholder;這個實用的小工具可在您放置的位置繪製交叉矩形,將該部分的 UI 標示為未完成。

5685cf886047f6ec.png

  1. 套用快速失敗原則,如果 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)。

a8873894c32e0d0b.png

Flutter 提供多種小工具,可讓應用程式自動回應。舉例來說,Wrap 是類似 RowColumn 的小工具,當垂直或水平空間不足時,會自動將子項換行至下一個「行」(稱為「run」)。FittedBox 是一種小工具,可根據您的規格自動將子項放入可用空間。

NavigationRail 無法自動在有足夠空間時顯示標籤,因為它無法在每個情境中判斷「足夠」的空間什麼。這項決定由開發人員自行決定。

假設您決定只在 MyHomePage 寬度至少為 600 像素時顯示標籤。

在本例中,要使用的小工具是 LayoutBuilder。您可以根據可用空間大小變更小工具樹狀結構。

再次提醒,請在 VS Code 中使用 Flutter 的「Refactor」選單,進行必要的變更。但這次的情況稍微複雜一點:

  1. _MyHomePageStatebuild 方法中,將游標放在 Scaffold 上。
  2. 使用 Ctrl+. (Windows/Linux) 或 Cmd+. (Mac) 叫出「Refactor」選單。
  3. 選取「Wrap with Builder」,然後按下 Enter 鍵。
  4. 將新新增的 Builder 名稱修改為 LayoutBuilder
  5. 將回呼參數清單從 (context) 修改為 (context, constraints)

每當限制條件變更時,系統就會呼叫 LayoutBuilderbuilder 回呼。例如:

  • 使用者調整應用程式視窗大小
  • 使用者將手機從直向模式轉為橫向模式,或反之
  • MyHomePage 旁邊的部分小工具會變大,導致 MyHomePage 的限制變小
  • 依此類推

程式碼現在可以查詢目前的 constraints,決定是否要顯示標籤。對 _MyHomePageStatebuild 方法進行下列單行變更:

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 例項。
  • 如果您也想試試新的小工具,ListTiletitle (通常用於文字)、leading (用於圖示或顯示圖片) 和 onTap (用於互動) 等屬性。不過,您可以使用熟悉的小工具達到類似效果。
  • Dart 允許在集合文字常值中使用 for 迴圈。舉例來說,如果 messages 包含字串清單,您可以使用以下程式碼:

f0444bba08f205aa.png

另一方面,如果您較熟悉函式程式設計,Dart 也能讓您編寫 messages.map((m) => Text(m)).toList() 這類程式碼。當然,您隨時可以建立小工具清單,並在 build 方法中強制新增。

自行新增「我的最愛」頁面的優點是,您可以透過自行做出決策來學習更多知識。缺點是,您可能會遇到自己無法解決的問題。請記住:失敗沒關係,這是學習過程中最重要的元素之一。沒有人會期待您在第一小時內就掌握 Flutter 開發技巧,您也不應如此。

252f7c4a212c94d2.png

以下只是一種實作「我的最愛」頁面的方式。這項功能的實作方式 (希望如此) 會激發您嘗試使用程式碼,改善 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 小工具的非功能性架構,改造成回應迅速且令人滿意的簡易應用程式。

d6e3d5f736411f13.png

涵蓋內容

  • Flutter 運作方式的基本概念
  • 在 Flutter 中建立版面配置
  • 將使用者互動 (例如按下按鈕) 連結至應用程式行為
  • 讓 Flutter 程式碼井然有序
  • 讓應用程式提供良好回應
  • 打造一致的應用程式外觀和風格

接下來該怎麼做?

  • 請進一步嘗試在本實驗室中編寫的應用程式。
  • 查看同一個應用程式的進階版本程式碼,瞭解如何新增動畫清單、漸層、交叉淡出等效果。