跳至主要內容

使用 Flutter 建置使用者介面

Flutter Widget 是使用現代架構建置的,該架構的靈感來自 React。核心概念是您使用 Widget 建置 UI。Widget 會描述在目前設定和狀態下,其檢視畫面應呈現的外觀。當 Widget 的狀態變更時,Widget 會重建其描述,而架構會將其與先前的描述進行差異比較,以判斷從一個狀態轉換到下一個狀態時,基礎轉譯樹中需要進行的最小變更。

Hello world

#

最簡化的 Flutter 應用程式只需使用一個小工具呼叫 runApp() 函數

import 'package:flutter/material.dart';

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

runApp() 函數會接收給定的 Widget,並將其設為小工具樹狀結構的根。在此範例中,小工具樹狀結構由兩個小工具組成,Center 小工具及其子小工具 Text 小工具。架構會強制根小工具覆蓋螢幕,這表示文字 "Hello, world" 最後會置於螢幕中央。在此情況下需要指定文字方向;當使用 MaterialApp 小工具時,系統會為您處理此問題,稍後會示範。

在編寫應用程式時,您通常會撰寫屬於 StatelessWidgetStatefulWidget 子類別的新小工具,具體取決於您的小工具是否管理任何狀態。小工具的主要工作是實作 build() 函數,此函數會以其他較低層級的小工具來描述小工具。架構會依序建構這些小工具,直到此程序以代表底層 RenderObject 的小工具結束,RenderObject 會計算和描述小工具的幾何形狀。

基本 Widget

#

Flutter 隨附一套強大的基本小工具,以下是常用的:

文字
Text 小工具可讓您在應用程式中建立一段具樣式的文字。
RowColumn
這些彈性小工具可讓您在水平 (Row) 和垂直 (Column) 方向建立彈性的版面配置。這些物件的設計基於網頁的 flexbox 版面配置模型。
Stack
Stack 小工具不會以線性方式定向 (水平或垂直),而是可讓您依繪製順序將小工具彼此疊放。然後,您可以在 Stack 的子物件上使用 Positioned 小工具,以將它們相對於堆疊的頂部、右側、底部或左側邊緣定位。堆疊基於網頁的絕對定位版面配置模型。
Container
Container 小工具可讓您建立矩形視覺元素。容器可以使用 BoxDecoration (例如背景、邊框或陰影) 來裝飾。Container 也可以套用邊界、內距和約束來設定其大小。此外,Container 可以使用矩陣在三維空間中轉換。

以下是一些結合這些小工具和其他小工具的簡單小工具

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, // in logical pixels
      padding: const EdgeInsets.symmetric(horizontal: 8),
      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 圖示集。如果您使用 Materials 程式庫,通常最好包含此行。

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

許多 Material Design 小工具需要位於 MaterialApp 內才能正確顯示,以便繼承主題資料。因此,請使用 MaterialApp 執行應用程式。

MyAppBar 小工具會建立一個高度為 56 個與裝置無關像素的 Container,其內部左側和右側都有 8 像素的內距。在容器內部,MyAppBar 會使用 Row 版面配置來組織其子物件。中間的子物件 title 小工具會標記為 Expanded,這表示它會展開以填滿其他子物件尚未使用的任何剩餘可用空間。您可以有多個 Expanded 子物件,並使用 Expandedflex 引數來決定它們消耗可用空間的比例。

MyScaffold 小工具會將其子物件組織成垂直欄。在欄的頂端,它會放置 MyAppBar 的執行個體,並將 Text 小工具傳遞至應用程式列,以作為其標題。將小工具作為引數傳遞至其他小工具是一種強大的技術,可讓您建立可在各種方式中重複使用的通用小工具。最後,MyScaffold 會使用 Expanded 以剩餘空間填滿其主體,其中包含置中的訊息。

如需更多資訊,請查看版面配置

使用 Material 元件

#

Flutter 提供許多可協助您建構遵循 Material Design 的應用程式的小工具。Material 應用程式以 MaterialApp 小工具開始,此小工具會在您的應用程式的根目錄建構許多實用的小工具,包括一個 Navigator,其會管理一堆以字串識別的小工具 (也稱為「路由」)。Navigator 可讓您在應用程式的畫面之間平穩轉換。使用 MaterialApp 小工具完全是選用的,但這是一個好習慣。

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 切換至 AppBarScaffold 小工具,並從 material.dart 切換,應用程式開始看起來更像 Material。例如,應用程式列具有陰影,且標題文字會自動繼承正確的樣式。也會新增浮動動作按鈕。

請注意,小工具會以引數形式傳遞至其他小工具。Scaffold 小工具會以具名引數的形式採用許多不同的小工具,每個小工具都會放置在 Scaffold 版面配置中的適當位置。同樣地,AppBar 小工具可讓您傳入小工具以作為 leading 小工具,以及 title 小工具的 actions。此模式會重複出現在整個架構中,您在設計自己的小工具時可能會考慮到這一點。

如需更多資訊,請查看Material Components 小工具

處理手勢

#

大多數應用程式都包含某種形式的使用者與系統互動。建構互動式應用程式的第一步是偵測輸入手勢。請建立一個簡單的按鈕,以瞭解其運作方式

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,
        padding: const EdgeInsets.all(8),
        margin: const EdgeInsets.symmetric(horizontal: 8),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(5),
          color: Colors.lightGreen[500],
        ),
        child: const Center(
          child: Text('Engage'),
        ),
      ),
    );
  }
}

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

GestureDetector 小工具沒有視覺呈現,而是會偵測使用者做出的手勢。當使用者點選 Container 時,GestureDetector 會呼叫其 onTap() 回呼,在此範例中會將訊息列印到主控台。您可以使用 GestureDetector 來偵測各種輸入手勢,包括點選、拖曳和縮放。

許多小工具會使用 GestureDetector 來為其他小工具提供選用回呼。例如,IconButtonElevatedButtonFloatingActionButton 小工具具有 onPressed() 回呼,當使用者點選小工具時會觸發這些回呼。

如需更多資訊,請查看Flutter 中的手勢

變更 Widget 以回應輸入

#

到目前為止,此頁面僅使用無狀態小工具。無狀態小工具會從其父小工具接收引數,並將其儲存在 final 成員變數中。當要求小工具執行 build() 時,它會使用這些儲存的值來為其建立的小工具衍生新引數。

為了建構更複雜的體驗 (例如,以更有趣的方式回應使用者輸入),應用程式通常會攜帶一些狀態。Flutter 使用 StatefulWidgets 來擷取此概念。StatefulWidgets 是特殊的小工具,它們知道如何產生 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(),
        ),
      ),
    ),
  );
}

您可能想知道為什麼 StatefulWidgetState 是個別的物件。在 Flutter 中,這兩種物件類型具有不同的生命週期。Widgets 是暫時性物件,用於建構應用程式在目前狀態下的呈現方式。另一方面,State 物件會在呼叫 build() 之間持續存在,使其能夠記住資訊。

上述範例會接受使用者輸入,並直接在其 build() 方法中使用結果。在更複雜的應用程式中,小工具階層的不同部分可能負責不同的考量;例如,一個小工具可能會呈現複雜的使用者介面,目的是收集特定資訊 (例如日期或位置),而另一個小工具可能會使用該資訊來變更整體呈現方式。

在 Flutter 中,變更通知會透過回呼「向上」傳遞小工具階層,而目前的狀態會「向下」傳遞至執行呈現的無狀態小工具。重新導向此流程的常見父項是 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 可以封裝更複雜的功能,同時保持父 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 會呼叫它從父 widget 接收到的 onCartChanged 函式。這種模式讓您可以將狀態儲存在 widget 階層的較高層級,這會導致狀態持續更長的時間。在極端情況下,儲存在傳遞給 runApp() 的 widget 上的狀態會在應用程式的整個生命週期中持續存在。

當父 widget 收到 onCartChanged 回呼時,父 widget 會更新其內部狀態,這會觸發父 widget 重建並建立具有新 inCart 值的新 ShoppingListItem 實例。雖然父 widget 在重建時會建立 ShoppingListItem 的新實例,但該操作成本很低,因為框架會將新建立的 widget 與先前建立的 widget 進行比較,並且僅將差異應用於底層的 RenderObject

以下是一個範例父 widget,它儲存可變狀態

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),
        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 屬性。如果父 widget 重建並建立新的 ShoppingList,則 _ShoppingListState 會使用新的 widget 值重建。如果您希望在 widget 屬性變更時收到通知,請覆寫 didUpdateWidget() 函式,該函式會傳遞 oldWidget,讓您可以將舊 widget 與目前的 widget 進行比較。

在處理 onCartChanged 回呼時,_ShoppingListState 會透過從 _shoppingCart 新增或移除產品來修改其內部狀態。為了向框架發出訊號,表明其內部狀態已變更,它會將這些呼叫包裝在 setState() 呼叫中。呼叫 setState 會將此 widget 標示為髒,並排定時間在您的應用程式下次需要更新螢幕時重建它。如果您在修改 widget 的內部狀態時忘記呼叫 setState,框架將不知道您的 widget 是髒的,並且可能不會呼叫 widget 的 build() 函式,這表示使用者介面可能不會更新以反映變更的狀態。透過以這種方式管理狀態,您不需要編寫單獨的程式碼來建立和更新子 widget。相反地,您只需實作 build 函式,它會處理這兩種情況。

回應 Widget 生命周期事件

#

在對 StatefulWidget 呼叫 createState() 之後,框架會將新的狀態物件插入樹狀結構中,然後在狀態物件上呼叫 initState()State 的子類別可以覆寫 initState 以執行只需執行一次的工作。例如,覆寫 initState 以設定動畫或訂閱平台服務。initState 的實作必須從呼叫 super.initState 開始。

當不再需要狀態物件時,框架會在狀態物件上呼叫 dispose()。覆寫 dispose 函式以執行清除工作。例如,覆寫 dispose 以取消計時器或取消訂閱平台服務。dispose 的實作通常以呼叫 super.dispose 結束。

更多資訊,請查看 State

Key

#

使用鍵來控制框架在 widget 重建時將哪些 widget 與其他 widget 進行比對。預設情況下,框架會根據它們的 runtimeType 和它們出現的順序,比對目前和先前版本中的 widget。使用鍵,框架要求兩個 widget 除了具有相同的 runtimeType 外,還必須具有相同的 key

鍵在建立同一類型 widget 的多個實例的 widget 中最有用。例如,ShoppingList widget,它只建立足夠多的 ShoppingListItem 實例來填滿其可見區域

  • 如果沒有鍵,目前版本中的第一個項目總是會與先前版本中的第一個項目同步,即使在語義上,列表中的第一個項目只是滾動出螢幕,並且在可視區域中不再可見。

  • 透過為列表中的每個項目指派一個「語義」鍵,無限列表可以更有效率,因為框架會將具有相符語義鍵的項目同步,因此具有相似(或相同)的視覺外觀。此外,以語義方式同步項目意味著保留在狀態子 widget 中的狀態會保持附加到相同的語義項目,而不是在可視區域中相同數字位置的項目。

更多資訊,請查看 Key API。

全域 Key

#

使用全域鍵來唯一識別子 widget。全域鍵在整個 widget 階層中必須是全域唯一的,與只需要在兄弟姐妹之間唯一性的本地鍵不同。由於它們是全域唯一的,因此可以使用全域鍵來擷取與 widget 相關聯的狀態。

更多資訊,請查看 GlobalKey API。