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;
    });
  2. 解析完成后新增根节点:

    var rootElement = _fromXml(root, null);
    if (_fixedPosition.isNotEmpty) {
      final children = [
     rootElement,
     ..._fixedPosition,
      ];
      rootElement = _AssembleElement('_floatStack', const CSSStyle({}), null, null, children);
      _fixedPosition.clear();
    }
  3. 在创建整个视图时应用这个特殊根节点:
    _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控件的关键就是得明确语义,实现关键呈现,然后取其根本, 放弃一些次要和边缘的效果.


林鹿
21 声望5 粉丝