你要如何修改你的應用程式,讓它能回應使用者輸入? 在本教學中,你將為一個僅包含非互動元件的應用程式加入互動性。 具體來說,你會修改一個圖示,讓它可以被點擊, 並透過建立一個自訂的有狀態元件來管理兩個 無狀態元件。

版面配置教學 已經教你如何建立下圖的版面配置。

版面配置教學應用程式
版面配置教學應用程式

當應用程式首次啟動時,星星是實心紅色, 表示這個湖泊已經被收藏過。 星星旁邊的數字表示有 41 人收藏了這個湖泊。完成本教學後, 點擊星星會移除收藏狀態, 將實心星星換成空心,並減少計數。再次點擊則會重新收藏該湖泊, 顯示實心星星並增加計數。

你將建立的自訂元件

為了達成這個目標,你將建立一個自訂元件, 同時包含星星和數字計數,這兩者本身也是元件。 點擊星星會同時改變這兩個元件的狀態,因此應由同一個元件來管理兩者。

你可以直接進入 步驟 2:繼承 StatefulWidget 開始動手寫程式碼。 如果你想嘗試不同的狀態管理方式, 請跳到 狀態管理

有狀態元件與無狀態元件

#

一個元件(widget)可以是有狀態(stateful)或無狀態(stateless)。如果一個元件會改變——例如當使用者與它互動時——那麼它就是有狀態的。

無狀態元件 永遠不會改變。 IconIconButtonText 都是無狀態元件的例子。無狀態元件 會繼承自 StatelessWidget

有狀態元件 是動態的:例如, 它可以根據使用者互動觸發的事件或接收到資料時改變外觀。 CheckboxRadioSliderInkWellFormTextField 都是有狀態元件的例子。有狀態元件 會繼承自 StatefulWidget

元件的狀態會儲存在 State 物件中, 將元件的狀態與其外觀分離。 狀態包含那些可能改變的值,例如 滑桿目前的值,或勾選框是否被勾選。 當元件的狀態改變時, 狀態物件會呼叫 setState(), 通知框架重新繪製該元件。

建立有狀態元件

#

在本節中,你將建立一個自訂有狀態元件。 你會將兩個無狀態元件——實心紅色星星 以及旁邊的數字計數——替換成一個 自訂有狀態元件,該元件會管理一個包含兩個 子元件(children)的 row:IconButtonText

實作自訂有狀態元件需要建立兩個類別:

  • 一個繼承自 StatefulWidget 的類別,用來定義元件。
  • 一個繼承自 State 的類別,負責該 元件的狀態,並定義元件的 build() 方法。

本節將示範如何為 lakes 應用程式 建立一個名為 FavoriteWidget 的有狀態元件。 完成設定後,你的第一步是選擇如何為 FavoriteWidget 管理狀態。

步驟 0:準備工作

#

如果你已經完成 版面配置教學, 請直接跳到下一節。

  1. 請確認你已經完成環境設定
  2. 建立一個新的 Flutter 應用程式
  3. main.dart 替換 lib/main.dart 檔案。
  4. pubspec.yaml 替換 pubspec.yaml 檔案。
  5. 在你的專案中建立 images 目錄,並加入 lake.jpg

當你有一台已連接且啟用的裝置, 或你已啟動 iOS 模擬器 (part of the Flutter install) 或 Android 模擬器 (part of the Android Studio install),就可以開始了!

步驟 1:決定由哪個物件管理元件的狀態

#

一個元件的狀態可以用多種方式管理, 但在本例中,元件本身, FavoriteWidget,會自行管理自己的狀態。 在這個例子中,切換星星的動作是獨立的, 不會影響父元件或其他 UI,因此可以由元件內部自行處理狀態。

想進一步了解元件與狀態的分離, 以及狀態可能的管理方式,請參考 狀態管理

步驟 2:繼承 StatefulWidget

#

FavoriteWidget 類別會自行管理自己的狀態, 因此會覆寫 createState(),建立一個 State 物件。當框架需要建立該元件時,會呼叫 createState()。 在這個例子中,createState() 會回傳一個 _FavoriteWidgetState 的實例, 你將在下一步實作它。

dart
class FavoriteWidget extends StatefulWidget {
  const FavoriteWidget({super.key});

  @override
  State<FavoriteWidget> createState() => _FavoriteWidgetState();
}

步驟 3:繼承 State

#

_FavoriteWidgetState 類別用來儲存可變動的資料, 這些資料會隨著元件(Widget)的生命週期而改變。 當應用程式首次啟動時,UI 會顯示一顆實心紅色星星, 表示該湖泊已被標記為「最愛」狀態,並顯示 41 個讚。 這些值分別儲存在 _isFavorited_favoriteCount 欄位中:

dart
class _FavoriteWidgetState extends State<FavoriteWidget> {
  bool _isFavorited = true;
  int _favoriteCount = 41;

這個類別同時定義了一個 build() 方法, 該方法會建立一個包含紅色 IconButtonText 的橫列。 你會使用 IconButton (instead of Icon), 因為它具有 onPressed 屬性,可以定義處理點擊事件的回呼函式(_toggleFavorite)。 接下來你將定義這個回呼函式。

dart
class _FavoriteWidgetState extends State<FavoriteWidget> {
  // ···
  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(
          padding: const EdgeInsets.all(0),
          child: IconButton(
            padding: const EdgeInsets.all(0),
            alignment: Alignment.center,
            icon: (_isFavorited
                ? const Icon(Icons.star)
                : const Icon(Icons.star_border)),
            color: Colors.red[500],
            onPressed: _toggleFavorite,
          ),
        ),
        SizedBox(width: 18, child: SizedBox(child: Text('$_favoriteCount'))),
      ],
    );
  }

  // ···
}

_toggleFavorite() 方法會在按下 IconButton 時被呼叫,並進而呼叫 setState()。呼叫 setState() 是關鍵步驟,因為這會通知框架該元件(Widget)的狀態已經改變,應該重新繪製。傳遞給 setState() 的函式參數會在這兩種 UI 狀態間切換:

  • 顯示 star 圖示和數字 41
  • 顯示 star_border 圖示和數字 40
dart
void _toggleFavorite() {
  setState(() {
    if (_isFavorited) {
      _favoriteCount -= 1;
      _isFavorited = false;
    } else {
      _favoriteCount += 1;
      _isFavorited = true;
    }
  });
}

步驟 4:將有狀態元件(StatefulWidget)插入元件樹(widget tree)

#

在應用程式的 build() 方法中,將你自訂的有狀態元件加入元件樹。首先,找到建立 IconText 的程式碼,並將其刪除。 接著,在相同的位置建立這個有狀態元件:

dart
child: Row(
  children: [
    // ...
    Icon(
      Icons.star,
      color: Colors.red[500],
    ),
    const Text('41'),
    const FavoriteWidget(),
  ],
),

就是這樣!當你熱重載(hot reload)應用程式時, 星形圖示現在應該會對點擊做出反應。

有問題嗎?

#

如果你的程式碼無法執行,請在 IDE 中查看可能的錯誤。偵錯 Flutter 應用程式 可能會有所幫助。 如果你仍然找不到問題所在, 請將你的程式碼與 GitHub 上的互動 lakes 範例進行比對。

如果你還有其他問題,可以參考任一開發者 社群 頻道。


本頁接下來將介紹多種管理元件(Widget)狀態的方法, 並列出其他可用的互動元件(Widgets)。

狀態管理

#

誰來管理 stateful 元件的狀態?是元件本身? 還是父元件?兩者皆是?還是其他物件? 答案是……視情況而定。有好幾種有效的方式 可以讓你的元件具備互動性。你作為元件設計者, 可以根據你預期元件的使用方式來做決定。 以下是最常見的狀態管理方式:

  • 元件自行管理自己的狀態

  • 父元件管理元件的狀態

  • 混合搭配的方法 那要如何決定該用哪一種方式呢? 以下原則可以協助你做判斷:

  • 如果該狀態屬於使用者資料, 例如核取方塊(checkbox)的勾選或未勾選狀態, 或是滑桿(slider)的位置, 那麼這類狀態最好由父元件管理。

  • 如果該狀態屬於美觀(aesthetic), 例如動畫(Animation), 那麼這類狀態最好由元件本身管理。

如果不確定,建議先從父元件管理狀態開始。

我們將透過三個簡單範例來說明不同的狀態管理方式:TapboxA、TapboxB, 以及 TapboxC。這些範例的運作方式都很類似—— 每個範例都會建立一個容器(Container),當點擊時, 會在綠色與灰色方塊之間切換。_active 布林值決定了 顏色:啟用時為綠色,未啟用時為灰色。

Active state Inactive state

這些範例會使用 GestureDetector 來捕捉 Container 上的互動行為。

元件自行管理自己的狀態

#

有時候,讓元件在內部自行管理狀態是最合理的。例如, ListView 當內容超出 render box 時會自動捲動。 大多數使用 ListView 的開發者並不希望自己管理 ListView 的捲動行為,因此 ListView 會自行管理其捲動位置(scroll offset)。

_TapboxAState 類別:

  • 管理 TapboxA 的狀態。
  • 定義 _active 布林值,決定方塊目前的顏色。
  • 定義 _handleTap() 函式,當方塊被點擊時會更新 _active,並呼叫 setState() 函式來更新 UI。
  • 實作所有元件的互動行為。
dart
import 'package:flutter/material.dart';

// TapboxA manages its own state.

//------------------------- TapboxA ----------------------------------

class TapboxA extends StatefulWidget {
  const TapboxA({super.key});

  @override
  State<TapboxA> createState() => _TapboxAState();
}

class _TapboxAState extends State<TapboxA> {
  bool _active = false;

  void _handleTap() {
    setState(() {
      _active = !_active;
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: Container(
        width: 200,
        height: 200,
        decoration: BoxDecoration(
          color: _active ? Colors.lightGreen[700] : Colors.grey[600],
        ),
        child: Center(
          child: Text(
            _active ? 'Active' : 'Inactive',
            style: const TextStyle(fontSize: 32, color: Colors.white),
          ),
        ),
      ),
    );
  }
}

//------------------------- MyApp ----------------------------------

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(title: const Text('Flutter Demo')),
        body: const Center(child: TapboxA()),
      ),
    );
  }
}

父元件(Widget)管理元件的狀態

#

通常讓父元件來管理狀態並在需要時通知其子元件更新,是最合理的做法。例如,IconButton 讓你可以將圖示(icon)當作可點擊的按鈕來使用。IconButton 是一個無狀態元件(StatelessWidget),因為我們決定父元件需要知道按鈕是否被點擊,以便採取適當的行動。

在以下範例中,TapboxB 透過 callback(回呼)將其狀態回傳給父元件。由於 TapboxB 不自行管理任何狀態,因此它繼承自 StatelessWidget。

ParentWidgetState 類別:

  • 管理 TapboxB 的 _active 狀態。
  • 實作 _handleTapboxChanged(),這個方法會在方塊被點擊時呼叫。
  • 當狀態改變時,呼叫 setState() 來更新 UI。

TapboxB 類別:

  • 繼承自 StatelessWidget,因為所有狀態都由父元件處理。
  • 當偵測到點擊時,會通知父元件。
dart
import 'package:flutter/material.dart';

// ParentWidget manages the state for TapboxB.

//------------------------ ParentWidget --------------------------------

class ParentWidget extends StatefulWidget {
  const ParentWidget({super.key});

  @override
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _active = false;

  void _handleTapboxChanged(bool newValue) {
    setState(() {
      _active = newValue;
    });
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      child: TapboxB(active: _active, onChanged: _handleTapboxChanged),
    );
  }
}

//------------------------- TapboxB ----------------------------------

class TapboxB extends StatelessWidget {
  const TapboxB({super.key, this.active = false, required this.onChanged});

  final bool active;
  final ValueChanged<bool> onChanged;

  void _handleTap() {
    onChanged(!active);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: Container(
        width: 200,
        height: 200,
        decoration: BoxDecoration(
          color: active ? Colors.lightGreen[700] : Colors.grey[600],
        ),
        child: Center(
          child: Text(
            active ? 'Active' : 'Inactive',
            style: const TextStyle(fontSize: 32, color: Colors.white),
          ),
        ),
      ),
    );
  }
}

混合搭配的方法

#

對於某些元件(Widgets),採用混合搭配的方法最為合適。在這種情境下,狀態型元件(StatefulWidget)會管理部分狀態,而父元件則管理其他狀態的面向。

TapboxC範例中,當點擊按下時,方框周圍會出現深綠色的邊框。當點擊放開時,邊框消失,且方框的顏色會改變。TapboxC會將其_active狀態傳遞給父元件,但會在內部自行管理_highlight狀態。此範例中有兩個State物件,分別是_ParentWidgetState_TapboxCState

_ParentWidgetState物件:

  • 負責管理_active狀態。
  • 實作_handleTapboxChanged(),當方框被點擊時會呼叫此方法。
  • 當點擊事件發生且_active狀態改變時,會呼叫setState()以更新 UI。

_TapboxCState物件:

  • 負責管理_highlight狀態。
  • GestureDetector會監聽所有點擊事件。當使用者按下時,會加入高亮效果(以深綠色邊框實作)。當使用者放開時,則移除高亮效果。
  • 當點擊按下、放開或取消時,且_highlight狀態改變,會呼叫setState()來更新 UI。
  • 在點擊事件發生時,會將該狀態變化傳遞給父元件,讓父元件透過widget屬性採取適當的行動。
dart
import 'package:flutter/material.dart';

//---------------------------- ParentWidget ----------------------------

class ParentWidget extends StatefulWidget {
  const ParentWidget({super.key});

  @override
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _active = false;

  void _handleTapboxChanged(bool newValue) {
    setState(() {
      _active = newValue;
    });
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      child: TapboxC(active: _active, onChanged: _handleTapboxChanged),
    );
  }
}

//----------------------------- TapboxC ------------------------------

class TapboxC extends StatefulWidget {
  const TapboxC({super.key, this.active = false, required this.onChanged});

  final bool active;
  final ValueChanged<bool> onChanged;

  @override
  State<TapboxC> createState() => _TapboxCState();
}

class _TapboxCState extends State<TapboxC> {
  bool _highlight = false;

  void _handleTapDown(TapDownDetails details) {
    setState(() {
      _highlight = true;
    });
  }

  void _handleTapUp(TapUpDetails details) {
    setState(() {
      _highlight = false;
    });
  }

  void _handleTapCancel() {
    setState(() {
      _highlight = false;
    });
  }

  void _handleTap() {
    widget.onChanged(!widget.active);
  }

  @override
  Widget build(BuildContext context) {
    // This example adds a green border on tap down.
    // On tap up, the square changes to the opposite state.
    return GestureDetector(
      onTapDown: _handleTapDown, // Handle the tap events in the order that
      onTapUp: _handleTapUp, // they occur: down, up, tap, cancel
      onTap: _handleTap,
      onTapCancel: _handleTapCancel,
      child: Container(
        width: 200,
        height: 200,
        decoration: BoxDecoration(
          color: widget.active ? Colors.lightGreen[700] : Colors.grey[600],
          border: _highlight
              ? Border.all(color: Colors.teal[700]!, width: 10)
              : null,
        ),
        child: Center(
          child: Text(
            widget.active ? 'Active' : 'Inactive',
            style: const TextStyle(fontSize: 32, color: Colors.white),
          ),
        ),
      ),
    );
  }
}

另一種實作方式可能會將 highlight 狀態導出給父元件,同時將 active 狀態保留在內部, 但如果你請某人使用那個 tap box,他們可能會抱怨這樣做沒什麼道理。 開發者關心的是這個方塊是否為 active 狀態。 開發者大概不會在意 highlight 是如何被管理的,並且更希望 tap box 能自己處理這些細節。


其他互動式元件

#

Flutter 提供了各種按鈕和類似的互動式元件(Widgets)。 這些元件大多實作了 Material Design 指南, 該指南定義了一套具有明確 UI 風格的元件。

如果你有需要,也可以使用 GestureDetector 將互動性加入任何自訂元件中。 你可以在 狀態管理中找到 GestureDetector 的範例。想進一步了解 GestureDetector, 請參考 Flutter cookbook 中的 Handle taps 教學。

當你需要互動性時,最簡單的方式就是使用現成的元件。以下是部分清單:

標準元件

#

Material 元件(Material Components)

#

相關資源

#

以下資源有助於你在應用程式中加入互動性。

Gestures,Flutter cookbook 中的相關章節。

Handling gestures :如何建立按鈕並讓它回應輸入。

Gestures in Flutter :介紹 Flutter 手勢機制的說明文件。

Flutter API 文件 :所有 Flutter 函式庫的參考文件。

Wonderous app 執行中應用程式原始碼庫 :一個具有自訂設計和豐富互動體驗的 Flutter 展示應用程式。

Flutter 的分層設計 (video) :這支影片包含有關 state 與 無狀態元件(stateless widgets)的資訊。由 Google 工程師 Ian Hickson 主講。