flutter: CSS規則對映flutter控制元件-position

林鹿 發表於 2021-07-23
Flutter CSS

css龐大而複雜,靈活且繁難, 如何把css的規則對映成flutter的控制元件確實是個不小的挑戰. css有如此多的規則和屬性, 而且還有各種簡寫形式, 無論如何肯定無法實現css的全部效果, 但到底能實現到哪種程度哪些部分還是需要實踐一下的.

css是應用在標籤上的規則, 要實現轉化必須首先解析標籤也就是把文字的結構化資料轉成記憶體的物件資料, html與小程式無一不是這樣.

準備

單純解析標籤問題不大,都有現成的html/xml解析庫; 關鍵是如何解析css, 要實現css規則的各種匹配,後代選擇器,還有聯級與覆蓋之類效果是不現實的, 好在web端有非常多強大現成的工具讓我們挑選. 我們的最終目的是將帶有css屬性的單個節點轉成相應的flutter控制元件, 那麼首先就需要得到單個節點都有哪些css屬性, 如果把css檔案中的各種屬性直接轉化成內聯樣式, 那麼當解析節點的時候我們就知道當前節點對應的所有css屬性了, 所以我們需要找到能把css轉成內聯樣式的庫或者工具. 目標明確了怎麼做其實非常簡單, 隨便一查就有非常多的工具, 有諸如@team-griffin/css-longhand, css-longhand, css-shorthand-expand, css-shorthand-expanders, fela-plugin-expand-shorthand, grunt-css-longhand, inline-style-expand-shorthand等, 最流行的是juice

所以我們解析的,是經過juice轉化後的html/xml檔案,用以下類來表示單個節點的CSS:

class CSSStyle {
  final Map<String, String> _attrs;

  const CSSStyle(this._attrs);

  String? operator[](String key) => _attrs[key];

  double? _getDouble(String key) => _attrs[key]?.let((it) => double.tryParse(it));

}
複製程式碼

css的作用

css不僅要幫助我們判斷當前節點的控制元件型別,還有一些附加的控制元件如padding/margin,這些屬性對應的控制元件就套在當前節點的控制元件作為父節點, 類似如下:

Widget w = builder.build(element, children);
CSSStyle css = ...;
final padding = css.padding;
if (padding != null) {
  w = Padding(
    padding: padding,
    child: w,
  );
}
final margin = css.margin;
if (margin != null) {
  w = Padding(
    padding: margin,
    child: w,
  );
}
複製程式碼

因為flutter的控制元件非常豐富強大,核心的想法是充分利用現有控制元件的組合; 當然可以像kraken那樣自己繪製控制元件這種紮實的方案, 但需要非常深入的flutter rendering技能, 先不實現.

position屬性解析

開頭的問題就是如何實現position屬性, 詳細的介紹可以看看MDN, 它的值主要有relative, absolute, fixed.static值等於沒有設定值.

relative

這一屬性的含義是好理解的, 實際就是偏移:

final positionCSS = css['position'];
if (positionCSS == 'relative') {
  final left = css._getDouble('left');
  final top = css._getDouble('top');
  final right = css._getDouble('right');
  final bottom = css._getDouble('bottom');
  final dx = left ?? right?.let((it) => -right);
  final dy = top ?? bottom?.let((it) => -bottom);
  if (dx != null || dy != null) {
    w = Transform.translate(
      offset: Offset(
        dx ?? 0,
        dy ?? 0,
      ),
      child: w,
    );
  }
}
複製程式碼

left,top就是dx, dy, right, bottom就是-dx,-dy

absolute

這一屬性也相對好理解, absolute的元素不參與兄弟元素佈局了,它是相對父親節點的偏移, 那這種佈局的效果在Android就是FrameLayout, 在flutter中妥妥對應的Stack呀.

理解不難但實際有點小棘手, 宣告absolute的元素或節點不管它自身對應的是哪種控制元件, 需要它的父節點首先得是一個Stack, 這意味著什麼呢? 這意味著當我們解析一個節點對應的控制元件時必須要考慮其子節點的屬性! 這時已不是一個對應問題而是一個解析問題了. 用以下類來表示一個html/xml中的節點:

class _AssembleElement {
  final String name;
  final CSSStyle style;
  final String? klass;
  final Map<String, String>? extra;
  final List<_AssembleElement> children;

  _AssembleElement(this.name, this.style, this.klass, this.extra, this.children);

  @override
  String toString() {
    return '<$name style="$style" ${extra?.let((it) => 'extra="$extra"') ?? ''}>';
  }
}
複製程式碼

style就是style=""中內聯的css屬性集合,extra表示節點除class, style外的屬性集合, class其實可以免去,但因為可以比較方便的作一些debug操作,單獨拎出來. 於是在我們判斷當前節點應該對應哪個控制元件的地方加入如下邏輯:

Widget build(_AssembleElement e, List<Widget> children) {
  final alignChildren = e.children.where((e) => e.style['position'] == 'absolute');
  if (alignChildren.length > 0) {
    return Stack(
      children: children,
    );
  }
  ...
}
複製程式碼

有了Stack作父節點,當前節點對應是很容易的, 當然是flutter中的Positioned了, 於是在外圍再"套圈":

else if (positionCSS == 'absolute') {
  final left = css._getDouble('left');
  final top = css._getDouble('top');
  final right = css._getDouble('right');
  final bottom = css._getDouble('bottom');
  w = Positioned(
    left: left,
    top: top,
    right: right,
    bottom: bottom,
    child: w,
  );
}
複製程式碼

兩者結合才能生成正確的控制元件物件.

fixed

最麻煩的得是fixed值了, 按文件描述

元素會被移出正常文件流,並不為元素預留空間,而是通過指定元素相對於螢幕視口(viewport)的位置來指定元素位置。

通常都是用在頁面中不隨滾動而一直顯示的檢視, 類似flutter中的FloatingActionButton, 那這樣的效果其實也是Stack形式的, 只不過處理起來需要不同的方式. 因為會被移出正常文件流, 所以解析時需要把節點單獨放置, 而且解析完成後在根節點外再套一個根節點, 可以分為3步:

  1. 當解析遇到屬性宣告成position: fixed的節點時將節點單獨放置(_fixedPosition):
children = elementNodes
    .map((child) => _fromXml(child, ancestorStyle))
    .toList();
children.removeWhere((e) {
  final isFixed = e.style['position'] == 'fixed';
  if (isFixed) {
    _fixedPosition.add(e);
  }
  return isFixed;
});
複製程式碼
  1. 解析完成後新增根節點:
var rootElement = _fromXml(root, null);
if (_fixedPosition.isNotEmpty) {
  final children = [
    rootElement,
    ..._fixedPosition,
  ];
  rootElement = _AssembleElement('_floatStack', const CSSStyle({}), null, null, children);
  _fixedPosition.clear();
}
複製程式碼
  1. 在建立整個檢視時應用這個特殊根節點:

_assembleWidget表示具體做單個節點的檢視建立工作的方法, 0表示深度.

Widget build(BuildContext context, _AssembleElement root) {
  if (root.name == '_floatStack') {
    final children = root.children.map((e) => _assembleWidget(e, 0)).toList(growable: false);
    return Stack(
      children: children,
    );
  }
  return _assembleWidget(root, 0);
}
複製程式碼

而當前節點的生成邏輯其實和absolute沒有兩樣:

else if (positionCSS == 'absolute' || positionCSS == 'fixed') {
...
}
複製程式碼

如此一來我們就能相對完整的實現CSS的position這一屬性的效果了! 歸根結底,轉化成flutter控制元件的關鍵就是得明確語義,實現關鍵呈現,然後取其根本, 放棄一些次要和邊緣的效果.