這是一個系列,通過8篇文章幫助大家建立起 Flutter 的知識體系,建議大家好好閱讀並收藏起來。本篇文章我們先介紹 Flutter 裡一些常用的 UI 控制元件,然後藉助官網提供的兩個 demo 把所學的控制元件知識實際使用起來。
基本控制元件
Widget
在 Flutter 裡,UI 控制元件就是所謂的 Widget。通過組合不同的 Widget,來實現我們使用者互動介面。
Widget 分為兩種,一種是無狀態的,叫 StatelessWidget,它只能用來展示資訊,不能有動作(使用者互動);另一種是有狀態的,叫 StatefulWidget,這種 Widget 可以通過改變狀態使得 UI 發生變化,它可以包含使用者互動。
StatelessWidget 的使用非常簡單,我們只需要繼承 StatelessWidget,然後實現 build 方法就可以了:
class FooWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ...
}
}
複製程式碼
關於 build 方法的實現,在後面我們學習具體的控制元件時讀者就會了解的,這裡暫時忽略掉。
StatefulWidget 用起來麻煩一些,他還需要一個 State:
class BarWidget extends StatefulWidget {
@override
State createState() {
return _BarWidgetState();
}
}
class _BarWidgetState extends State<BarWidget> {
@override
Widget build(BuildContext context) {
// ...
}
}
複製程式碼
這裡看起來可能有些繞,BarWidget 依賴了 _BarWidgetState,而 _BarWidgetState 又繼承了 State< BarWidget>。如果讀者不太理解,其實也沒有什麼關係,這只是一個樣板程式碼,照著寫就行了。
從 BarWidget 的實現來看,好像跟前面使用 StatelessWidget 沒有什麼區別,都是在 build 方法裡面返回一個 Widget,只是 stateful widget 把這個方法挪到了 State 裡面。實際上,兩者的區別非常大。stateless widget 整個生命週期裡都不會改變,所以 build 方法只會執行一次。而 stateful widget 只要狀態改變,就會呼叫 build 方法重新建立 UI。
為了觸發 UI 的重建,我們可以呼叫 setState 方法。下面的程式碼讀者留意一下即可,在後面我們學習了相關的控制元件後再回過頭來看。
class BarWidget extends StatefulWidget {
@override
State createState() {
return _BarWidgetState();
}
}
class _BarWidgetState extends State<BarWidget> {
var i = 0;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Text('i = $i'),
RaisedButton(
onPressed: () {
setState(() {
++i;
});
},
child: Text('click'),
)
],
);
}
}
複製程式碼
下面我們開始學習一些具體的控制元件。
文字
為了展示文字,我們使用 Text:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text("Put your text here");
}
}
複製程式碼
這就是最簡單的文字了,它使用的是預設的樣式。很多情況下,我們都需要對文字的樣式進行修改,這個時候,可以使用 TextStyle:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text(
"Put your text here",
style: TextStyle(
color: Colors.blue,
fontSize: 16.0,
fontWeight: FontWeight.bold
),
);
}
}
複製程式碼
圖片
使用 Image,可以讓我們向使用者展示一張圖片。圖片的來源可以是網路、檔案、資源和記憶體,它們對應的建構函式分別是:
Image.asset(name);
Image.file(file);
Image.memory(bytes);
Image.network(src);
複製程式碼
比方說,為了展示一張來自網路的圖片,我們可以這樣:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Image.network(
"http://www.example.com/xxx.png",
width: 200.0,
height: 150.0,
);
}
}
複製程式碼
按鈕
Flutter 提供了兩個基本的按鈕控制元件:FlatButton 和 RaisedButton,它們的使用方法是類似的:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
var flatBtn = FlatButton(
onPressed: () => print('FlatButton pressed'),
child: Text('BUTTON'),
);
var raisedButton = RaisedButton(
onPressed: () => print('RaisedButton pressed'),
child: Text('BUTTON'),
);
return raisedButton;
}
}
複製程式碼
通過設定 onPressed 回撥,我們可以在按鈕被點選的時候得到回撥。child 引數用於設定按鈕的內容。雖然我們給 child 傳遞的是 Text,但這不是必需的,它可以接受任意的 Widget,比方說,Image。
注意,由於我們只是在按鈕點選的時候列印一個字串,這裡使用 StatelessWidget 是沒有問題的。但如果有其他 UI 動作(比如彈出一個 dialog,則必須使用 StatefulWidget)。
它們的區別只是樣式不同而已的:
FlatButton:
RaiseButton:
文字輸入框
Flutter 的文字輸入框叫 TextField。為了獲取使用者輸入的文字,我們需要給他設定一個 controller。通過這個 controller,就可以拿到文字框裡的內容:
class MessageForm extends StatefulWidget {
@override
State createState() {
return _MessageFormState();
}
}
class _MessageFormState extends State<MessageForm> {
var editController = TextEditingController();
@override
Widget build(BuildContext context) {
// Row、Expand 都是用於佈局的控制元件,這裡可以先忽略它們
return Row(
children: <Widget>[
Expanded(
child: TextField(
controller: editController,
),
),
RaisedButton(
child: Text("click"),
onPressed: () => print('text inputted: ${editController.text}'),
)
],
);
}
@override
void dispose() {
super.dispose();
// 手動呼叫 controller 的 dispose 方法以釋放資源
editController.dispose();
}
}
複製程式碼
顯示彈框
在前面的 TextField 例子中,我們只是把使用者的輸入通過 print 列印出來,這未免也太無趣了。在這一小節,我們要把它顯示在 dialog 裡。為了彈出一個 dialog,我們需要呼叫 showDialog 方法並傳遞一個 builder:
class _MessageFormState extends State<MessageForm> {
var editController = TextEditingController();
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: TextField(
controller: editController,
),
),
RaisedButton(
child: Text("click"),
onPressed: () {
showDialog(
// 第一個 context 是引數名,第二個 context 是 State 的成員變數
context: context,
builder: (_) {
return AlertDialog(
// dialog 的內容
content: Text(editController.text),
// actions 設定 dialog 的按鈕
actions: <Widget>[
FlatButton(
child: Text('OK'),
// 使用者點選按鈕後,關閉彈框
onPressed: () => Navigator.pop(context),
)
],
);
}
);
}
)
],
);
}
@override
void dispose() {
super.dispose();
editController.dispose();
}
}
複製程式碼
最簡單的佈局——Container、Padding 和 Center:
我們經常說,Flutter 裡面所有的東西都是 Widget,所以,佈局也是 Widget。
控制元件 Container 可以讓我們設定一個控制元件的尺寸、背景、margin 等:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
child: Text('text'),
padding: EdgeInsets.all(8.0),
margin: EdgeInsets.all(4.0),
width: 80.0,
decoration: BoxDecoration(
// 背景色
color: Colors.grey,
// 圓角
borderRadius: BorderRadius.circular(5.0),
),
);
}
}
複製程式碼
如果我們只需要 padding,可以使用控制元件 Padding:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(8.0),
child: Text('text'),
);
}
}
複製程式碼
Center 就跟它的名字一樣,把一個控制元件放在中間:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(8.0),
margin: EdgeInsets.all(4.0),
width: 200.0,
height: 200.0,
decoration: BoxDecoration(
// 背景色
color: Colors.grey,
// 圓角
borderRadius: BorderRadius.circular(5.0),
),
// 把文字放在 Container 的中間
child: Center(
child: Text('text'),
),
);
}
}
複製程式碼
水平、豎直佈局和 Expand
我們經常說,Flutter 裡面所有的東西都是 Widget,所以,佈局也是 Widget。水平佈局我們可以使用 Row,豎直佈局使用 Column。
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
// 只有一個子元素的 widget,一般使用 child 引數來設定;Row 可以包含多個子控制元件,
// 對應的則是 children。
children: <Widget>[
Text('text1'),
Text('text2'),
Text('text3'),
Text('text4'),
],
);
}
}
複製程式碼
Column 的使用是一樣的:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text('text1'),
Text('text2'),
Text('text3'),
Text('text4'),
],
);
}
}
複製程式碼
關於 Expand 控制元件,我們來看看 TextField 的那個例子:
class MessageForm extends StatefulWidget {
@override
State createState() {
return _MessageFormState();
}
}
class _MessageFormState extends State<MessageForm> {
var editController = TextEditingController();
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
// 佔滿一行裡除 RaisedButton 外的所有空間
Expanded(
child: TextField(
controller: editController,
),
),
RaisedButton(
child: Text("click"),
onPressed: () => print('text inputted: ${editController.text}'),
)
],
);
}
@override
void dispose() {
super.dispose();
editController.dispose();
}
}
複製程式碼
這裡通過使用 Expand,TextField 才能夠佔滿一行裡除按鈕外的所有空間。此外,當一行/列裡有多個 Expand 時,我們還可以通過設定它的 flex 引數,在多個 Expand 之間按比例劃分可用空間。
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
// 佔一行的 2/3
flex: 2,
child: RaisedButton(child: Text('btn1'),),
),
Expanded(
// 佔一行的 1/3
flex: 1,
child: RaisedButton(child: Text('btn2'),),
),
],
);
}
}
複製程式碼
Stack 佈局
有些時候,我們可能會希望一個控制元件疊在另一個控制元件的上面。於是,Stack 應運而生:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Text('foobar'),
Text('barfoo'),
],
);
}
}
複製程式碼
預設情況下,子控制元件都按 Stack 的左上角對齊,於是,上面的兩個文字完全一上一下堆疊在一起。我們還可以通過設定 alignment 引數來改變這個對齊的位置:
class TestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Stack(
// Aligment 的取值範圍為 [-1, 1],Stack 中心為 (0, 0),
// 這裡設定為 (-0.5, -0.5) 後,可以讓文字對齊到 Container 的 1/4 處
alignment: const Alignment(-0.5, -0.5),
children: <Widget>[
Container(
width: 200.0,
height: 200.0,
color: Colors.blue,
),
Text('foobar'),
],
);
}
}
複製程式碼
效果如下:
通過組合 Row/Column 和 Stack,已經能夠完成絕大部分的佈局了,所以 Flutter 裡沒有相對佈局之類的東西。更多的 Flutter 控制元件,讀者可以參考 flutter.io/widgets/。
示例一
在這一節裡,我們綜合前面所學的知識,來實現下面這個介面。
展示圖片
把圖片 lake 放到專案根目錄的 images 資料夾下(如果沒有,你需要自己建立一個)
修改 pubspec.yaml,找到下面這個地方,然後把圖片加進來
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
複製程式碼修改後如下:
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
assets:
- images/lake.jpg
複製程式碼現在,我們可以把這張圖片展示出來了:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter UI basic 1',
home: Scaffold(
appBar: AppBar(
title: Text('Top Lakes'),
),
body: Image.asset(
'images/lake.jpg',
width: 600.0,
height: 240.0,
// cover 類似於 Android 開發中的 centerCrop,其他一些型別,讀者可以檢視
// https://docs.flutter.io/flutter/painting/BoxFit-class.html
fit: BoxFit.cover,
)
),
);
}
}
複製程式碼
如果讀者是初學 Flutter,強烈建議在遇到不熟悉的 API 時翻一翻文件,並在文件中找到 demo 所使用的 API。我們的例子不可能覆蓋所有的 API,通過這種方式熟悉文件後,讀者就可以根據文件實現出自己想要的效果。不妨就從 Image 開始吧,在 docs.flutter.io/flutter/wid… 找出上面我們使用的 Image.asset 建構函式的幾個引數的含義,還有 BoxFit 的其他幾個列舉值。
佈局
在這一小節,我們來實現圖片下方的標題區域。
我們直接來看程式碼:
class _TitleSection extends StatelessWidget {
final String title;
final String subtitle;
final int starCount;
_TitleSection(this.title, this.subtitle, this.starCount);
@override
Widget build(BuildContext context) {
// 為了給 title section 加上 padding,這裡我們給內容套一個 Container
return Container(
// 設定上下左右的 padding 都是 32。類似的還有 EdgeInsets.only/symmetric 等
padding: EdgeInsets.all(32.0),
child: Row(
children: <Widget>[
// 這裡為了讓標題佔滿螢幕寬度的剩餘空間,用 Expanded 把標題包了起來
Expanded(
// 再次提醒讀者,Expanded 只能包含一個子元素,使用的引數名是 child。接下來,
// 為了在豎直方向放兩個標題,加入一個 Column。
child: Column(
// Column 是豎直方向的,cross 為交叉的意思,也就是說,這裡設定的是水平方向
// 的對齊。在水平方向,我們讓文字對齊到 start(讀者可以修改為 end 看看效果)
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 聰明的你,這個時候肯定知道為什麼突然加入一個 Container 了。
// 跟前面一樣,只是為了設定一個 padding
Container(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
title,
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Text(
subtitle,
style: TextStyle(color: Colors.grey[500]),
)
],
),
),
// 這裡是 Row 的第二個子元素,下面這兩個就沒用太多值得說的東西了。
Icon(
Icons.star,
color: Colors.red[500],
),
Text(starCount.toString())
],
),
);
}
}
複製程式碼
對齊
接下來我們要做的這一部分在佈局上所用到的知識,基本知識在上一小節我們都已經學習了。這裡唯一的區別在於,三個按鈕是水平分佈的。
實現如下:
Widget _buildButtonColumn(BuildContext context, IconData icon, String label) {
final color = Theme.of(context).primaryColor;
return Column(
// main axis 跟我們前面提到的 cross axis 相對應,對 Column 來說,指的就是豎直方向。
// 在放置完子控制元件後,螢幕上可能還會有一些剩餘的空間(free space),min 表示儘量少佔用
// free space;類似於 Android 的 wrap_content。
// 對應的,還有 MainAxisSize.max
mainAxisSize: MainAxisSize.min,
// 沿著 main axis 居中放置
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, color: color),
Container(
margin: const EdgeInsets.only(top: 8.0),
child: Text(
label,
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.w400,
color: color,
),
),
)
],
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
//...
Widget buttonSection = Container(
child: Row(
// 沿水平方向平均放置
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildButtonColumn(context, Icons.call, 'CALL'),
_buildButtonColumn(context, Icons.near_me, 'ROUTE'),
_buildButtonColumn(context, Icons.share, 'SHARE'),
],
),
);
//...
}
複製程式碼
關於 cross/main axis,看看下面這兩個圖就很清楚了:
MainAxisAlignment 的更多的資訊,可以檢視 docs.flutter.io/flutter/ren…。
全部放到一起
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final titleSection = _TitleSection(
'Oeschinen Lake Campground', 'Kandersteg, Switzerland', 41);
final buttonSection = ...;
final textSection = Container(
padding: const EdgeInsets.all(32.0),
child: Text(
'''
Lake Oeschinen lies at the foot of the Blüemlisalp in the Bernese Alps. Situated 1,578 meters above sea level, it is one of the larger Alpine Lakes. A gondola ride from Kandersteg, followed by a half-hour walk through pastures and pine forest, leads you to the lake, which warms to 20 degrees Celsius in the summer. Activities enjoyed here include rowing, and riding the summer toboggan run.
''',
softWrap: true,
),
);
return MaterialApp(
title: 'Flutter UI basic 1',
home: Scaffold(
appBar: AppBar(
title: Text('Top Lakes'),
),
// 由於我們的內容可能會超出螢幕的長度,這裡把內容都放到 ListView 裡。
// 除了這種用法,ListView 也可以像我們在 Android 原生開發中使用 ListView 那樣,
// 根據資料動態生成一個個 item。這個我們在下一節再來學習
body: ListView(
children: <Widget>[
Image.asset(
'images/lake.jpg',
width: 600.0,
height: 240.0,
// cover 類似於 Android 開發中的 centerCrop,其他一些型別,讀者可以檢視
// https://docs.flutter.io/flutter/painting/BoxFit-class.html
fit: BoxFit.cover,
),
titleSection,
buttonSection,
textSection
],
),
)
);
}
}
}
複製程式碼
現在,如果沒有出錯的話,執行後應該就可以看到下面這個頁面。
如果你遇到了麻煩,可以在這裡找到所有的原始碼:
git clone https://github.com/Jekton/flutter_demo.git
cd flutter_demo
git checkout ui-basic1
複製程式碼
更多的佈局知識,讀者還可以參考 flutter.io/tutorials/l…。
示例二
在這一小節我們來實現一個 list view。
這裡我們採用的還是官網提供的例子,但是換一種方式來實現,讓它跟我們平時使用 Java 時更像一些。
首先給資料建模:
enum BuildingType { theater, restaurant }
class Building {
final BuildingType type;
final String title;
final String address;
Building(this.type, this.title, this.address);
}
複製程式碼
然後實現每個 item 的 UI:
class ItemView extends StatelessWidget {
final int position;
final Building building;
ItemView(this.position, this.building);
@override
Widget build(BuildContext context) {
final icon = Icon(
building.type == BuildingType.restaurant
? Icons.restaurant
: Icons.theaters,
color: Colors.blue[500]);
final widget = Row(
children: <Widget>[
Container(
margin: EdgeInsets.all(16.0),
child: icon,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
building.title,
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.w500,
)
),
Text(building.address)
],
),
)
],
);
return widget;
}
}
複製程式碼
接著是 ListView。由於渲染機制不同,這裡沒必要弄個 adapter 來管理 widget:
class BuildingListView extends StatelessWidget {
final List<Building> buildings;
BuildingListView(this.buildings);
@override
Widget build(BuildContext context) {
// ListView.builder 可以按需生成子控制元件
return ListView.builder(
itemCount: buildings.length,
itemBuilder: (context, index) {
return new ItemView(index, buildings[index]);
}
);
}
}
複製程式碼
現在,我們來給 item 加上點選事件。
// 定義一個回撥介面
typedef OnItemClickListener = void Function(int position);
class ItemView extends StatelessWidget {
final int position;
final Building building;
final OnItemClickListener listener;
// 這裡的 listener 會從 ListView 那邊傳過來
ItemView(this.position, this.building, this.listener);
@override
Widget build(BuildContext context) {
final widget = ...;
// 一般來說,為了監聽手勢事件,我們使用 GestureDetector。但這裡為了在點選的時候有個
// 水波紋效果,使用的是 InkWell。
return InkWell(
onTap: () => listener(position),
child: widget
);
}
}
class BuildingListView extends StatelessWidget {
final List<Building> buildings;
final OnItemClickListener listener;
// 這是對外介面。外部通過建構函式傳入資料和 listener
BuildingListView(this.buildings, this.listener);
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: buildings.length,
itemBuilder: (context, index) {
return new ItemView(index, buildings[index], listener);
}
);
}
}
複製程式碼
最後加上一些腳手架程式碼,我們的列表就能夠跑起來了:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final buildings = [
Building(BuildingType.theater, 'CineArts at the Empire', '85 W Portal Ave'),
Building(BuildingType.theater, 'The Castro Theater', '429 Castro St'),
Building(BuildingType.theater, 'Alamo Drafthouse Cinema', '2550 Mission St'),
Building(BuildingType.theater, 'Roxie Theater', '3117 16th St'),
Building(BuildingType.theater, 'United Artists Stonestown Twin', '501 Buckingham Way'),
Building(BuildingType.theater, 'AMC Metreon 16', '135 4th St #3000'),
Building(BuildingType.restaurant, 'K\'s Kitchen', '1923 Ocean Ave'),
Building(BuildingType.restaurant, 'Chaiya Thai Restaurant', '72 Claremont Blvd'),
Building(BuildingType.restaurant, 'La Ciccia', '291 30th St'),
// double 一下
Building(BuildingType.theater, 'CineArts at the Empire', '85 W Portal Ave'),
Building(BuildingType.theater, 'The Castro Theater', '429 Castro St'),
Building(BuildingType.theater, 'Alamo Drafthouse Cinema', '2550 Mission St'),
Building(BuildingType.theater, 'Roxie Theater', '3117 16th St'),
Building(BuildingType.theater, 'United Artists Stonestown Twin', '501 Buckingham Way'),
Building(BuildingType.theater, 'AMC Metreon 16', '135 4th St #3000'),
Building(BuildingType.restaurant, 'K\'s Kitchen', '1923 Ocean Ave'),
Building(BuildingType.restaurant, 'Chaiya Thai Restaurant', '72 Claremont Blvd'),
Building(BuildingType.restaurant, 'La Ciccia', '291 30th St'),
];
return MaterialApp(
title: 'ListView demo',
home: Scaffold(
appBar: AppBar(
title: Text('Buildings'),
),
body: BuildingListView(buildings, (index) => debugPrint('item $index clicked'))
),
);
}
}
複製程式碼
這個時候你應該可以看到像這樣的介面了:
如果你遇到了什麼麻煩,可以檢視 tag ui-basic2 的程式碼:
git clone https://github.com/Jekton/flutter_demo.git
cd flutter_demo
git checkout ui-basic2
複製程式碼
推薦閱讀
Flutter學習指南:熟悉Dart語言
Flutter學習指南:編寫第一個應用
Flutter學習指南:開發環境搭建
關注下方的公眾號並回復 [禮包] 領取50超程式設計禮包