跳到主內容

使用 Flutter 構建使用者介面

Flutter 使用者介面開發簡介。

Flutter 元件基於一種受 React 啟發的現代框架構建。其核心理念是使用元件來構建 UI。元件根據其當前配置和狀態來描述檢視的外觀。當元件的狀態發生變化時,元件會重新構建其描述,框架會將新的描述與之前的描述進行對比,從而確定為了從當前狀態過渡到下一狀態,底層渲染樹中需要進行的最小化更改。

Hello world

#

最簡單的 Flutter 應用只需呼叫包含元件的 runApp() 函式即可。

import 'package:flutter/material.dart';

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

runApp() 函式接收給定的 Widget 並將其作為元件樹的根。在此示例中,元件樹由兩個元件組成:Center 元件及其子元件 Text。框架強制根元件覆蓋整個螢幕,這意味著“Hello, world”文字最終會居中顯示。在此例項中需要指定文字方向;當使用 MaterialApp 元件時,系統會自動處理,稍後會演示。

編寫應用時,通常會根據元件是否管理狀態,來編寫作為 StatelessWidget(無狀態元件)或 StatefulWidget(有狀態元件)子類的新元件。元件的主要工作是實現一個 build() 函式,該函式使用其他更底層的元件來描述自身。框架會依次構建這些元件,直到處理到代表底層 RenderObject(渲染物件)的元件,該物件負責計算並描述元件的幾何形狀。

基礎元件

#

Flutter 附帶了一套功能強大的基礎元件,以下是常用的元件:

文字

Text 元件允許你在應用中建立一段帶樣式的文字。

Row, Column

這些 Flex 元件允許你在水平(Row)和垂直(Column)方向上建立靈活的佈局。這些物件的設計基於 Web 的 Flexbox 佈局模型。

層疊佈局

Stack 元件不再是線性方向(水平或垂直),而是允許你按繪製順序將元件相互疊加放置。你可以對 Stack 的子元件使用 Positioned 元件,以便相對於堆疊的頂部、右側、底部或左側邊緣定位它們。Stack 基於 Web 的絕對定位佈局模型。

容器

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 圖示。如果你正在使用 Material 庫,通常建議包含此行。

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

許多 Material Design 元件為了正常顯示並繼承主題資料,需要放置在 MaterialApp 內部。因此,請使用 MaterialApp 執行應用。

MyAppBar 元件建立一個高度為 56 個邏輯畫素、左右內邊距為 8 畫素的 Container。在容器內部,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 元件

處理手勢

#

大多數應用都包含某種形式的使用者與系統互動。構建互動式應用的第一步是檢測輸入手勢。透過建立一個簡單的按鈕來看看它是如何工作的。

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 中的手勢

根據輸入更改元件

#

到目前為止,本頁面僅使用了無狀態元件。無狀態元件從父元件接收引數,並將其儲存在 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())),
    ),
  );
}

注意建立了兩個新的無狀態元件,明確區分了顯示計數器(CounterDisplay)和更改計數器(CounterIncrementor)的職責。儘管最終結果與前面的示例相同,但職責的分離允許將更大的複雜性封裝在各個元件中,同時保持父元件的簡潔性。

更多資訊,請檢視:

綜合示例

#

下面是一個將這些概念結合起來的更完整示例:一個假設的購物應用展示了各種待售產品,併為計劃購買的商品維護一個購物車。首先定義展示類 ShoppingListItem

import 'package:flutter/material.dart';

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

  final String name;
}

typedef CartChangedCallback = void 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 元件遵循無狀態元件的常用模式。它將建構函式中接收到的值儲存在 final 成員變數中,然後在 build() 函式中使用它們。例如,inCart 布林值在兩種視覺外觀之間切換:一種使用當前主題的原色,另一種使用灰色。

當用戶點選列表項時,該元件不會直接修改其 inCart 值。相反,它會呼叫從父元件接收到的 onCartChanged 函式。這種模式允許你將狀態儲存在元件層次結構的更高層級,從而使狀態持久存在更長的時間。在極端情況下,儲存在傳遞給 runApp() 的元件上的狀態在應用的整個生命週期內都會持續存在。

當父元件收到 onCartChanged 回撥時,父元件會更新其內部狀態,這將觸發父元件重新構建,並使用新的 inCart 值建立 ShoppingListItem 的新例項。雖然父元件在重建時會建立 ShoppingListItem 的新例項,但該操作成本較低,因為框架會將新構建的元件與之前構建的元件進行比較,並僅將差異應用到底層 RenderObject

這是一個儲存可變狀態的父元件示例。

import 'package:flutter/material.dart';

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

  final String name;
}

typedef CartChangedCallback = void 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,這意味著該元件儲存可變狀態。當 ShoppingList 元件首次插入樹中時,框架呼叫 createState() 函式來建立一個新的 _ShoppingListState 例項,並將其關聯到樹中的那個位置。(注意,State 的子類通常以帶下劃線的名稱命名,以表示它們是私有的實現細節。)當此元件的父元件重新構建時,父元件會建立 ShoppingList 的新例項,但框架會複用樹中已存在的 _ShoppingListState 例項,而不是再次呼叫 createState

要訪問當前 ShoppingList 的屬性,_ShoppingListState 可以使用其 widget 屬性。如果父元件重新構建並建立了一個新的 ShoppingList_ShoppingListState 會使用新的 widget 值重新構建。如果你希望在 widget 屬性更改時收到通知,請重寫 didUpdateWidget() 函式,該函式會傳入一個 oldWidget,讓你比較舊元件和當前元件。

在處理 onCartChanged 回撥時,_ShoppingListState 透過向 _shoppingCart 新增或移除產品來改變其內部狀態。為了向框架發出其內部狀態已更改的訊號,它將這些呼叫包裝在 setState() 呼叫中。呼叫 setState 會將此元件標記為髒(dirty),並計劃在應用下次需要更新螢幕時對其進行重建。如果你在修改元件內部狀態時忘記呼叫 setState,框架將不會知道你的元件是髒的,可能不會呼叫元件的 build() 函式,這意味著使用者介面可能不會更新以反映更改後的狀態。透過以這種方式管理狀態,你無需為建立和更新子元件編寫單獨的程式碼。相反,你只需實現 build 函式,它就能處理這兩種情況。

響應元件生命週期事件

#

在對 StatefulWidget 呼叫 createState() 後,框架會將新的狀態物件插入樹中,然後在該狀態物件上呼叫 initState()State 的子類可以重寫 initState 以執行只需發生一次的工作。例如,重寫 initState 以配置動畫或訂閱平臺服務。實現 initState 時,必須首先呼叫 super.initState

當不再需要狀態物件時,框架會在狀態物件上呼叫 dispose()。重寫 dispose 函式以執行清理工作。例如,重寫 dispose 以取消定時器或取消訂閱平臺服務。實現 dispose 時,通常在最後呼叫 super.dispose

更多資訊,請檢視 State

鍵 (Keys)

#

使用鍵 (Keys) 來控制組件重建時框架如何將元件與其它元件匹配。預設情況下,框架會根據 runtimeType 和它們出現的順序來匹配當前構建和之前構建中的元件。使用鍵時,框架要求兩個元件必須具有相同的 key 以及相同的 runtimeType

鍵在構建大量相同型別元件例項的場景中最有用。例如,ShoppingList 元件只構建足以填滿可見區域的 ShoppingListItem 例項:

  • 如果沒有鍵,當前構建中的第一個條目總是會與之前構建中的第一個條目同步,即使從語義上講,列表中的第一個條目已經滾動出了螢幕,不再在視口中可見。

  • 透過為列表中的每個條目分配一個“語義”鍵,無限列表的效率可以更高,因為框架會使用匹配的語義鍵同步條目,因此具有相似(或相同)的視覺外觀。此外,以語義方式同步條目意味著在有狀態子元件中保留的狀態會保持與同一個語義條目相關聯,而不是與視口中數值位置相同的條目相關聯。

更多資訊,請檢視 Key API。

全域性鍵 (Global keys)

#

使用全域性鍵 (Global keys) 來唯一標識子元件。全域性鍵必須在整個元件層次結構中全域性唯一,不像區域性鍵只需要在兄弟元件中唯一即可。由於它們是全域性唯一的,因此可以使用全域性鍵來檢索與元件關聯的狀態。

更多資訊,請檢視 GlobalKey API。