Skip to main content

為你的 Flutter 應用程式加入互動性

如何實作可回應點擊的有狀態元件(stateful widget)。

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

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

版面配置教學應用程式

版面配置教學應用程式

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

你將建立的自訂元件

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

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

有狀態元件與無狀態元件

#

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

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

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

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

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

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

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() 是關鍵步驟,因為這會通知框架該元件的狀態已經改變,應該重新繪製。傳遞給 setState() 的函式參數會在這兩種 UI 狀態間切換:

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

步驟 4:將有狀態元件插入元件樹

#

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

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

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

有問題嗎?

#

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

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


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

狀態管理

#

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

那要如何決定該用哪一種方式呢? 以下原則可以協助你做判斷:

  • 如果該狀態屬於使用者資料, 例如核取方塊(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()),
      ),
    );
  }
}

父元件管理元件的狀態

#

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

在以下範例中,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),
          ),
        ),
      ),
    );
  }
}

混合搭配的方法

#

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

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 提供了各種按鈕和類似的互動式元件。 這些元件大多實作了 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 主講。