Flutter 佈局(九)- Flow、Table、Wrap詳解

吹個大氣球發表於2019-03-03

本文主要介紹Flutter佈局中的Flow、Table、Wrap控制元件,詳細介紹了其佈局行為以及使用場景,並對原始碼進行了分析。

1. Flow

A widget that implements the flow layout algorithm.

1.1 簡介

Flow按照解釋的那樣,是一個實現流式佈局演算法的控制元件。流式佈局在大前端是很常見的佈局方式,但是一般使用Flow很少,因為其過於複雜,很多場景下都會去使用Wrap。

1.2 佈局行為

Flow官方介紹是一個對child尺寸以及位置調整非常高效的控制元件,主要是得益於其FlowDelegate。另外Flow在用轉換矩陣(transformation matrices)對child進行位置調整的時候進行了優化。

Flow以及其child的一些約束都會受到FlowDelegate的控制,例如重寫FlowDelegate中的geiSize,可以設定Flow的尺寸,重寫其getConstraintsForChild方法,可以設定每個child的佈局約束條件。

Flow之所以高效,是因為其在定位過後,如果使用FlowDelegate中的paintChildren改變child的尺寸或者位置,只是重繪,並沒有實際調整其位置。

1.3 繼承關係

Object > Diagnosticable > DiagnosticableTree > Widget > RenderObjectWidget > MultiChildRenderObjectWidget > Flow
複製程式碼

1.4 示例程式碼

const width = 80.0;
const height = 60.0;

Flow(
  delegate: TestFlowDelegate(margin: EdgeInsets.fromLTRB(10.0, 10.0, 10.0, 10.0)),
  children: <Widget>[
    new Container(width: width, height: height, color: Colors.yellow,),
    new Container(width: width, height: height, color: Colors.green,),
    new Container(width: width, height: height, color: Colors.red,),
    new Container(width: width, height: height, color: Colors.black,),
    new Container(width: width, height: height, color: Colors.blue,),
    new Container(width: width, height: height, color: Colors.lightGreenAccent,),
  ],
)

class TestFlowDelegate extends FlowDelegate {
  EdgeInsets margin = EdgeInsets.zero;

  TestFlowDelegate({this.margin});
  @override
  void paintChildren(FlowPaintingContext context) {
    var x = margin.left;
    var y = margin.top;
    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;
        context.paintChild(i,
            transform: new Matrix4.translationValues(
                x, y, 0.0));
        x += context.getChildSize(i).width + margin.left + margin.right;
      }
    }
  }

  @override
  bool shouldRepaint(FlowDelegate oldDelegate) {
    return oldDelegate != this;
  }
}
複製程式碼

樣例其實並不複雜,FlowDelegate需要自己實現child的繪製,其實大多數時候就是位置的擺放。上面例子中,對每個child按照給定的margin值,進行排列,如果超出一行,則在下一行進行佈局。

Flow樣例

另外,對這個例子多做一個說明,對於上述child寬度的變化,這個例子是沒問題的,如果每個child的高度不同,則需要對程式碼進行調整,具體的調整是換行的時候,需要根據上一行的最大高度來確定下一行的起始y座標。

1.5 原始碼解析

建構函式如下:

Flow({
  Key key,
  @required this.delegate,
  List<Widget> children = const <Widget>[],
})
複製程式碼

1.5.1 屬性解析

delegate:影響Flow具體佈局的FlowDelegate。

其中FlowDelegate包含如下幾個方法:

  • getConstraintsForChild: 設定每個child的佈局約束條件,會覆蓋已有的;
  • getSize:設定Flow的尺寸;
  • paintChildren:child的繪製控制程式碼,可以調整尺寸位置,寫起來比較的繁瑣;
  • shouldRepaint:是否需要重繪;
  • shouldRelayout:是否需要重新佈局。

其中,我們平時使用的時候,一般會使用到paintChildren以及shouldRepaint兩個方法。

1.5.2 原始碼

我們先來看一下Flow的佈局程式碼

Size _getSize(BoxConstraints constraints) {
  assert(constraints.debugAssertIsValid());
  return constraints.constrain(_delegate.getSize(constraints));
}

@override
void performLayout() {
  size = _getSize(constraints);
  int i = 0;
  _randomAccessChildren.clear();
  RenderBox child = firstChild;
  while (child != null) {
    _randomAccessChildren.add(child);
    final BoxConstraints innerConstraints = _delegate.getConstraintsForChild(i, constraints);
    child.layout(innerConstraints, parentUsesSize: true);
    final FlowParentData childParentData = child.parentData;
    childParentData.offset = Offset.zero;
    child = childParentData.nextSibling;
    i += 1;
  }
}
複製程式碼

可以看到Flow尺寸的取值,直接來自於delegate的getSize方法。對於每一個child,則是將delegate中的getConstraintsForChild設定的約束條件,設定在child上。

Flow佈局上的表現,受Delegate中getSize以及getConstraintsForChild兩個方法的影響。第一個方法設定其尺寸,第二個方法設定其children的佈局約束條件。

接下來我們來看一下其繪製方法。

void _paintWithDelegate(PaintingContext context, Offset offset) {
  _lastPaintOrder.clear();
  _paintingContext = context;
  _paintingOffset = offset;
  for (RenderBox child in _randomAccessChildren) {
    final FlowParentData childParentData = child.parentData;
    childParentData._transform = null;
  }
  try {
    _delegate.paintChildren(this);
  } finally {
    _paintingContext = null;
    _paintingOffset = null;
  }
}
複製程式碼

它的繪製方法非常的簡單,先將上次設定的引數都初始化,然後呼叫delegate中的paintChildren進行繪製。在paintChildren中會呼叫paintChild方法去繪製每個child,我們接下來看下其程式碼。

@override
  void paintChild(int i, { Matrix4 transform, double opacity = 1.0 }) {
    transform ??= new Matrix4.identity();
    final RenderBox child = _randomAccessChildren[i];
    final FlowParentData childParentData = child.parentData;
    _lastPaintOrder.add(i);
    childParentData._transform = transform;
    
    if (opacity == 0.0)
      return;

    void painter(PaintingContext context, Offset offset) {
      context.paintChild(child, offset);
    }
    
    if (opacity == 1.0) {
      _paintingContext.pushTransform(needsCompositing, _paintingOffset, transform, painter);
    } else {
      _paintingContext.pushOpacity(_paintingOffset, _getAlphaFromOpacity(opacity), (PaintingContext context, Offset offset) {
        context.pushTransform(needsCompositing, offset, transform, painter);
      });
    }
  }
複製程式碼

paitChild函式首先會將transform值設在child上,然後根據opacity值,決定其繪製的表現。

  • 當opacity為0時,只是設定了transform值,這樣做是為了讓其響應區域跟隨調整,雖然不顯示出來;
  • 當opacity為1的時候,只是進行Transform操作;
  • 當opacity大於0小於1時,先調整其透明度,再進行Transform操作。

至於其為什麼高效,主要是因為它的佈局函式不牽涉到child的佈局,而在繪製的時候,則根據delegate中的策略,進行有效的繪製。

1.6 使用場景

Flow在一些定製化的流式佈局中,有可用場景,但是一般寫起來比較複雜,但勝在靈活性以及其高效。

2. Table

A widget that uses the table layout algorithm for its children.

2.1 簡介

每一種移動端佈局中都會有一種table佈局,這種控制元件太常見了。至於其表現形式,完全可以借鑑其他移動端的,通俗點講,就是表格。

2.2 佈局行為

表格的每一行的高度,由其內容決定,每一列的寬度,則由columnWidths屬性單獨控制。

2.3 繼承關係

Object > Diagnosticable > DiagnosticableTree > Widget > RenderObjectWidget > Table
複製程式碼

2.4 示例程式碼

Table(
  columnWidths: const <int, TableColumnWidth>{
    0: FixedColumnWidth(50.0),
    1: FixedColumnWidth(100.0),
    2: FixedColumnWidth(50.0),
    3: FixedColumnWidth(100.0),
  },
  border: TableBorder.all(color: Colors.red, width: 1.0, style: BorderStyle.solid),
  children: const <TableRow>[
    TableRow(
      children: <Widget>[
        Text(`A1`),
        Text(`B1`),
        Text(`C1`),
        Text(`D1`),
      ],
    ),
    TableRow(
      children: <Widget>[
        Text(`A2`),
        Text(`B2`),
        Text(`C2`),
        Text(`D2`),
      ],
    ),
    TableRow(
      children: <Widget>[
        Text(`A3`),
        Text(`B3`),
        Text(`C3`),
        Text(`D3`),
      ],
    ),
  ],
)
複製程式碼

一個三行四列的表格,第一三行寬度為50,第二四行寬度為100。

Table樣例

2.5 原始碼解析

建構函式如下:

Table({
  Key key,
  this.children = const <TableRow>[],
  this.columnWidths,
  this.defaultColumnWidth = const FlexColumnWidth(1.0),
  this.textDirection,
  this.border,
  this.defaultVerticalAlignment = TableCellVerticalAlignment.top,
  this.textBaseline,
})
複製程式碼

2.5.1 屬性解析

columnWidths:設定每一列的寬度。

defaultColumnWidth:預設的每一列寬度值,預設情況下均分。

textDirection:文字方向,一般無需考慮。

border:表格邊框。

defaultVerticalAlignment:每一個cell的垂直方向的alignment。

總共包含5種:

  • top:被放置在的頂部;
  • middle:垂直居中;
  • bottom:放置在底部;
  • baseline:文字baseline對齊;
  • fill:充滿整個cell。

textBaseline:defaultVerticalAlignment為baseline的時候,會用到這個屬性。

2.5.2 原始碼

我們直接來看其佈局原始碼:

第一步,當行或者列為0的時候,將自身尺寸設為0x0。

if (rows * columns == 0) {
  size = constraints.constrain(const Size(0.0, 0.0));
  return;
}
複製程式碼

第二步,根據textDirection值,設定方向,一般在阿拉伯語系中,一些文字都是從右往左現實的,平時使用時,不需要去考慮這個屬性。

switch (textDirection) {
  case TextDirection.rtl:
    positions[columns - 1] = 0.0;
    for (int x = columns - 2; x >= 0; x -= 1)
      positions[x] = positions[x+1] + widths[x+1];
    _columnLefts = positions.reversed;
    tableWidth = positions.first + widths.first;
    break;
  case TextDirection.ltr:
    positions[0] = 0.0;
    for (int x = 1; x < columns; x += 1)
      positions[x] = positions[x-1] + widths[x-1];
    _columnLefts = positions;
    tableWidth = positions.last + widths.last;
    break;
}
複製程式碼

第三步,設定每一個cell的尺寸。

  for (int x = 0; x < columns; x += 1) {
    final int xy = x + y * columns;
    final RenderBox child = _children[xy];
    if (child != null) {
      final TableCellParentData childParentData = child.parentData;
      childParentData.x = x;
      childParentData.y = y;
      switch (childParentData.verticalAlignment ?? defaultVerticalAlignment) {
        case TableCellVerticalAlignment.baseline:
          child.layout(new BoxConstraints.tightFor(width: widths[x]), parentUsesSize: true);
          final double childBaseline = child.getDistanceToBaseline(textBaseline, onlyReal: true);
          if (childBaseline != null) {
            beforeBaselineDistance = math.max(beforeBaselineDistance, childBaseline);
            afterBaselineDistance = math.max(afterBaselineDistance, child.size.height - childBaseline);
            baselines[x] = childBaseline;
            haveBaseline = true;
          } else {
            rowHeight = math.max(rowHeight, child.size.height);
            childParentData.offset = new Offset(positions[x], rowTop);
          }
          break;
        case TableCellVerticalAlignment.top:
        case TableCellVerticalAlignment.middle:
        case TableCellVerticalAlignment.bottom:
          child.layout(new BoxConstraints.tightFor(width: widths[x]), parentUsesSize: true);
          rowHeight = math.max(rowHeight, child.size.height);
          break;
        case TableCellVerticalAlignment.fill:
          break;
      }
    }
  }
複製程式碼

第四步,如果有baseline則進行相關設定。

if (haveBaseline) {
  if (y == 0)
    _baselineDistance = beforeBaselineDistance;
    rowHeight = math.max(rowHeight, beforeBaselineDistance + afterBaselineDistance);
}
複製程式碼

第五步,根據alignment,調整child的位置。

  for (int x = 0; x < columns; x += 1) {
    final int xy = x + y * columns;
    final RenderBox child = _children[xy];
    if (child != null) {
      final TableCellParentData childParentData = child.parentData;
      switch (childParentData.verticalAlignment ?? defaultVerticalAlignment) {
        case TableCellVerticalAlignment.baseline:
          if (baselines[x] != null)
            childParentData.offset = new Offset(positions[x], rowTop + beforeBaselineDistance - baselines[x]);
          break;
        case TableCellVerticalAlignment.top:
          childParentData.offset = new Offset(positions[x], rowTop);
          break;
        case TableCellVerticalAlignment.middle:
          childParentData.offset = new Offset(positions[x], rowTop + (rowHeight - child.size.height) / 2.0);
          break;
        case TableCellVerticalAlignment.bottom:
          childParentData.offset = new Offset(positions[x], rowTop + rowHeight - child.size.height);
          break;
        case TableCellVerticalAlignment.fill:
          child.layout(new BoxConstraints.tightFor(width: widths[x], height: rowHeight));
          childParentData.offset = new Offset(positions[x], rowTop);
          break;
      }
    }
  }
複製程式碼

最後一步,則是根據每一行的寬度以及每一列的高度,設定Table的尺寸。

size = constraints.constrain(new Size(tableWidth, rowTop));
複製程式碼

最後梳理一下整個的佈局流程:

  • 當行或者列為0的時候,將自身尺寸設為0x0;
  • 根據textDirection進行相關設定;
  • 設定cell的尺寸;
  • 如果設定了baseline,則進行相關設定;
  • 根據alignment設定cell垂直方向的位置;
  • 設定Table的尺寸。

如果經常關注系列文章的讀者,可能會發現,佈局控制元件的佈局流程基本上跟上述流程是相似的。

2.6 使用場景

在一些需要表格展示的場景中,可以使用Table控制元件。

3. Wrap

A widget that displays its children in multiple horizontal or vertical runs.

3.1 簡介

看簡介,其實Wrap實現的效果,Flow可以很輕鬆,而且可以更加靈活的實現出來。

3.2 佈局行為

Flow可以很輕易的實現Wrap的效果,但是Wrap更多的是在使用了Flex中的一些概念,某種意義上說是跟Row、Column更加相似的。

單行的Wrap跟Row表現幾乎一致,單列的Wrap則跟Row表現幾乎一致。但Row與Column都是單行單列的,Wrap則突破了這個限制,mainAxis上空間不足時,則向crossAxis上去擴充套件顯示。

從效率上講,Flow肯定會比Wrap高,但是Wrap使用起來會方便一些。

3.3 繼承關係

Object > Diagnosticable > DiagnosticableTree > Widget > RenderObjectWidget > MultiChildRenderObjectWidget > Wrap
複製程式碼

從繼承關係上看,Wrap與Flow都是繼承自MultiChildRenderObjectWidget,Flow可以實現Wrap的效果,但是兩者卻是單獨實現的,說明兩者有很大的不同。

3.4 示例程式碼

Wrap(
  spacing: 8.0, // gap between adjacent chips
  runSpacing: 4.0, // gap between lines
  children: <Widget>[
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text(`AH`, style: TextStyle(fontSize: 10.0),)),
      label: Text(`Hamilton`),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text(`ML`, style: TextStyle(fontSize: 10.0),)),
      label: Text(`Lafayette`),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text(`HM`, style: TextStyle(fontSize: 10.0),)),
      label: Text(`Mulligan`),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text(`JL`, style: TextStyle(fontSize: 10.0),)),
      label: Text(`Laurens`),
    ),
  ],
)
複製程式碼

示例程式碼直接使用的官方文件上的,效果跟Flow的例子中相似。

Wrap樣例

3.5 原始碼解析

建構函式如下:

Wrap({
  Key key,
  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>[],
})
複製程式碼

3.5.1 屬性解析

direction:主軸(mainAxis)的方向,預設為水平。

alignment:主軸方向上的對齊方式,預設為start。

spacing:主軸方向上的間距。

runAlignment:run的對齊方式。run可以理解為新的行或者列,如果是水平方向佈局的話,run可以理解為新的一行。

runSpacing:run的間距。

crossAxisAlignment:交叉軸(crossAxis)方向上的對齊方式。

textDirection:文字方向。

verticalDirection:定義了children擺放順序,預設是down,見Flex相關屬性介紹。

3.5.2 原始碼

我們來看下其佈局程式碼。

第一步,如果第一個child為null,則將其設定為最小尺寸。

RenderBox child = firstChild;
if (child == null) {
  size = constraints.smallest;
  return;
}
複製程式碼

第二步,根據direction、textDirection以及verticalDirection屬性,計算出相關的mainAxis、crossAxis是否需要調整方向,以及主軸方向上的限制。

double mainAxisLimit = 0.0;
bool flipMainAxis = false;
bool flipCrossAxis = false;
switch (direction) {
  case Axis.horizontal:
    childConstraints = new BoxConstraints(maxWidth: constraints.maxWidth);
    mainAxisLimit = constraints.maxWidth;
    if (textDirection == TextDirection.rtl)
      flipMainAxis = true;
    if (verticalDirection == VerticalDirection.up)
      flipCrossAxis = true;
    break;
  case Axis.vertical:
    childConstraints = new BoxConstraints(maxHeight: constraints.maxHeight);
    mainAxisLimit = constraints.maxHeight;
    if (verticalDirection == VerticalDirection.up)
      flipMainAxis = true;
    if (textDirection == TextDirection.rtl)
      flipCrossAxis = true;
    break;
}
複製程式碼

第三步,計算出主軸以及交叉軸的區域大小。

while (child != null) {
  child.layout(childConstraints, parentUsesSize: true);
  final double childMainAxisExtent = _getMainAxisExtent(child);
  final double childCrossAxisExtent = _getCrossAxisExtent(child);
  if (childCount > 0 && runMainAxisExtent + spacing + childMainAxisExtent > mainAxisLimit) {
    mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
    crossAxisExtent += runCrossAxisExtent;
    if (runMetrics.isNotEmpty)
      crossAxisExtent += runSpacing;
    runMetrics.add(new _RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
    runMainAxisExtent = 0.0;
    runCrossAxisExtent = 0.0;
    childCount = 0;
  }
  runMainAxisExtent += childMainAxisExtent;
  if (childCount > 0)
    runMainAxisExtent += spacing;
  runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
  childCount += 1;
  final WrapParentData childParentData = child.parentData;
  childParentData._runIndex = runMetrics.length;
  child = childParentData.nextSibling;
}
複製程式碼

第四步,根據direction設定Wrap的尺寸。

switch (direction) {
  case Axis.horizontal:
    size = constraints.constrain(new Size(mainAxisExtent, crossAxisExtent));
    containerMainAxisExtent = size.width;
    containerCrossAxisExtent = size.height;
    break;
  case Axis.vertical:
    size = constraints.constrain(new Size(crossAxisExtent, mainAxisExtent));
    containerMainAxisExtent = size.height;
    containerCrossAxisExtent = size.width;
    break;
}
複製程式碼

第五步,根據runAlignment計算出每一個run之間的距離,幾種屬性的差異,之前文章介紹過,在此就不做詳細闡述。

final double crossAxisFreeSpace = math.max(0.0, containerCrossAxisExtent - crossAxisExtent);
double runLeadingSpace = 0.0;
double runBetweenSpace = 0.0;
switch (runAlignment) {
  case WrapAlignment.start:
    break;
  case WrapAlignment.end:
    runLeadingSpace = crossAxisFreeSpace;
    break;
  case WrapAlignment.center:
    runLeadingSpace = crossAxisFreeSpace / 2.0;
    break;
  case WrapAlignment.spaceBetween:
    runBetweenSpace = runCount > 1 ? crossAxisFreeSpace / (runCount - 1) : 0.0;
    break;
  case WrapAlignment.spaceAround:
    runBetweenSpace = crossAxisFreeSpace / runCount;
    runLeadingSpace = runBetweenSpace / 2.0;
    break;
  case WrapAlignment.spaceEvenly:
    runBetweenSpace = crossAxisFreeSpace / (runCount + 1);
    runLeadingSpace = runBetweenSpace;
    break;
}
複製程式碼

第六步,根據alignment計算出每一個run中child的主軸方向上的間距。

  switch (alignment) {
    case WrapAlignment.start:
      break;
    case WrapAlignment.end:
      childLeadingSpace = mainAxisFreeSpace;
      break;
    case WrapAlignment.center:
      childLeadingSpace = mainAxisFreeSpace / 2.0;
      break;
    case WrapAlignment.spaceBetween:
      childBetweenSpace = childCount > 1 ? mainAxisFreeSpace / (childCount - 1) : 0.0;
      break;
    case WrapAlignment.spaceAround:
      childBetweenSpace = mainAxisFreeSpace / childCount;
      childLeadingSpace = childBetweenSpace / 2.0;
      break;
    case WrapAlignment.spaceEvenly:
      childBetweenSpace = mainAxisFreeSpace / (childCount + 1);
      childLeadingSpace = childBetweenSpace;
      break;
  }
複製程式碼

最後一步,調整child的位置。

  while (child != null) {
    final WrapParentData childParentData = child.parentData;
    if (childParentData._runIndex != i)
      break;
    final double childMainAxisExtent = _getMainAxisExtent(child);
    final double childCrossAxisExtent = _getCrossAxisExtent(child);
    final double childCrossAxisOffset = _getChildCrossAxisOffset(flipCrossAxis, runCrossAxisExtent, childCrossAxisExtent);
    if (flipMainAxis)
      childMainPosition -= childMainAxisExtent;
    childParentData.offset = _getOffset(childMainPosition, crossAxisOffset + childCrossAxisOffset);
    if (flipMainAxis)
      childMainPosition -= childBetweenSpace;
    else
      childMainPosition += childMainAxisExtent + childBetweenSpace;
    child = childParentData.nextSibling;
  }

  if (flipCrossAxis)
    crossAxisOffset -= runBetweenSpace;
  else
    crossAxisOffset += runCrossAxisExtent + runBetweenSpace;
複製程式碼

我們大致梳理一下佈局的流程。

  • 如果第一個child為null,則將Wrap設定為最小尺寸,佈局結束;
  • 根據direction、textDirection以及verticalDirection屬性,計算出mainAxis、crossAxis是否需要調整方向;
  • 計算出主軸以及交叉軸的區域大小;
  • 根據direction設定Wrap的尺寸;
  • 根據runAlignment計算出每一個run之間的距離;
  • 根據alignment計算出每一個run中child的主軸方向上的間距
  • 調整每一個child的位置。

3.6 使用場景

對於一些需要按寬度或者高度,讓child自動換行佈局的場景,可以使用,但是Wrap可以滿足的場景,Flow一定可以實現,只不過會複雜很多,但是相對的會靈活以及高效很多。

4. 後話

筆者建了一個Flutter學習相關的專案,Github地址,裡面包含了筆者寫的關於Flutter學習相關的一些文章,會定期更新,也會上傳一些學習Demo,歡迎大家關注。

5. 參考

  1. Flow class
  2. Table class
  3. Wrap class

相關文章