iT邦幫忙

2024 iThome 鐵人賽

DAY 3
0

為了對 Flutter 專案有個大略的認識,下面我們將快速實作一個簡單的應用。

建立專案

$ flutter create my_app

# 或者使用更多參數:
$ flutter create --project_name my_app --org com.example --template app --empty --android-language java --ioslanguage objc my_app

實務上,雖然我們後續可以修改 iOS 的 Bundle Id 和 Android 的 Application ID,但使用 --org 參數可以避免後續修改的麻煩。其他參數僅供參考,讓我們概略了解 Flutter 命令的全面,例如可以指定平台使用的程式語言。

執行應用程式

預設情況下,Flutter 會提供一個簡單的計數器範例。這個專案是可以直接運行的。

我們可以直接使用指令:

# 檢查裝置或模擬器是否正確
$ flutter devices
$ flutter emulators

$ cd my_app
$ flutter run

又或者使用編輯器。

使用編輯器開啟我們的專案,並切換到程式的進入點 lib/main.dart ,然後可以啟動偵錯。

Bonus: 若您使用官方推薦的 Visual Studio Code ,可以試試 Cursor 編輯器,其基於 VS Code 提供強大的 LLM 功能非常值得一試。

熱替換 Hot Reload

Flutter 支援狀態的熱替換,這個功能讓正在運行的應用程式可以更新狀態而不用整個重起或重新編譯。執行 flutter run 預設會啟動偵錯模式。偵錯模式以性能為代價提供一些協助開發的功能例如熱替換等等。效能不佳是可預期的,一旦你需要分析效能或者釋出正式版,可以換成使用效能分析(profile)或正式版(release)模式。

目前 Flutter 網頁應用程式支援 Hot Restart 而非 Hot Reload。

一般來說,從支援的編輯器或者指令介面執行應用程式便可支援 Hot Reload,無論是實體裝置或模擬器都沒問題,唯一條件是必須在偵錯模式下執行。當我們編輯程式碼並儲存時,Hot Reload 會將程式碼注入運行的 Dart VM,然後 Flutter 會自動更新介面。大部分的程式都可以進行 Hot Reload,只有少部分的情況須使用 Hot Restart。

Hot Reload 會保留狀態,不會重新執行 main()initState() 。但 Hot Restart 會重啟 Flutter 應用程式。另外,某些特殊情況可能會造成 Hot Reload 失效,例如:

  • 應用程式被關閉
  • 出現編譯錯誤
  • CupertinoTabViewbuilder 進行更改
  • 泛型型別變更
  • 更改了原生平台的程式碼

若您使用 Visual Studio Code 進行程式編輯的話,建議安裝 Flutter extension for VS Code

安裝函式庫

類似於 JavaScript 專案中的 package.json,Flutter 專案的相依性設定檔為 pubspec.yaml。我們的範例需要安裝下面兩個套件,你可以選擇編輯 pubspec.yaml 然後 pub get 或者指令直接安裝:

# 完整安裝
$ flutter pub get

# 安裝套件
$ flutter pub add english_words
$ flutter pub add provider

注意:如果你使用 VS Code 並安裝了 Flutter 擴充套件的話,編輯 pubspec.yaml 會自動執行 flutter pub get 安裝套件。這些開發工具整合的相當完整,提供了良好的 DX 體驗。

  • pubspec.yaml 檔案設定了應用程式的基本資訊,例如當前版本,相依套件等等。

  • analysis_options.yaml 則是用來設定 Flutter 的程式碼該遵守的規範。隨著對 Flutter 和 Dart 的熟悉應該要設定的比較嚴格。

範例練習

第一個範例 lib/main.dart

# 安裝套件
$ flutter pub add english_words
$ flutter pub add provider

這裡我們直接提供完整程式碼,讓您可以快速概覽。(建議您可以嘗試直接使用 macOS 或 Windows 模擬器體驗一下自適應設計)

import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(const 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('單字:'),
          Text(appState.current.asLowerCase),
          ElevatedButton(
            onPressed: () {
              print('按鈕被點擊');
            },
            child: Text('下一個'),
          ),
        ],
      ),
    );
  }
}

現在,在編輯器中,你可能會看到一些波浪符號的警告。別擔心,這是因為預設的 Lint 規則比較嚴格。我們可以先關閉這些規範。隨著對 Dart 深入理解,我們應該逐漸遵循這些規範提升我們程式碼的品質。

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

程式碼解析

首先在 lib/main.dart

void main() {
  runApp(MyApp());
}

main 作為程式的進入點,告訴 Flutter 執行定義在 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()
      )
    );
  }
}

MyApp 繼承了 StatelessWidget 類別。Widget 就是 Flutter 應用程式的基本元素。如你所見,即便是應用程式本身也是一個組件Widget

MyApp 代表整個應用程式,它建立了一個應用程式層級的狀態,設定了標題,樣式主題,並指定了 home 這個首頁組件。這個組件就是整個應用程式的起點。

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

接著,MyAppState 類別定義了應用層級的狀態。因為這是第一個 Flutter 範例,我們儘可能保持簡單。當然還有其他強大的方式可以管理 Flutter 的狀態,但 ChangeNotifier 是相對容易理解的方式。

  • MyAppState 定義了所需要的資料也就是狀態。這個範例只包含一個變數 - 隨機的單字。
  • MyAppState 這個類別繼承了 ChangeNotifier 意思是一旦資料改變,它可以通知其他人資料發生變更。舉例來說,如果目前 current 改變了,應用程式中一些有訂閱的組件就會收到通知。
  • 整個應用程式使用 ChangeNotifierProvider(如在 MyApp 的程式碼中所示)來提供這個狀態,這樣應用程式中的任何組件都可以輕易獲取和使用這個狀態。非常類似於 React 的 Context Provider。
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {           // ← 1
    var appState = context.watch<MyAppState>();  // ← 2

    return Scaffold(                             // ← 3
      body: Column(                              // ← 4
        children: [
          Text('單字:'),        // ← 5
          Text(appState.current.asLowerCase),    // ← 6
          ElevatedButton(
            onPressed: () {
              print('按鈕被點擊');
            },
            child: Text('下一個'),
          ),
        ],                                       // ← 7
      ),
    );
  }
}

最後針對 MyHomePage 進行說明:

  1. 每個組件都會定義 build() 方法,每當組件發生變化時,這個方法就會被自動執行,確保組件更新。(類似於 React Class 元件的 render 方法)。
  2. MyHomePage 使用 context.watch 訂閱了狀態。
  3. 每一個 build 方法必須要回傳一個 Widget 或者 Widget 樹狀結構 。在這個範例最上層的 Widget 是 Scaffold。 雖然目前的教學,我們不會直接控制 Scaffold,但它是一個非常實用的 Widget,並且在絕大多數實務 Flutter 應用程式中都能見到。
  4. Column 也是基本的 Widget。它可以接收 children 並將它們由上而下垂直呈現,後續我們將學習如何調整佈局。
  5. Text 很直觀的就是顯示文字
  6. 第二個 Text 使用了 appState ,並存取該類別的成員也就是 current 屬性,這個屬性是 WordPair 物件實例 ,此外,WordPair 還提供了一些有用的存取子(getter)如 asPascalCaseasSnakeCase。這個範例使用了 asLowerCase
  7. 注意到 Flutter 的程式碼大量使用結尾的逗號 , 。這個結尾逗號其實不是必須的,例如 Column 的唯一參數 children 是不需要使用的,然而,使用結尾逗號後續增加元素或參數比較方便,也提示 Dart 的自動格式化工具在那裡換行。

第一個互動操作

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

  // 加入方法
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }
}

新加入的 getNext() 方法使用 WordPair 重新為 current 賦值,然後執行 notifyListeners() 這是 ChangeNotifier 的一個方法,確保有 watch 訂閱 MyAppState 的地方會被通知。

Tips:這些狀態管理相關的方法來自 provider 套件。

接下來,我們需要在按鈕點擊的時候執行 getNext

ElevatedButton(
	onPressed: () {
    appState.getNext();
  },
  child: Text('下一個'),
),

調整佈局

目前我們的狀態值使用 Text(appState.current.asLowerCase) 呈現。為了調整佈局,我們可以將這一行程式碼抽成獨立的 Widget。獨立 Widget 和邏輯對於處理複雜 UI 是非常重要的方式。

Flutter 提供了重構的輔助功能來擷取獨立 Widget,但在你使用之前,請確保該行程式碼單純只存取它所需的資訊。目前這行程式讀取了整個 appState 但實際上它只需要知道目前 WordPair 的資料是什麼就好了。為此我們改寫 MyHomePage

若您曾經有開發 React 或 React Native 的經驗,大概可以理解我們儘可能的要避免因為不相關的狀態造成重新渲染的議題。

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current; // <- 這裡

    return Scaffold(
      body: Column(
        children: [
          Text('單字:'),
          Text(pair.asLowerCase), // <- 這裡
          ElevatedButton(
            onPressed: () {
              appState.getNext();
            },
            child: Text('下一個'),
          ),
        ],
      ),
    );
  }
}

現在 Text 不再參考整個 appState 了。接著,使用推薦的編輯器,請點擊右鍵「重構」選擇 Extract Widget (或者 macOS Cmd + . )然後輸入 BigCard 會自動幫我們建立一個新的類別 BigCard

觀看官方的教學影片學習更多好用的技巧。

class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @override
  Widget build(BuildContext context) {
    return Text(pair.asLowerCase);
  }
}

然後在 BigCard類別的 build() 方法,如之前的操作執行重構 Text 但這次我們不選 Extract the Widget 而是選擇 Wrap with Padding 這個操作會建立一個上層 Widget Padding 包住 Text 。接著調整預設值 8.0 為 20。

在 Flutter 中,如果可以組合就先使用組合而不是繼承。這裡我們使用單獨的 Padding Widget 而不是 Textpadding 屬性來實現效果。這樣做可以讓 Widget 專注於單一職責,開發者也可以更自由地組合介面因應需求的變更。

下一步將滑鼠移至 Padding Widget 一樣重構選擇 Wrap with widget... 輸入 Card 然後 Enter。

class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Text(pair.asLowerCase),
      ),
    );
  }
}

接著,為了更凸顯卡片,我們為它設定豐富的顏色。為了保持色調的一致性我們使用 Theme

class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);  // <- 這裡

    return Card(
      color: theme.colorScheme.primary, // <- 這裡
      child: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Text(pair.asLowerCase),
      ),
    );
  }
}
  • 首先我們需要在 MyApp 設定的 ThemeData資料。可以利用 Theme.of(context) 取得
  • 然後設定 Card 的顏色,使用相同的 primary 顏色

Flutter 的 Colors 類別可以方便的存取調色盤的顏色,如果要自己設定可以使用 Color.fromRGBO(0, 255, 0, 1.0),如果要使用 Hex 可以用 Color(0xFF00FF00)

注意到顏色變化的平滑過渡效果。這是隱式動畫(Implicit animation)的結果。許多 Flutter Widget 都支援在值之間平滑地進行插值(Interpolate),讓 UI 不只是在狀態之間直接切換。ElevatedButton 也支援這個效果。

現在讓調整文字樣式

// ...
@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),
      // ↓  加入此行
      child: Text(pair.asLowerCase, style: style),
    ),
  );
  
}
// ...
  • 使用 theme.textTheme 可以存取字體樣式主題。這個類別包含成員如 bodyMedium 標準字體中等大小,caption 圖片的說明,headlineLarge 標題大型字體等等。
  • displayMedium 屬性是用來顯示文字的大型樣式。display 表示排版,例如 displayMedium 的文件說明為 display 樣式是專為簡短、重要文字使用。
  • displayMedium 屬性理論上可能為 null 。因為 Dart 程式語言屬於一種 Null-safe 的語言,所以它不允許你呼叫一個物件可能為 null 的屬性。在這個情況下我們使用 ! 運算子 (Bang Operator)跟 Dart 保證我們知道自己在幹嘛。因為 displayMedium 在這個例子中原則上不會為 null。
  • 執行 copyWith()會回傳該樣式設定的副本,這個例子我們只調整字體顏色。
  • 為了取得顏色,我們再次存取 app 的 theme。onPrimary 屬性定義了符合 app 主色調的顏色。
  • Control + Shift + R 、 Cmd + . = 重構
  • Cmd + Shift + Space = 查看屬性列表

無障礙

Flutter 預設支援無障礙。例如 Flutter 應用程式介面上的文字和互動元素都支援 Android 的 TalkBack 和 iOS 的VoiceOver。

不過,有時候還是需要做一些額外的設定,在這個例子輔助閱讀器可能無法唸出產生的名字。雖然人類可以辨識 cheaphead 是兩個單字組成,但輔助閱讀器不知道如何區分。

一個簡單的解法就是將 pair.asLowerCase 變成 "${pair.first} ${pair.second}"。使用分開的兩個字取代組合好的字確保輔助閱讀器可以識別。

不過,我們可能希望保持 pair.asLowerCase 的呈現方式。這是可以使用 TextsemanticsLabel 語意標籤屬性可以協助輔助閱讀器理解。

return Card(
  color: theme.colorScheme.primary,
  elevation: 10.0,
  child: Padding(
    padding: const EdgeInsets.all(20.0),
    child: Text(
      pair.asLowerCase,
      style: style,
      semanticsLabel: "${pair.first} ${pair.second}", // <- 這裡
    ),
  ),
);

佈局置中

到此,我們的單字卡片本身的視覺效果已經不錯了,但還需要調整排版使其置中。

首先,BigCardColumn 的一部分, Column 會由上而下排列子元素,並且預設向上對齊。到 MyHomePagebuild() 加入

return Scaffold(
  body: Column(
    mainAxisAlignment: MainAxisAlignment.center,  // ← 加入
    children: [
      Text('單字:'),
      BigCard(pair: pair),
      ElevatedButton(
        onPressed: () {
          appState.getNext();
        },
        child: Text('下一個'),
      ),
    ],
  ),
);

增加我的最愛功能

class MyAppState extends ChangeNotifier {
  void current = WordPair.random();
  
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }
  
  var favorites = <WordPair>[];
  
  void toggleFavorites() {
    if (favorites.contains(current)) {
      favorites.remove(current);
    } else {
      favorites.add(current);
    }
    notifyListeners();
  }
}

MyHomePage 使用 ElevatedButton.icon() 建構子來建立按鈕。

class MyHomePage 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 Scaffold(
      body: 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('讚'),
                ),
                SizedBox(width: 10),
                ElevatedButton(
                    onPressed: () {
                      appState.getNext();
                    },
                    child: Text('下一個')),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

使用下面程式碼取代 MyHomePage

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('首頁'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('我的最愛'),
                ),
              ],
              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('讚'),
              ),
              SizedBox(width: 10),
              ElevatedButton.icon(
                onPressed: () {
                  appState.getNext();
                },
                icon: Icon(Icons.refresh),
                label: Text('下一個'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}
  • 首先整個 MyHomePage 的內容被提取成 GeneratorPage。原本 Widget 中只有 Scaffold 沒有被提取。
  • 新的 MyHomePage 包含 Row 和兩個子 Widget 分別是 SafeAreaExpand
  • SafeArea 確保它的子元素不會被硬體設計或 Status Bar 擋住,在此應用程式中,包住 NavigationRail,以防止導覽按鈕被移動狀態列遮擋。
  • 你可以嘗試調整 NavigationRailextended: falsetrue。就會展開邊寬顯示標籤文字。後續教學我們會學習如何根據視窗大小自動縮放
  • NavigationRail 有兩個 Destination 就是 HomeFavorites,各自包含對應的 Icon,同時也設定了 selectedIndex ,0 表示第一個 Destination ,1 就是第二個。
  • NavigationRail也設定了 onDestinationSelected 當使用者選擇 Destination 時的行為。目前單純只有 print()
  • MyHomePage > Scaffold > Row 中的第二個 Widget 就是 ExpandExpandRowColumn 裡面非常實用 - 它們讓你能夠設計出某些子元件只佔用它們所需的空間(在這個例子中是 SafeArea),而其他小部件則應盡可能佔用剩餘的空間(在這個例子中是 Expanded)。(有點類似 CSS flex:1 的概念)。

狀態與無狀態 Widget

到目前為止,MyAppState 包含了全部需要的狀態。這也是為什麼其他 Widget 使用無狀態的原因。它們沒有包含任何需要變更的狀態。這些狀態不能改變自己,它們必須通過 MyAppState

後續我們需要改變這個狀況。因為我們需要紀錄 selectedIndex 同時也需要在 onDestinationSelected 執行的時候使用它的 value

當然我們可以將 selectedIndex 也放在 MyAppState ,但可以見得 app 的狀態會急速膨脹。

一些只和該 Widget 相關的狀態應該就自己包起來就好。

選取 MyHomePage > Cmd + . 執行 Convert to StatefulWidget 。現在會產生 2 個類別,原本的類別繼承 StatefulWidget_MyHomePageState。這個類別繼承了 State,因此可以管理自己的狀態。也請注意,舊的無狀態組件 StatelessWidgetbuild 方法已經移至_MyHomePageState。它被完整地移過來 — build方法內的內容沒有改變。

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
      case 1:
        page = Placeholder();
      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,
          ))
        ],
      ),
    );
  }
}

自適應

Flutter 支援一些 Widget 協助開發者實現自適應的效果。例如 WrapRowColumn 類似,但是一旦空間不足會自動換行。FittedBox 可以根據設定自動調整子元素。預設 NavigationRail 即便在空間足夠的情況下並不會自動顯示標籤文字。

假設我們希望在 MyHomePage 寬 600px 以上的時候顯示文字。在這個情況下可以使用 LayoutBuilder

  1. _MyHomePageStatebuilder 將滑鼠移至 Scaffold
  2. 執行重構功能
  3. 選擇 Wrap with Builder
  4. Builder 修改為 LayoutBuilder
  5. (context) 改成 (context, constraints)

LayoutBuilderbuilder 函式會在每次 constraints 發生變更的時候執行。

每當用戶調整視窗大小,旋轉行動裝置,Widget 尺寸發生變更的時候, constraints 就會取得最新的資料

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('首頁'),
                  ),
                  NavigationRailDestination(
                    icon: Icon(Icons.favorite),
                    label: Text('我的最愛'),
                  ),
                ],
                selectedIndex: selectedIndex,
                onDestinationSelected: (value) {
                  setState(() {
                    selectedIndex = value;
                  });
                },
              ),
            ),
            Expanded(
              child: Container(
                color: Theme.of(context).colorScheme.primaryContainer,
                child: page,
              ),
            ),
          ],
        ),
      );
    });
  }
}

Placeholder 這個組件可以協助我們先暫時取代一下還未開發的組件。接著我們要實作 FavoritesPage 來顯示我的最愛清單。

在組織清單的 UI 時可以使用 for

var messages = ['哈囉', 'Hello', '안녕하세요'];
return Column(
	children: [
    for (var msg in messages)
    	Text(msg),
  ]
);

最後補上完整程式碼:

import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(const 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.deepPurple),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

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

  // 加入方法
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }

  var favorites = <WordPair>[];
  void toggleFavorite() {
    if (favorites.contains(current)) {
      favorites.remove(current);
    } else {
      favorites.add(current);
    }
    notifyListeners();
  }
}

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;

    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
      case 1:
        page = FavoritesPage();
      default:
        throw UnimplementedError("該 Index 沒有組件");
    }

    return LayoutBuilder(builder: (context, constraints) {
      return Scaffold(
        body: Row(
          children: [
            SafeArea(
                child: NavigationRail(
                    extended: constraints.maxWidth >= 600,
                    destinations: [
                      NavigationRailDestination(
                        icon: Icon(Icons.home),
                        label: Text('首頁'),
                      ),
                      NavigationRailDestination(
                        icon: Icon(Icons.favorite),
                        label: Text('我的最愛'),
                      ),
                    ],
                    selectedIndex: selectedIndex, // <- 使用變數
                    onDestinationSelected: (value) {
                      setState(() {
                        selectedIndex = value;
                      });
                    })),
            Expanded(
                child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: page,
            ))
          ],
        ),
      );
    });
  }
}

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('讚'),
              ),
              SizedBox(width: 10),
              ElevatedButton.icon(
                onPressed: () {
                  appState.getNext();
                },
                icon: Icon(Icons.refresh),
                label: Text('下一個'),
              )
            ],
          ),
        ],
      ),
    );
  }
}

class FavoritesPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();

    if (appState.favorites.isEmpty) {
      return Center(
        child: Text('尚無我的最愛'),
      );
    }

    return ListView(
      children: [
        Padding(
          padding: const EdgeInsets.all(20),
          child: Text('共 '
              '${appState.favorites.length} 我的最愛:'),
        ),
        for (var pair in appState.favorites)
          ListTile(
            leading: Icon(Icons.favorite),
            title: Text(pair.asLowerCase),
          ),
      ],
    );
  }
}

class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @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.0),
        child: Text(
          pair.asLowerCase,
          style: style,
          semanticsLabel: "${pair.first} ${pair.second}",
        ),
      ),
    );
  }
}

總結

通過這個範例我們快速的概覽了如何開發一個 Flutter 應用程式。認識了基本的如何建構介面,提供操作和狀態。

當然,雖然此時我們一定是充滿各種問題,到處是一些不是那麼直覺的參數或設定方式,而這些問題的源頭來自於我們不了解 Dart,畢竟和 HTML、JavaScript 比起來,個人是沒有任何關於 Dart 的知識,而 JSX 類似於 HTML 的組織風格和這種 OOP 風格可以說是相去甚遠。總體來說我們會需要花費大量時間熟悉語法和組件 Widget,這個過程類似於我們熟悉 HTML 標籤一樣,其實並沒有那麼可怕。後續,我們將繼續熟悉一些基本和 Flutter 會使用到的 Dart 語法,進而協助我們在範例和官方 API 文件可以讀懂它們。

重點筆記

  • flutter create 建立專案常用參數

    • -s 參數可以從官方文件中直接建立範例專案
    • --org 即反過來的 domain,為 iOS 的 bundle identifier,Android 的 package name
    • -i -a 分別指定 iOS 或 Android 的原生語言
    • --project-name 變更專案名稱,必須為合法的 Dart package 名稱(全小寫 + 底線)
    $ flutter create -n my_app -t app --org com.example --android-language kotlin --ios-language swift
    
  • 基本的專案結構介紹

    • android / ios 這兩個目錄是針對平台的程式碼。如果你已經知道 Android 的專案結構,那麼裡面的檔案應該不陌生,Xcode 的 iOS 專案也一樣,還有 linuxmacoswebwindows, 目錄也是各自針對系統平台。
    • hello_world.iml 這是 IntelliJ 專案檔裡面包含 JAVA_MODULE 資訊,.idea 目錄則是包含 IntelliJ 設定,.vscode 目錄為 Visual Studio Code 設定
    • lib 目錄,這是我們主要 Flutter 應用程式的目錄也是你大部分時間處理程式的地方。初始建立的專案至少會有一個 main.dart
    • pubspec.yamlpubspec.lock 一個是定義 Dart Package 的地方,包含專案設定,版號,相依套件,圖片等資源檔
    • test 目錄包含測試相關檔案
    • analysis_options.yaml 為解析工具設定,可以設定程式風格規範保持專案程式碼的一致性
  • 組件就是 Flutter 的核心,Flutter 使用組件來渲染使用介面

  • 組件使用 class 定義描述,在 build 方法中回傳 Widget,最終會得到一個組件樹狀結構 Widget Tree 。一旦介面有任何變動,build 方法會再被呼叫重新渲染

  • 組件樹狀結構邏輯上表示整個 UI ,會被用來渲染和互動操作。渲染之後會有對應的 Element 樹狀結構,就跟 React 元件和 Element 關係類似。

  • Flutter 支援 debug, release, profile 模式

  • 渲染引擎為 Skia 和 Impller,前者因為卡頓的問題正汰換為 Impller

  • pubspec.yaml 在 Flutter 中是用來定義 Dart 套件和一些專案設定的。如果你編輯這個檔案,IDE 如 VS Code 會自動執行 flutter pub get 安裝套件,這是 Flutter 安裝套件的指令,除了 IDE 自動執行,也可以手動執行

    # 完整安裝
    $ flutter pub get
    
    # 安裝套件
    $ flutter pub add english_words
    
  • Emulator 和 Simulator 差異:Emulator 模擬 Android 裝置的軟硬體,而 Simulator 只模擬軟體然後使用執行機器的硬體,建議使用 Simulator 測試。

    $ flutter run
    
    # 開啟模擬器
    $ flutter devices
    
    $ flutter emulators
    
    $ flutter run -d [DEVICE_ID]
    

上一篇
Day2 Flutter 跨平台解決方案與環境安裝
下一篇
Day 4 Dart 基礎 (上)
系列文
Flutter 開發實戰 - 30 天逃離新手村27
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言