您的第一個 Flutter 應用程式

1. 簡介

Flutter 是 Google 的 UI 工具包,可讓您透過單一程式碼集建構適用於行動、網頁和電腦的應用程式。在本程式碼研究室中,您將建構下列 Flutter 應用程式:

這個應用程式會產生冷卻名稱,例如「newstay」、「lightstream」、「mainbrake」或「graypine」。使用者可以要求下一個名稱、將目前名稱加入收藏,以及在個別頁面中檢閱常用名稱的清單。應用程式支援不同的螢幕大小。

課程內容

  • Flutter 運作方式的基本概念
  • 在 Flutter 中建立版面配置
  • 將使用者互動 (例如按下按鈕) 連結至應用程式行為
  • 妥善管理 Flutter 程式碼
  • 讓應用程式反應更靈敏 (針對不同螢幕)
  • 打造一致的風格及應用程式的風格

您將從基本的 Scaffold 著手,方便直接跳到有趣的部分。

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

請前往 docs.flutter.dev,查看最新的 Flutter SDK 安裝操作說明。

Flutter 網站上的說明不僅包含 SDK 本身的安裝作業,也涵蓋開發目標相關工具和編輯器外掛程式。請注意,在這個程式碼研究室中,您只需要安裝下列內容:

  1. Flutter SDK
  2. 含 Flutter 外掛程式的 Visual Studio Code
  3. 所選開發目標所需的軟體 (例如:使用 Visual Studio 指定 Windows,或用 Xcode 指定 macOS)

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

如果您目前遇到問題,或許可以參考 StackOverflow 中的一些問題和解答,來排解問題。

常見問題

3. 建立專案

建立您的第一個 Flutter 專案

啟動 Visual Studio Code,然後開啟指令區塊面板 (使用 F1Ctrl+Shift+PShift+Cmd+P)。開始輸入「flutter new」。選取「Flutter: New Project」指令。

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

最後為專案命名例如「namer_app」或「my_awesome_namer」。

260a7d97f9678005.png

Flutter 現在會建立專案資料夾,VS Code 會隨即開啟。

您現在可以使用應用程式的基本 Scaffold 覆寫 3 個檔案的內容。

複製和貼上初始應用程式

在 VS Code 的左側窗格中,確認已選取「Explorer」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.1.1

dependencies:
  flutter:
    sdk: flutter

  english_words: ^4.0.0
  provider: ^6.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^2.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」會顯示已按下按鈕! 的訊息。

Flutter 速成課程 (5 分鐘)

跟觀察偵錯控制台一樣有趣,您希望能讓按鈕執行更有意義的操作。不過,請先仔細查看 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 中的程式碼可設定整個應用程式。此工具會建立整個應用程式的狀態 (稍後會詳細說明)、為應用程式命名、定義視覺主題,以及設定「首頁」小工具,也就是應用程式的起點。

lib/main.dart

// ...

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
}

// ...

接著,MyAppState 類別會定義應用程式的...良好狀態。這是你第一次進入 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),然後按一下 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」選單。不過,這次系統不會擷取小工具。

請改為選取「納入邊框間距」。這會在 Text 小工具周圍建立名為 Padding 的新父項小工具。儲存後,您會看到隨機字詞有更多呼吸空間。

從預設值 8.0 增加邊框間距。舉例來說,使用 20 做為房間邊框間距。

接著再調高下一關。將遊標放在 Padding 小工具上,開啟「Refactor」選單,然後選取「Wrap with widget...」

這可讓您指定父項小工具。輸入「卡片」然後按下 Enter 鍵。

這會納入 Padding 小工具,因此也會納入含有 Card 小工具的 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 小工具會流暢地在值之間插入資料,因此 UI 不只是「跳躍」狀態。

資訊卡下方的向上按鈕也會改變顏色。這就是使用應用程式層級 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 屬性是用於顯示文字的大型樣式。這裡的字體寫法是「display」,例如「顯示字體」displayMedium 說明文件指出「顯示樣式只保留給簡短且重要的文字使用」,正如我們的用途。
  • 理論上,主題的 displayMedium 屬性可能是 null。Dart 是編寫這個應用程式時使用的程式設計語言,對空值無威脅,因此您無法呼叫可能為 null 的物件方法。不過在這種情況下,您可以使用 ! 運算子 (「bang 運算子」),確保 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

不過,有時還是需要執行一些作業。在這個應用程式中,螢幕閱讀器可能無法朗讀系統產生的部分字詞組合。雖然人類無法識別「便宜頭」中的兩個字詞,但螢幕閱讀器可能會將字詞中間的 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 置中

現在隨機字詞配對具有足夠的視覺吸引力,接下來應將隨機字詞組合放在應用程式的視窗/畫面中央。

首先,請記得 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 的中心。可以使用小工具檢查器進行驗證。

小工具檢查器本身不在本程式碼研究室的涵蓋範圍內,但您可以看到當 Column 醒目顯示時,不會佔滿應用程式的完整寬度。遊戲只會根據子項的需求佔用足夠的水平空間。

您可以將欄本身置於中心。將遊標懸停在 Column 上,呼叫「Refactor」選單 (使用 Ctrl+.Cmd+.),然後選取「使用中心換行」

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

455688d93c30d154.png

您可以視需要進一步調整。

  • 您可以移除 BigCard 上方的 Text 小工具。這可能表示不再需要描述性文字 (「隨機的想法:」),因為 UI 即使沒有這些內容也行得通,這種方法也更簡潔
  • 您也可以在 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'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ...

UI 會回到先前的位置。

3d53d2b071e2f372.png

接著,新增「喜歡」按鈕,並連結至 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。選取零的索引會選取第一個目的地,一個索引會選取第二個目的地,依此類推。目前採用硬式編碼為零。
  • 使用者透過 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 方法中沒有任何變更。而現在只是其他地方。

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. 然後,切換陳述式會根據 selectedIndex 中目前的值,將畫面指派給 page
  3. 由於目前沒有任何 FavoritesPage,因此請使用 Placeholder;這個實用的小工具可在您放置在任何位置時繪製一個交叉矩形,將使用者介面的該部分標示為未完成。

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 類似的小工具,可自動將子項換行到下一個「line」(稱為「執行」)。FittedBox:這個小工具會根據您的規格,自動將其子項調整為可用空間。

但如果空間足夠,NavigationRail 就不會「自動」顯示標籤,因為這項功能無法判斷每個內容的「是否」足夠空間。這個呼叫完全由開發人員決定。

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

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

再次使用 Flutter 的 VS Code 選單進行必要變更。但這時間會比較複雜:

  1. _MyHomePageStatebuild 方法中,將遊標放在 Scaffold 上。
  2. 使用 Ctrl+. (Windows/Linux) 或 Cmd+. (Mac) 呼叫「Refactor」選單。
  3. 選取「Wrap with Builder」(使用建構工具),然後按下 Enter 鍵。
  4. 將新新增至 LayoutBuilderBuilder 名稱修改。
  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 替換為實際的「Favorites」畫面。詳情請見下一節。

8. 新增頁面

還記得我們使用的 Placeholder 小工具,而不是「我的收藏」頁面嗎?

請立即修正這個問題。

如果喜歡冒險,不妨試著自己完成這個步驟。您的目標是在新的無狀態小工具 FavoritesPage 中顯示 favorites 清單,然後顯示該小工具,而非 Placeholder

以下提供幾個要點:

  • 當您希望 Column 捲動時,請使用 ListView 小工具。
  • 請記得,使用 context.watch<MyAppState>() 從任何小工具存取 MyAppState 例項。
  • 如果您想試用新的小工具,ListTile 具有 title (一般用於文字)、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 的程式碼研究室存放區中取得這個應用程式的最終程式碼。

9. 後續步驟

恭喜!

真厲害!您用了 Column 和兩個 Text 小工具,拍攝功能異常的鷹架,並將其打造成充滿樂趣的回應式小應用程式。

d6e3d5f736411f13.png

涵蓋內容

  • Flutter 運作方式的基本概念
  • 在 Flutter 中建立版面配置
  • 將使用者互動 (例如按下按鈕) 連結至應用程式行為
  • 妥善管理 Flutter 程式碼
  • 讓應用程式提供回應
  • 打造一致的風格及應用程式的風格

接下來該怎麼做?

  • 在這個研究室中,使用您編寫的應用程式進行更多實驗。
  • 查看同一個應用程式進階版本的程式碼,瞭解如何新增動畫清單、漸層、交叉淡出等效果。