Flutter佈局詳解/必知必會
前言
本文的目的是為了讓讀者掌握不同佈局類Widget的佈局特點,分享一些在實際使用過程遇到的一些問題,在《Flutter實戰》這本書中已經講解的很詳細了,本文主要是對其內容的濃縮及實際遇到的問題的補充。
什麼是佈局類Widget
佈局類Widget就是指直接或間接繼承(包含)MultiChildRenderObjectWidget的Widget,它們一般都會有一個children屬性用於接收子Widget。在Flutter中Element樹才是最終的繪製樹,Element樹是通過widget樹來建立的(通過Widget.createElement()),widget其實就是Element的配置資料。它的最終佈局、UI介面渲染都是通過RenderObject物件來實現的,這裡的細節我就不詳細描述了,因為我也不懂。不過感興趣的小夥伴也可以看看本專欄的
Flutter中主要有以下幾種佈局類的Widget:
- 線性佈局Row和Column
- 彈性佈局Flex
- 流式佈局Wrap、Flow
- 層疊佈局Stack、Positioned
本文Demo地址
線性佈局Row和Column
線性佈局其實是指沿水平或垂直方向排布子Widget,Flutter中通過Row來實現水平方向的子Widegt佈局,通過Column來實現垂直方向的子Widget佈局。他們都繼承Flex,所以它們有很多相似的屬性。
在前端的Flex佈局中,預設存在兩根軸:水平的主軸(main axis)和垂直的交叉軸(cross axis)。主軸的開始位置(與邊框的交叉點)叫做main start,結束位置叫做main end;交叉軸的開始位置叫做cross start,結束位置叫做cross end。與Flutter中MainAxisAlignment和CrossAxisAlignment類似,分別代表主軸對齊和縱軸對齊。
原始碼屬性解讀
Row({
.....
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline textBaseline,
List <Widget> children = const <Widget>[],
})
Column({
.....
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline textBaseline,
List<Widget> children = const <Widget>[],
})
複製程式碼
- textDirection:表示水平方向子widget的佈局順序(是從左往右還是從右往左),預設為系統當前Locale環境的文字方向(如中文、英語都是從左往右,而阿拉伯語是從右往左)。
- 主軸方向: Row即為水平方向,Column為垂直方向
- mainAxisAlignment 主軸方向,對child起作用
- center:將children放置在主軸的中心
- start:將children放置在主軸的起點
- end:將children放置在主軸的末尾
- spaceAround:將主軸方向上的空白區域均分,使children之間的空白區域相等,但是首尾child的靠邊間距為空白區域為1/2
- spaceBetween:將主軸方向上的空白區域均分,使children之間的空白區域相等,首尾child靠邊沒有間隙
- spaceEvenly:將主軸方向上的空白區域均分,使得children之間的空白區域相等,包括首尾child
- mainAxisSize max表示儘可能佔多的控制元件,min會導致控制元件聚攏在一起
- crossAxisAlignment 交叉軸方向,對child起作用
- baseline:使children baseline對齊
- center:children在交叉軸上居中展示
- end:children在交叉軸上末尾展示
- start:children在交叉軸上起點處展示
- stretch:讓children填滿交叉軸方向
- verticalDirection ,child的放置順序
- VerticalDirection.down,在Row中就是從左邊到右邊,Column代表從頂部到底部
- VerticalDirection.up,相反
Row
示例程式碼
ListView(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Text("我是Row的子控制元件 "),
Text("MainAxisAlignment.start")
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text("我是Row的子控制元件 "),
Text("MainAxisAlignment.center")
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Text("我是Row的子控制元件 "),
Text("MainAxisAlignment.end")
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
verticalDirection: VerticalDirection.up,
children: <Widget>[
Text(" Hello World ", style: TextStyle(fontSize: 30.0),),
Text(" I am Jack "),
],
],
)
複製程式碼
程式碼執行效果
前3個Row很簡單,只是設定了主軸方向的對齊方式;第四個Row測試的是縱軸的對齊方式,由於兩個子Text字型不一樣,所以其高度也不同,我們指定了verticalDirection值為VerticalDirection.up,即從低向頂排列,而此時crossAxisAlignment值為CrossAxisAlignment.start表示底對齊。大家可以參考上面Row和Column的主側軸的示意圖,看看佈局是不是正確的,還有很多種情況就不一一列舉了。Column
示例程式碼
ListView(children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text("我是Colum的子控制元件"),
Text("CrossAxisAlignment.start"),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("我是Colum的子控制元件"),
Text("CrossAxisAlignment.center"),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text("我是Colum的子控制元件"),
Text("CrossAxisAlignment.end"),
],
),
],)
複製程式碼
程式碼執行效果
Column和Row差不多,只是佈局方向不一樣而已,大家可以參考著看,這裡就不再贅述了。實際使用
由於篇幅有限,我就不詳細講解實際遇到的問題了,只說現象和解決辦法:
- 如果Row裡面巢狀Row,或者Column裡面再巢狀Column,那麼只有對最外面的Row或Column會佔用盡可能大的空間,裡面Row或Column所佔用的空間為實際大小,如果要讓裡面的Colum或Row佔滿外部Colum或Row,可以使用Expanded widget
- 如果使用Column發現超範圍,可用SingleChildScrollView包裹,scrollDirection屬性設定滑動方向
- 使用Column巢狀ListView/GridView的時候,會報異常資訊【Viewports expand in the scrolling direction to fill their container...】,這種情況flutter已給出解決辦法,將ListView/GridView的 shrinkWrap屬性設為true
- 有的時候修改Row/Column的verticalDirection會得到很好的效果,比如需要頁面在底部需要幾個按鍵,也可以用Stack來佈局,但是相對麻煩,而且有時還需要知道控制元件的大小,沒有verticalDirection方便
彈性佈局
彈性佈局是一種允許子widget按照一定比例來分配父容器空間的佈局方式,如果你知道了它的主軸方向,那就可以用Row或Column了,一般情況下,可以用Flex的地方都可以用Row或者Column一起使用,通常配合Expanded Widget來使用,同樣Expanded也不能脫離Flex單獨建立。
Expanded
Expanded繼承自Flexible,Flexible是一個控制Row、Column、Flex等子元件如何佈局的元件,它可以按比例“擴伸”Row、Column和Flex子widget所佔用的空間。
const Expanded({
int flex = 1,
@required Widget child,
})
複製程式碼
flex為彈性係數,如果為0或null,則child是沒有彈性的,即不會被擴伸佔用的空間。如果大於0,所有的Expanded按照其flex的比例來分割主軸的全部空閒空間。
示例程式碼
Row(children: <Widget>[
RaisedButton(
onPressed: () {
print('點選紅色按鈕事件');
},
color: Colors.red,
child: Text('紅色按鈕'),
),
Expanded(
flex: 1,
child: RaisedButton(
onPressed: () {
print('點選黃色按鈕事件');
},
color: Colors.yellow,
child: Text('黃色按鈕'),
),
),
RaisedButton(
onPressed: () {
print('點選粉色按鈕事件');
},
color: Colors.green,
child: Text('綠色按鈕'),
),
])
複製程式碼
程式碼執行效果
Flexible和 Expanded的區別
- Flexible元件必須是Row、Column、Flex等元件的後裔,並且從Flexible到它封裝的Row、Column、Flex的路徑必須只包括StatelessWidgets或StatefulWidgets元件(不能是其他型別的元件,像RenderObjectWidgets)
- Row、Column、Flex會被Expanded撐開,充滿主軸可用空間,而Flexible不強制子元件填充可用空間,這是因為fit屬性的值不同,該屬性在Expanded中為FlexFit.tight,Flexible為FlexFit.loose,區別在於tight表示強制使子控制元件填充剩餘可用空間,loose表示最多填滿其在父控制元件所設定的比例,所以loose預設即為控制元件的大小
流式佈局
流式佈局(Liquid)的特點(也叫"Fluid") 是頁面元素的寬度按照螢幕解析度進行適配調整,但整體佈局不變。柵欄系統(網格系統),使用者標籤等。在Flutter中主要有Wrap和Flow兩種Widget實現。
Wrap
在介紹Row和Colum時,如果子widget超出螢幕範圍,則會報溢位錯誤,在Flutter中通過Wrap和Flow來支援流式佈局,溢位部分則會自動折行。
原始碼屬性解讀
Wrap({
...
this.direction = Axis.horizontal,
this.alignment = WrapAlignment.start,
this.spacing = 0.0,
this.runAlignment = WrapAlignment.start,
this.runSpacing = 0.0,
this.crossAxisAlignment = WrapCrossAlignment.start,
this.textDirection,
this.verticalDirection = VerticalDirection.down,
List<Widget> children = const <Widget>[],
})
複製程式碼
上述有很多屬性和Row的相同,其意義其實也是相同的,這裡我就不一一介紹了,主要介紹下不同的屬性:
- spacing:主軸方向子widget的間距
- runSpacing:縱軸方向的間距
- runAlignment:縱軸方向的對齊方式
示例程式碼
Wrap(
spacing: 10.0,
direction: Axis.horizontal,
alignment: WrapAlignment.start,
children: <Widget>[
_card('關注'),
_card('推薦'),
_card('新時代'),
_card('小視訊'),
_card('黨媒推薦'),
_card('中國新唱將'),
_card('歷史'),
_card('視訊'),
_card('遊戲'),
_card('頭條號'),
_card('數碼'),
],
)
Widget _card(String title) {
return Card(child: Text(title),);
}
}
複製程式碼
執行效果
小結
- 使用Wrap可以很輕鬆的實現流式佈局效果
- Wrap支援設定流式佈局是縱向顯示或者是橫向顯示
- 可以使用alignment屬性來控制widgets的佈局方式
Flow
我們一般很少會使用Flow,因為其過於複雜,需要自己實現子widget的位置轉換,在很多場景下首先要考慮的是Wrap是否滿足需求。Flow主要用於一些需要自定義佈局策略或效能要求較高(如動畫中)的場景。Flow有如下優點:
- 效能好;Flow是一個對child尺寸以及位置調整非常高效的控制元件,Flow用轉換矩陣(transformation matrices)在對child進行位置調整的時候進行了優化:在Flow定位過後,如果child的尺寸或者位置發生了變化,在FlowDelegate中的paintChildren()方法中呼叫context.paintChild 進行重繪,而context.paintChild在重繪時使用了轉換矩陣(transformation matrices),並沒有實際調整Widget位置。
- 靈活;由於我們需要自己實現FlowDelegate的paintChildren()方法,所以我們需要自己計算每一個widget的位置,因此,可以自定義佈局策略。 缺點:
- 使用複雜.
- 不能自適應子widget大小,必須通過指定父容器大小或實現TestFlowDelegate的getSize返回固定大小。
示例程式碼
我們對六個色塊進行自定義流式佈局:
Flow(
delegate: TestFlowDelegate(margin: EdgeInsets.all(10.0)),
children: <Widget>[
new Container(width: 80.0, height:80.0, color: Colors.red,),
new Container(width: 80.0, height:80.0, color: Colors.green,),
new Container(width: 80.0, height:80.0, color: Colors.blue,),
new Container(width: 80.0, height:80.0, color: Colors.yellow,),
new Container(width: 80.0, height:80.0, color: Colors.brown,),
new Container(width: 80.0, height:80.0, color: Colors.purple,),
],
)
複製程式碼
實現TestFlowDelegate:
class TestFlowDelegate extends FlowDelegate {
EdgeInsets margin = EdgeInsets.zero;
TestFlowDelegate({this.margin});
@override
void paintChildren(FlowPaintingContext context) {
var x = margin.left;
var y = margin.top;
//計算每一個子widget的位置
for (int i = 0; i < context.childCount; i++) {
var w = context.getChildSize(i).width + x + margin.right;
if (w < context.size.width) {
context.paintChild(i,
transform: new Matrix4.translationValues(
x, y, 0.0));
x = w + margin.left;
} else {
x = margin.left;
y += context.getChildSize(i).height + margin.top + margin.bottom;
//繪製子widget(有優化)
context.paintChild(i,
transform: new Matrix4.translationValues(
x, y, 0.0));
x += context.getChildSize(i).width + margin.left + margin.right;
}
}
}
getSize(BoxConstraints constraints){
//指定Flow的大小
return Size(double.infinity,200.0);
}
@override
bool shouldRepaint(FlowDelegate oldDelegate) {
return oldDelegate != this;
}
}
複製程式碼
執行效果
可以看到我們主要的任務就是實現paintChildren,它的主要任務是確定每個子widget位置。由於Flow不能自適應子widget的大小,我們通過在getSize返回一個固定大小來指定Flow的大小,實現起來還是比較麻煩的。小結
- 引數簡單,不過需要自己定義delegate
- delegate一般是為了實現child的繪製,就是位置的擺放,不同情況需要定義不同的delegate
- 不同的delegate一般會提供實現的幾個方法:
- getConstraintsForChild: 設定每個child的佈局約束條件,會覆蓋已有的方式
- getSize:設定控制元件的尺寸
- shouldRelayout:表示是否需要重新佈局
- 儘可能的用Wrap,畢竟簡單
層疊佈局
層疊佈局和Web中的絕對定位、Android中的Frame佈局是相似的,子widget可以根據到父容器四個角的位置來確定本身的位置。絕對定位允許子widget堆疊(按照程式碼中宣告的順序)。Flutter中使用Stack和Positioned來實現絕對定位,Stack允許子widget堆疊,而Positioned可以給子widget定位(根據Stack的四個角)。
Stack
Stack({
this.alignment = AlignmentDirectional.topStart,
this.textDirection,
this.fit = StackFit.loose,
this.overflow = Overflow.clip,
List<Widget> children = const <Widget>[],
})
複製程式碼
- alignment:此引數決定如何去對齊沒有定位(沒有使用Positioned)或部分定位的子widget。所謂部分定位,在這裡特指沒有在某一個軸上定位:left、right為橫軸,top、bottom為縱軸,只要包含某個軸上的一個定位屬性就算在該軸上有定位。
- textDirection:和Row、Wrap的textDirection功能一樣,都用於決定alignment對齊的參考系即:textDirection的值為TextDirection.ltr,則alignment的start代表左,end代表右;textDirection的值為TextDirection.rtl,則alignment的start代表右,end代表左。
- fit:此引數用於決定沒有定位的子widget如何去適應Stack的大小。StackFit.loose表示使用子widget的大小,StackFit.expand表示擴伸到Stack的大小。
- overflow:此屬性決定如何顯示超出Stack顯示空間的子widget,值為Overflow.clip時,超出部分會被剪裁(隱藏),值為Overflow.visible 時則不會。
下面是我用Stack實現的一個簡易的loading
class Loading extends StatelessWidget {
/// ProgressIndicator的padding,決定loading的大小
final EdgeInsets padding = EdgeInsets.all(30.0);
/// 文字頂部距菊花的底部的距離
final double margin = 10.0;
/// 圓角
final double cornerRadius = 10.0;
final Widget _child;
final bool _isLoading;
final double opacity;
final Color color;
final String text;
Loading({
Key key,
@required child,
@required isLoading,
this.text,
this.opacity = 0.3,
this.color = Colors.grey,
}) : assert(child != null),
assert(isLoading != null),
_child = child,
_isLoading = isLoading,
super(key: key);
@override
Widget build(BuildContext context) {
List<Widget> widgetList = List<Widget>();
widgetList.add(_child);
if (_isLoading) {
final loading = [
Opacity(
opacity: opacity,
child: ModalBarrier(dismissible: false, color: color),
),
_buildProgressIndicator()
];
widgetList.addAll(loading);
}
return Stack(
children: widgetList,
);
}
Widget _buildProgressIndicator() {
return Center(
child: Container(
padding: padding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
CupertinoActivityIndicator(),
Padding(
padding: EdgeInsets.only(top: margin),
child: Text(text ?? '載入中...')),
],
),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(cornerRadius)),
color: Colors.white),
),
);
}
}
複製程式碼
顯示效果
本控制元件使用Stack封裝,你傳入的主檢視在最下面一層,背景層在中間,最上面一層為菊花和文字loading,用isLoading控制顯示
Positioned
const Positioned({
Key key,
this.left,
this.top,
this.right,
this.bottom,
this.width,
this.height,
@required Widget child,
})
複製程式碼
left、top 、right、 bottom分別代表離Stack左、上、右、底四邊的距離。width和height用於指定定位元素的寬度和高度,注意,此處的width、height 和其它地方的意義稍微有點區別,此處用於配合left、top 、right、 bottom來定位widget,舉個例子,在水平方向時,你只能指定left、right、width三個屬性中的兩個,如指定left和width後,right會自動算出(left+width),如果同時指定三個屬性則會報錯,垂直方向同理。
示例程式碼
//通過ConstrainedBox來確保Stack佔滿螢幕
ConstrainedBox(
constraints: BoxConstraints.expand(),
child: Stack(
alignment:Alignment.center , //指定未定位或部分定位widget的對齊方式
children: <Widget>[
Container(child: Text("Hello world",style: TextStyle(color: Colors.white)),
color: Colors.red,
),
Positioned(
left: 18.0,
child: Text("I am Jack"),
),
Positioned(
top: 18.0,
child: Text("Your friend"),
)
],
),
);
複製程式碼