使用 Flutter 建立使用者介面
Flutter 元件 (Widgets) 是利用現代化框架所構建,其靈感來自 React。核心理念是你可以利用元件 (Widgets) 來組建你的 UI。元件 (Widgets) 會根據其目前的設定與狀態,描述它們應該呈現的視圖樣貌。當元件 (Widget) 的狀態發生變化時,該元件會重新建立其描述,框架則會將新的描述與先前的描述進行差異比較,以決定底層渲染樹(render tree)中所需的最小變更,以便從一個狀態平滑過渡到下一個狀態。
Hello world
#最簡單的 Flutter 應用程式只需呼叫 runApp() 函式並傳入一個元件 (Widget):
import 'package:flutter/material.dart';
void main() {
runApp(
const Center(
child: Text(
'Hello, world!',
textDirection: TextDirection.ltr,
style: TextStyle(color: Colors.blue),
),
),
);
}
runApp() 函式會接收給定的 Widget,並將其設為元件樹(widget tree)的根節點。在此範例中,元件樹包含兩個元件,分別是 Center 元件以及其子元件 Text。框架會強制根元件覆蓋整個螢幕,因此文字 "Hello, world" 最終會置中顯示在螢幕上。在這個例子中,必須指定文字方向;當你使用 MaterialApp 元件時,這部分會自動處理,稍後會有相關示範。
在撰寫應用程式時,你通常會建立新的元件,這些元件會繼承自 StatelessWidget 或 StatefulWidget,取決於你的元件是否需要管理狀態。元件的主要工作是實作 build() 函式,該函式會以其他較低階的元件來描述此元件。框架會依序建立這些元件,直到最底層的元件,這些元件代表底層的 RenderObject,負責計算並描述元件的幾何形狀。
基本元件(Basic widgets)
#Flutter 提供了一套強大的基本元件(Basic widgets),其中以下幾個是最常用的:
TextText元件可讓你在應用程式中建立一段具備樣式的文字。Row,Column- 這些彈性元件(flex widgets)可讓你在水平方向(
Row)與垂直方向(Column)建立彈性的版面配置。這些物件的設計是基於網頁的 flexbox 版面配置模型。 StackStack元件不是線性排列(不論是水平還是垂直),而是允許你依繪製順序將元件堆疊在彼此之上。你可以在Stack的子元件上使用Positioned元件,將它們相對於堆疊的上、右、下或左邊緣定位。Stack 以網頁的絕對定位(absolute positioning)版面配置模型為基礎。ContainerContainer元件可讓你建立一個矩形的視覺元素。Container 可以使用BoxDecoration進行裝飾,例如背景、邊框或陰影。Container也可以設定外距(margin)、內距(padding)以及尺寸限制(constraints)。此外,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 icons(Material 圖示)。如果你正在使用Materials 函式庫,通常建議加入這一行。
name: my_app
flutter:
uses-material-design: true許多 Material Design 元件需要放在 MaterialApp 內部,才能正確顯示並繼承主題資料。因此,請使用 MaterialApp 來執行應用程式。
MyAppBar 元件會建立一個 Container,其高度為 56 個裝置獨立像素,左右兩側各有 8 像素的內邊距。在這個容器內,MyAppBar 使用 Row 版面配置來組織其子元件。中間的子元件,也就是 title 元件,被標記為 Expanded,這表示它會擴展以填滿尚未被其他子元件佔用的剩餘空間。你可以有多個 Expanded 子元件,並可透過 flex 的 Expanded 參數來決定它們分配剩餘空間的比例。
MyScaffold 元件會將其子元件以垂直欄(column)排列。在欄的最上方會放置一個 MyAppBar 實例,並將 Text 元件作為標題傳遞給 app bar。將元件作為參數傳遞給其他元件是一個強大的技巧,可以讓你建立通用且能以多種方式重複使用的元件。最後,MyScaffold 使用 Expanded 來用其主體填滿剩餘空間,而主體內容是一則置中的訊息。
如需更多資訊,請參閱 Layouts。
使用 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),
),
);
}
}
現在,程式碼已經從 MyAppBar 和 MyScaffold 切換為 AppBar 和 Scaffold 元件(Widgets),以及從 material.dart,讓應用程式開始更有 Material 的風格。 例如,應用程式列(app bar)現在有陰影,標題文字也會自動繼承正確的樣式。此外,也新增了一個浮動操作按鈕(floating action button)。
請注意,元件(Widgets)可以作為參數傳遞給其他元件。 Scaffold 元件會以命名參數的方式接收多個不同的元件,每個元件都會被放置在 Scaffold 版面配置(layout)中的適當位置。類似地,AppBar 元件允許你傳入 leading 元件,以及 actions 到 title 元件的元件。 這種設計模式在整個框架中反覆出現,當你設計自己的元件時,也可以考慮採用這種方式。
如需更多資訊,請參閱 Material Components widgets。
處理手勢
#大多數應用程式都包含某種形式的使用者與系統互動。 建立互動式應用程式的第一步,就是偵測輸入手勢。你可以透過建立一個簡單的按鈕來了解其運作方式:
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() 回呼函式(callback),在這個例子中則是在主控台印出訊息。
你可以使用 GestureDetector 來偵測各種輸入手勢,包括點擊(tap)、拖曳(drag)和縮放(scale)等。
許多元件會使用 GestureDetector 來為其他元件提供可選的回呼函式。
舉例來說,IconButton、ElevatedButton 和 FloatingActionButton 這些元件都有 onPressed() 回呼函式,當使用者點擊該元件時就會被觸發。
如需更多資訊,請參閱 Gestures in Flutter。
根據輸入變更元件
#到目前為止,本頁僅使用了無狀態元件(stateless widgets)。
無狀態元件會從其父元件接收參數,並將這些參數儲存在 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())),
),
);
}
你可能會好奇,為什麼 StatefulWidget 和 State 是分開的物件。 在 Flutter 中,這兩種類型的物件有不同的生命週期。 Widgets 是暫時性的物件,用於構建應用程式當前狀態下的呈現方式。而 State 物件則會在每次呼叫 build() 時持續存在,讓它們能夠記住資訊。
上面的範例接受使用者輸入,並直接在其 build() 方法中使用結果。在更複雜的應用程式中,元件(Widget)階層中的不同部分可能負責不同的職責;例如,一個元件可能會呈現一個複雜的使用者介面,目的是收集特定資訊(如日期或地點),而另一個元件則可能使用這些資訊來改變整體的呈現方式。
在 Flutter 中,變更通知會透過回呼(callback)「向上」流動至元件階層,而當前狀態則會「向下」傳遞給負責呈現的無狀態元件(stateless widgets)。 負責重新導向這個流程的共同父元件是 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())),
),
);
}
請注意,這裡建立了兩個新的無狀態元件(stateless widgets), 將「顯示」計數器(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 元件遵循無狀態元件(stateless widgets)的常見設計模式。它會將建構函式收到的值,儲存在 final 成員變數中,並在其 build() 函式中使用這些值。
例如,inCart 布林值可在兩種視覺外觀之間切換:一種使用目前主題的主色,另一種則使用灰色。
當使用者點擊清單項目時,該元件不會直接修改其 inCart 值。相反地,元件會呼叫從父元件接收到的 onCartChanged 函式。
這種設計模式讓你可以將狀態儲存在元件階層(widget hierarchy)較高的地方,讓狀態能夠持續更長的時間。在極端情況下,儲存在傳遞給 runApp() 的元件上的狀態,會在整個應用程式生命週期內持續存在。
當父元件收到 onCartChanged 回呼時,父元件會更新其內部狀態,這會觸發父元件重新建構(rebuild),並以新的 ShoppingListItem 值建立新的 inCart 實例。
雖然父元件在重新建構時會建立新的 ShoppingListItem 實例,但這個操作是輕量的,因為框架會將新建構的元件與先前建構的元件進行比較,並僅將差異套用到底層的 RenderObject。
以下是一個儲存可變狀態(mutable state)的父元件範例:
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,
這表示此元件(Widget)會儲存可變狀態。
當 ShoppingList 元件首次插入到樹狀結構中時,框架會呼叫 createState() 函式,
以建立一個全新的 _ShoppingListState 實例,並將其與該樹中對應的位置關聯。
(請注意,State 的子類別通常會以底線開頭命名,
以表示它們是私有的實作細節。)
當此元件的父元件重建時,父元件會建立一個新的 ShoppingList 實例,
但框架會重複使用已經存在於樹中的 _ShoppingListState 實例,而不會再次呼叫 createState。
若要存取目前 ShoppingList 的屬性,
_ShoppingListState 可以使用其 widget 屬性。
如果父元件重建並建立了一個新的 ShoppingList,
_ShoppingListState 會以新的元件值重建。
如果你希望在 widget 屬性變更時收到通知,
可以覆寫 didUpdateWidget() 函式,該函式會傳入一個 oldWidget,
讓你可以比較舊的元件與目前的元件。
當處理 onCartChanged 回呼時,_ShoppingListState
會透過新增或移除 _shoppingCart 中的產品來改變其內部狀態。
為了向框架通知其內部狀態已變更,這些呼叫會包裹在 setState() 呼叫中。
呼叫 setState 會將此元件標記為「髒」狀態,並排程於下次應用程式需要更新螢幕時重建。
如果你在修改元件的內部狀態時忘記呼叫 setState,
框架將不會知道你的元件已經變髒,
也可能不會呼叫該元件的 build() 函式,
這代表使用者介面可能無法即時反映狀態的變更。
透過這種方式管理狀態,你不需要為建立和更新子元件撰寫額外程式碼,
只需實作 build 函式即可同時處理這兩種情境。
回應元件生命週期事件
#在 StatefulWidget 上呼叫 createState() 後,
框架會將新的狀態物件插入樹中,
然後在該狀態物件上呼叫 initState()。
State 的子類別可以覆寫 initState,以執行僅需執行一次的工作。
例如,可以覆寫 initState 來設定動畫(Animation)或訂閱平台服務。
initState 的實作必須一開始就呼叫 super.initState。
當狀態物件不再需要時,
框架會在該狀態物件上呼叫 dispose()。
可以覆寫 dispose 函式來進行清理工作。
例如,覆寫 dispose 以取消計時器或取消訂閱平台服務。
dispose 的實作通常會在結尾呼叫 super.dispose。
如需更多資訊,請參閱 State。
Keys(鍵值)
#使用 key 來控制框架在元件重建時,
如何將元件與其他元件配對。預設情況下,
框架會根據元件的 runtimeType 以及它們出現的順序,
來比對目前與前一次建構的元件。
有了 key,框架會要求兩個元件除了要有相同的 key,
還必須有相同的 runtimeType。
key 在建立多個相同型別元件的情境中特別有用。
例如,ShoppingList 元件只會建立足夠的 ShoppingListItem 實例來填滿可見區域:
沒有 key 時,目前建構中的第一個項目總是會與前一次建構的第一個項目同步,
即使在語意上,清單的第一個項目其實已經滑出螢幕、在檢視區域中不可見。若為清單中的每個項目分配一個「語意」key,
則無限清單可以更有效率,因為框架會同步具有相同語意 key 的項目,
因此它們會有相似(甚至相同)的視覺外觀。
此外,語意同步項目意味著,
狀態會保留在有狀態的子元件上,並繫結於相同語意的項目,
而不是僅僅依賴於檢視區域中的數值位置。
如需更多資訊,請參閱 Key API。
Global keys(全域 key)
#使用全域 key 來唯一識別子元件。
全域 key 必須在整個元件階層中全域唯一,
而本地 key 只需在同層兄弟元件之間唯一即可。
由於全域唯一,全域 key 可用於檢索與元件關聯的狀態。
如需更多資訊,請參閱 GlobalKey API。