Widgets 介紹

Flutter 從 React 中吸取靈感,透過現代化框架創建出精美的元件。它的核心思想是用 widget 來建構你的 UI 介面。 Widget 描述了在當前的配置和狀態下檢視所應該呈現的樣子。當 widget 的狀態改變時,它會重新建構其描述(展示的 UI),框架則會對比前後變化的不同,以確定底層渲染樹從一個狀態轉換到下一個狀態所需的最小更改。

Hello world

建立一個最小的 Flutter 應用簡單到僅需呼叫 runApp() 方法並傳入一個 widget 即可:

import 'package:flutter/material.dart';

void main() {
  runApp(
    const Center(
      child: Text(
        'Hello, world!',
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

runApp() 函式會持有傳入的 Widget,並且使它成為 widget 樹中的根節點。在這個例子中,Widget 樹有兩個 widgets, Center widget 及其子 widget —— Text widget。框架會強制讓根 widget 鋪滿整個螢幕,也就是說“Hello World”會在螢幕上居中顯示。在這個例子我們需要指定文字的方向,當使用 MaterialApp widget 時,你就無需考慮這一點,之後我們會進一步的描述。

在寫應用的過程中,取決於是否需要管理狀態,你通常會建立一個新的元件繼承 StatelessWidgetStatefulWidget。 Widget 的主要工作是實現 build() 方法,該方法根據其它較低級別的 widget 來描述這個 widget。框架會逐一建構這些 widget,直到最底層的描述 widget 幾何形狀的 RenderObject

基礎 widgets

Flutter 自帶了一套強大的基礎 widgets,下面列出了一些常用的:

Text
Text widget 可以用來在應用內建立帶樣式的文字。

Row, Column
這兩個 flex widgets 可以讓你在水平 (Row) 和垂直(Column) 方向建立靈活的佈局。它是基於 web 的 flexbox 佈局模型設計的。

Stack
Stack widget 不是線性(水平或垂直)定位的,而是按照繪製順序將 widget 堆疊在一起。你可以用 Positioned widget 作為Stack 的子 widget,以相對於 Stack 的上,右,下,左來定位它們。 Stack 是基於 Web 中的絕對位置佈局模型設計的。

Container
Container widget 可以用來建立一個可見的矩形元素。 Container 可以使用 BoxDecoration 來進行裝飾,如背景,邊框,或陰影等。 Container 還可以設定外邊距、內邊距和尺寸的約束條件等。另外,Container可以使用矩陣在三維空間進行轉換。

下面是一些簡單的 widget,它們結合了上面提到的 widget 和一些其他的 widget:

import 'package:flutter/material.dart';

class MyAppBar extends StatelessWidget {
  const MyAppBar({required this.title, super.key});

  // Fields in a Widget subclass are always marked "final".

  final Widget title;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56.0, // in logical pixels
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
      decoration: BoxDecoration(color: Colors.blue[500]),
      // Row is a horizontal, linear layout.
      child: Row(
        children: [
          const IconButton(
            icon: Icon(Icons.menu),
            tooltip: 'Navigation menu',
            onPressed: null, // null disables the button
          ),
          // Expanded expands its child
          // to fill the available space.
          Expanded(
            child: title,
          ),
          const IconButton(
            icon: Icon(Icons.search),
            tooltip: 'Search',
            onPressed: null,
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    // Material is a conceptual piece
    // of paper on which the UI appears.
    return Material(
      // Column is a vertical, linear layout.
      child: Column(
        children: [
          MyAppBar(
            title: Text(
              'Example title',
              style: Theme.of(context) //
                  .primaryTextTheme
                  .titleLarge,
            ),
          ),
          const Expanded(
            child: Center(
              child: Text('Hello, world!'),
            ),
          ),
        ],
      ),
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      title: 'My app', // used by the OS task switcher
      home: SafeArea(
        child: MyScaffold(),
      ),
    ),
  );
}

請確認在 pubspec.yaml 檔案中 flutter 部分有 uses-material-design: true 這條,它能讓你使用預置的 Material icons

name: my_app
flutter:
  uses-material-design: true

為了獲得(MaterialApp)主題的資料,許多 Material Design 的 widget 需要在 MaterialApp 中才能顯現正常。因此,請使用 MaterialApp 執行應用。

MyAppBar widget 建立了一個高 56 獨立畫素,左右內邊距 8 畫素的 Container。在容器內,MyAppBarRow 佈局來組織它的子元素。中間的子 widget(title widget),被標記為 Expanded,這意味著它會擴充以填充其它子 widget 未使用的可用空間。你可以定義多個Expanded 子 widget,並使用 flex 引數確定它們佔用可用空間的比例。

MyScaffold widget 將其子 widget 組織在垂直列中。在列的頂部,它放置一個 MyAppBar 例項,並把 Text widget 傳給它來作為應用的標題。把 widget 作為引數傳遞給其他 widget 是一個很強大的技術,它可以讓你以各種方式建立一些可重用的通用元件。最後,MyScaffold 使用 Expanded 來填充剩餘空間,其中包含一個居中的訊息。

有關更多資訊,請參閱 佈局

使用 Material 元件

Flutter 提供了許多 widget,可幫助你建構遵循 Material Design 的應用。 Material 應用以 MaterialApp widget 開始,它在你的應用的底層下建構了許多有用的 widget。這其中包括 Navigator,它管理由字串標識的 widget 棧,也稱為“routes”。 Navigator 可以讓你在應用的頁面中平滑的切換。使用 MaterialApp widget 不是必須的,但這是一個很好的做法。

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      title: 'Flutter Tutorial',
      home: TutorialHome(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    // Scaffold is a layout for
    // the major Material Components.
    return Scaffold(
      appBar: AppBar(
        leading: const IconButton(
          icon: Icon(Icons.menu),
          tooltip: 'Navigation menu',
          onPressed: null,
        ),
        title: const Text('Example title'),
        actions: const [
          IconButton(
            icon: Icon(Icons.search),
            tooltip: 'Search',
            onPressed: null,
          ),
        ],
      ),
      // body is the majority of the screen.
      body: const Center(
        child: Text('Hello, world!'),
      ),
      floatingActionButton: const FloatingActionButton(
        tooltip: 'Add', // used by assistive technologies
        onPressed: null,
        child: Icon(Icons.add),
      ),
    );
  }
}

現在我們已經從 MyAppBarMyScaffold 切換到了 material.dart 中的 AppBarScaffold widget,我們的應用更“Material”了一些。例如,標題欄有了陰影,標題文字會自動繼承正確的樣式,此外還添加了一個浮動操作按鈕。

注意,widget 作為引數傳遞給了另外的 widget。 Scaffold widget 將許多不同的 widget 作為命名引數,每個 widget 都放在了 Scofford 佈局中的合適位置。同樣的,AppBar widget 允許我們給 leadingtitle widget 的 actions 傳遞 widget。這種模式在整個框架會中重複出現,在設計自己的 widget 時可以考慮這種模式。

有關更多資訊,請參閱 Material 元件

處理手勢

大多數應用都需要透過系統來處理一些使用者互動。建構互動式應用程式的第一步是檢測輸入手勢,這裡透過建立一個簡單的按鈕來了解其工作原理:

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        print('MyButton was tapped!');
      },
      child: Container(
        height: 50.0,
        padding: const EdgeInsets.all(8.0),
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(5.0),
          color: Colors.lightGreen[500],
        ),
        child: const Center(
          child: Text('Engage'),
        ),
      ),
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: MyButton(),
        ),
      ),
    ),
  );
}

GestureDetector widget 沒有視覺化的展現,但它能識別使用者的手勢。當用戶點選 Container 時, GestureDetector 會呼叫其 onTap() 回呼(Callback),在這裡會向控制檯列印一條訊息。你可以使用 GestureDetector 檢測各種輸入的手勢,包括點選,拖動和縮放。

許多 widget 使用 GestureDetector 為其他 widget 提供可選的回呼(Callback)。例如,IconButtonElevatedButtonFloatingActionButton widget 都有 onPressed() 回呼(Callback),當用戶點選 widget 時就會觸發這些回呼(Callback)。

有關更多資訊,請參閱 Flutter 中的手勢

根據使用者輸入改變 widget

到目前為止,這個頁面僅使用了無狀態的 widget。無狀態 widget 接收的引數來自於它的父 widget,它們儲存在 final 成員變數中。當 widget 需要被 build() 時,就是用這些儲存的變數為建立的 widget 產生新的引數。

為了建構更復雜的體驗,例如,以更有趣的方式對使用者輸入做出反應—應用通常帶有一些狀態。 Flutter 使用 StatefulWidgets 來實現這一想法。 StatefulWidgets 是一種特殊的 widget,它會產生 State 物件,用於儲存狀態。看看這個基本的例子,它使用了前面提到的 ElevatedButton

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  // This class is the configuration for the state.
  // It holds the values (in this case nothing) provided
  // by the parent and used by the build  method of the
  // State. Fields in a Widget subclass are always marked
  // "final".

  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      // This call to setState tells the Flutter framework
      // that something has changed in this State, which
      // causes it to rerun the build method below so that
      // the display can reflect the updated values. If you
      // change _counter without calling setState(), then
      // the build method won't be called again, and so
      // nothing would appear to happen.
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    // This method is rerun every time setState is called,
    // for instance, as done by the _increment method above.
    // The Flutter framework has been optimized to make
    // rerunning build methods fast, so that you can just
    // rebuild anything that needs updating rather than
    // having to individually changes instances of widgets.
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

您可能想知道為什麼 StatefulWidget 和 State 是獨立的物件。在 Flutter 中,這兩種型別的物件具有不同的生命週期。 Widget 是臨時物件,用於構造應用當前狀態的展示。而 State 物件在呼叫 build() 之間是持久的,以此來儲存資訊。

上面的範例接受使用者輸入並直接在其 build() 方法中直接使用結果。在更復雜的應用中,widget 層次不同的部分可能負責不同的關注點;例如,一個 widget 可能呈現複雜的使用者介面,來收集像日期或位置這樣特定的資訊,而另一個 widget 可能使用該資訊來改變整體的展現。

在 Flutter 中,widget 透過回呼(Callback)得到狀態改變的通知,同時當前狀態通知給其他 widget 用於顯示。重新導向這一流程的共同父級是 State,下面稍微複雜的範例顯示了它在實踐中的工作原理:

import 'package:flutter/material.dart';

class CounterDisplay extends StatelessWidget {
  const CounterDisplay({required this.count, super.key});

  final int count;

  @override
  Widget build(BuildContext context) {
    return Text('Count: $count');
  }
}

class CounterIncrementor extends StatelessWidget {
  const CounterIncrementor({required this.onPressed, super.key});

  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: const Text('Increment'),
    );
  }
}

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

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      ++_counter;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        CounterIncrementor(onPressed: _increment),
        const SizedBox(width: 16),
        CounterDisplay(count: _counter),
      ],
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

注意建立兩個新的無狀態 widget 的方式,它清楚地分離了 顯示 計數器(CounterDisplay)和 改變 計數器(CounterIncrementor)。儘管最終結果與前面的範例相同,但是責任的分離將更大的複雜性封裝在各個 widget 中,保證了父級的簡單性。

有關更多資訊,請參閱:

整合在一起

下面是一個更完整的範例,彙集了上面介紹的概念:假定一個購物應用顯示各種出售的產品,並在購物車中維護想購買的物品。首先定義一個用於展示的類,ShoppingListItem

import 'package:flutter/material.dart';

class Product {
  const Product({required this.name});

  final String name;
}

typedef CartChangedCallback = Function(Product product, bool inCart);

class ShoppingListItem extends StatelessWidget {
  ShoppingListItem({
    required this.product,
    required this.inCart,
    required this.onCartChanged,
  }) : super(key: ObjectKey(product));

  final Product product;
  final bool inCart;
  final CartChangedCallback onCartChanged;

  Color _getColor(BuildContext context) {
    // The theme depends on the BuildContext because different
    // parts of the tree can have different themes.
    // The BuildContext indicates where the build is
    // taking place and therefore which theme to use.

    return inCart //
        ? Colors.black54
        : Theme.of(context).primaryColor;
  }

  TextStyle? _getTextStyle(BuildContext context) {
    if (!inCart) return null;

    return const TextStyle(
      color: Colors.black54,
      decoration: TextDecoration.lineThrough,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        onCartChanged(product, inCart);
      },
      leading: CircleAvatar(
        backgroundColor: _getColor(context),
        child: Text(product.name[0]),
      ),
      title: Text(product.name, style: _getTextStyle(context)),
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Center(
          child: ShoppingListItem(
            product: const Product(name: 'Chips'),
            inCart: true,
            onCartChanged: (product, inCart) {},
          ),
        ),
      ),
    ),
  );
}

ShoppingListItem widget 遵循無狀態 widget 的通用模式。它將建構函式中接受到的值儲存在 final 成員變數中,然後在 build() 函式中使用它們。例如,inCart 布林值使兩種樣式進行切換:一個使用當前主題的主要顏色,另一個使用灰色。

當用戶點選列表中的一項,widget 不會直接改變 inCart 的值,而是透過呼叫從父 widget 接收到的 onCartChanged 函式。這種方式可以在元件的生命週期中儲存狀態更長久,從而使狀態持久化。甚至,widget 傳給 runApp() 的狀態可以持久到整個應用的生命週期。

當父級接收到 onCartChanged 回呼(Callback)時,父級會更新其內部狀態,從而觸發父級重建並使用新的 inCart 值來建立新的 ShoppingListItem 例項。儘管父級在重建時會建立 ShoppingListItem 的新例項,但是由於框架會將新建構的 widget 與先前建構的 widget 進行比較,僅將差異應用於底層的 RenderObject,這種代價是很小的。

這裡有一個範例展示父元件是如何儲存可變狀態:

import 'package:flutter/material.dart';

class Product {
  const Product({required this.name});

  final String name;
}

typedef CartChangedCallback = Function(Product product, bool inCart);

class ShoppingListItem extends StatelessWidget {
  ShoppingListItem({
    required this.product,
    required this.inCart,
    required this.onCartChanged,
  }) : super(key: ObjectKey(product));

  final Product product;
  final bool inCart;
  final CartChangedCallback onCartChanged;

  Color _getColor(BuildContext context) {
    // The theme depends on the BuildContext because different
    // parts of the tree can have different themes.
    // The BuildContext indicates where the build is
    // taking place and therefore which theme to use.

    return inCart //
        ? Colors.black54
        : Theme.of(context).primaryColor;
  }

  TextStyle? _getTextStyle(BuildContext context) {
    if (!inCart) return null;

    return const TextStyle(
      color: Colors.black54,
      decoration: TextDecoration.lineThrough,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        onCartChanged(product, inCart);
      },
      leading: CircleAvatar(
        backgroundColor: _getColor(context),
        child: Text(product.name[0]),
      ),
      title: Text(
        product.name,
        style: _getTextStyle(context),
      ),
    );
  }
}

class ShoppingList extends StatefulWidget {
  const ShoppingList({required this.products, super.key});

  final List<Product> products;

  // The framework calls createState the first time
  // a widget appears at a given location in the tree.
  // If the parent rebuilds and uses the same type of
  // widget (with the same key), the framework re-uses
  // the State object instead of creating a new State object.

  @override
  State<ShoppingList> createState() => _ShoppingListState();
}

class _ShoppingListState extends State<ShoppingList> {
  final _shoppingCart = <Product>{};

  void _handleCartChanged(Product product, bool inCart) {
    setState(() {
      // When a user changes what's in the cart, you need
      // to change _shoppingCart inside a setState call to
      // trigger a rebuild.
      // The framework then calls build, below,
      // which updates the visual appearance of the app.

      if (!inCart) {
        _shoppingCart.add(product);
      } else {
        _shoppingCart.remove(product);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shopping List'),
      ),
      body: ListView(
        padding: const EdgeInsets.symmetric(vertical: 8.0),
        children: widget.products.map((product) {
          return ShoppingListItem(
            product: product,
            inCart: _shoppingCart.contains(product),
            onCartChanged: _handleCartChanged,
          );
        }).toList(),
      ),
    );
  }
}

void main() {
  runApp(const MaterialApp(
    title: 'Shopping App',
    home: ShoppingList(
      products: [
        Product(name: 'Eggs'),
        Product(name: 'Flour'),
        Product(name: 'Chocolate chips'),
      ],
    ),
  ));
}

ShoppingList 類繼承自 StatefulWidget,這意味著這個 widget 儲存著可變狀態。當 ShoppingList 首次插入到 widget 樹中時,框架呼叫 createState() 函式來建立 _ShoppingListState 的新例項,以與樹中的該位置相關聯。(注意,State 的子類別通常以下劃線開頭進行命名,表示它們的實現細節是私有的)當該 widget 的父 widget 重建時,父 widget 首先會建立一個 ShoppingList 的例項,但是框架會複用之前建立的 _ShoppingListState,而不會重新呼叫 createState

為了訪問當前 ShoppingList 的屬性, _ShoppingListState 可以使用它的 widget 屬性。當父元件重建一個新的 ShoppingList 時, _ShoppingListState 會使用新的 widget 值來建立。如果希望在 widget 屬性更改時收到通知,則可以重寫 didUpdateWidget() 函式,該函式將 oldWidget 作為引數傳遞,以便將 oldWidget 與當前 widget 進行比較。

當處理 onCartChanged 回呼(Callback)時,_ShoppingListState 透過增加或刪除 _shoppingCart 中的產品來改變其內部狀態。為了通知框架它改變了它的內部狀態,需要呼叫 setState(),將該 widget 標記為「dirty」(髒標記),並且計劃在下次應用需要更新螢幕時重新建構它。如果在修改 widget 的內部狀態後忘記呼叫 setState,框架將不知道這個 widget 是「dirty」(髒標記),並且可能不會呼叫 widget 的 build() 方法,這意味著使用者介面可能不會更新以展示新的狀態。透過以這種方式管理狀態,你不需要編寫用於建立和更新子 widget 的單獨程式碼。相反,你只需實現 build 函式,它可以處理這兩種情況。

響應 widget 的生命週期事件

在 StatefulWidget 上呼叫 createState() 之後,框架將新的狀態物件插入到樹中,然後在狀態物件上呼叫 initState()State 的子類別可以重寫 initState 來完成只需要發生一次的工作。例如,重寫 initState 來配置動畫或訂閱平台服務。實現 initState 需要呼叫父類別的 super.initState 方法來開始。

當不再需要狀態物件時,框架會呼叫狀態物件上的 dispose() 方法。可以重寫dispose 方法來清理狀態。例如,重寫 dispose 以取消計時器或取消訂閱平台服務。實現 dispose 時通常透過呼叫 super.dispose 來結束。

有關更多資訊,請參閱 State

Keys

使用 key 可以控制框架在 widget 重建時與哪些其他 widget 進行匹配。預設情況下,框架根據它們的 runtimeType 以及它們的顯示順序來匹配。使用 key 時,框架要求兩個 widget 具有相同的 keyruntimeType

Key 在建構相同型別 widget 的多個例項時很有用。例如,ShoppingList widget,它只建構剛剛好足夠的 ShoppingListItem 例項來填充其可見區域:

  • 如果沒有 key,當前建構中的第一個條目將始終與前一個建構中的第一個條目同步,在語義上,列表中的第一個條目如果滾動出螢幕,那麼它應該不會再在視窗中可見。

  • 透過給列表中的每個條目分配為“語義” key,無限列表可以更高效,因為框架將透過相匹配的語義 key 來同步條目,並因此具有相似(或相同)的可視外觀。此外,語義上同步條目意味著在有狀態子 widget 中,保留的狀態將附加到相同的語義條目上,而不是附加到相同數字位置上的條目。

有關更多資訊,請參閱 Key API。

全域 key

全域 key 可以用來標識唯一子 widget。全域 key 在整個 widget 結構中必須是全域唯一的,而不像本地 key 只需要在兄弟 widget 中唯一。由於它們是全域唯一的,因此可以使用全域 key 來檢索與 widget 關聯的狀態。

有關更多資訊,請參閱 GlobalKey API。