Flutter Widgets 之 RubyText_ide


最近在用 Flutter 做一个日语类 App,需要用到上面这种展示效果。HTML 的 ​​ruby​​​ 标签可以达到这个目的,可惜 Flutter 不行,只能自己动手实现。我一开始想得比较简单,不就是一个 ​​Row​​​ 或者 ​​Wrap​​​ 里面嵌入许多 ​​Column​​ 吗,直到读完 RubyText 的源码,我才发现是我肤浅了。

Flutter Widgets 之 RubyText_ios_02


如上图所示,日语中的汉字和它的读音跟中文的汉字不一样,不是以字为单位,也就是说一个汉字可能对应多个假名,一个假名也可能对应多个汉字(比较少见),自然就会带来一个问题:汉字和假名,上下两行,一个长一个短,单纯用 Column 布局不好看。

那怎么办呢?一起来看下 RubyText 的实现方式:

源码地址:​​https://github.com/YeungKC/RubyText​

用法

RubyText(
[
RubyTextData(
'検査',
ruby: 'けんさ',
),
],
);

分成上下两行的文本,上面一行叫 ruby,下面一行叫 text。

​RubyText​​ 要做的事情就是对 ruby 和 text 进行排版,使上下两行看起来比较和谐。

对于一个带 ruby 的 text:

  • 如果 ruby 长度大于 text,那么需要加大 text 的 letter space,使上下两行左右对齐
  • 反之,则需要增加 ruby 的 letter space

对于不带 ruby 的 text,则无需计算。


先从 ​​RubyTextData​​ 开始分析:

const RubyTextData(
this.text, {
this.ruby,
this.style,
this.rubyStyle,
this.textDirection = TextDirection.rtl,
});
  • ​text​​ 必传,表示文本,比如上例中的【検査】
  • ​ruby​​ 非必需,表示振り仮名,有些字无需 ruby,所以可以为空
  • ​style​​ 用于 text
  • ​rubyStyle​​ 用于 ruby
  • ​textDirection​​ 表示文本方向

然后,RubyTextData 作为数组传给 ​​RubyText​​:

class RubyText extends StatelessWidget {
const RubyText(
this.data, {
Key? key,
this.spacing = 0.0,
this.style,
this.rubyStyle,
this.textAlign,
this.textDirection,
this.softWrap,
this.overflow,
this.maxLines,
}) : super(key: key);

final List<RubyTextData> data;
}

剩余参数可以对照 Flutter 自带的 ​​Text​​ widget.

重点分析一下 ​​RubyText​​​ 拿到 ​​List<RubyTextData>​​ 后的处理流程:

  • 首先将 ​​RubyTextData​​​ 映射成一个 ​​WidgetSpan​
  • 然后用 ​​Text.rich​​ 构造出 Text Widget

这个流程中比较特殊的地方是 ​​WidgetSpan​​​ 的 child 是一个 ​​RubySpanWidget​​:

class RubySpanWidget extends HookWidget {
const RubySpanWidget(this.data, {Key? key}) : super(key: key);
final RubyTextData data;
}

其中一个 ​​RubySpanWidget​​​ 对应一个 ​​RubyTextData​​:

注意,HookWidget 不是 flutter 自带的,是一个三方 package => ​​flutter_hooks​

我们来逐行分析一下 ​​build​​ 方法的内部实现逻辑:

final defaultTextStyle = DefaultTextStyle.of(context).style;
final boldTextOverride = MediaQuery.boldTextOverride(context);

​defaultTextStyle​​​ 值来自 ​​DefaultTextStyle​​:

The text style to apply to descendant Text widgets which don’t have an explicit style.

意思是,如果没有给后代 Text widgets 指定 style,Flutter 将会默认使用 ​​DefaultTextStyle​​,这个值肯定也是通过父节点一级一级计算出来的。

继续往下分析,这里用到了 flutter hooks 的 ​​useMemorized​​:

final result = useMemoized(
() {
//...
},
[defaultTextStyle, boldTextOverride, data],,
)

Caches the instance of a complex object.

useMemoized will immediately call valueBuilder on first call and store its result. Later, when the HookWidget rebuilds, the call to useMemoized will return the previously created instance without calling valueBuilder.

A subsequent call of useMemoized with different keys will call useMemoized again to create a new instance.

通过注释可以知道,​​useMemorized​​ 用于缓存比较复杂的对象,如果 keys 不发生变化,所缓存的复杂对象就不会被重新计算。

T useMemoized<T>(
T Function() valueBuilder, [
List<Object?> keys = const <Object>[],
]) {
return use(
_MemoizedHook(
valueBuilder,
keys: keys,
),
);
}

通过以上源码可以看出,​​useMemoized​​ 有两个参数:

  • valueBuilder => 高阶函数,用于计算
  • keys => 计算的输入值。

了解 FP(函数式编程)的朋友可能知道,在 FP 的世界中,function 没有 side effects,一个 input 对应一个 output。

具体到这里的​​useMemoized​​,也是一样的道理。

它的作用是根据 keys(​​[defaultTextStyle, boldTextOverride, data]​​)计算出 result,只要 keys 不发生变化,计算过程就不会重复执行。

我们具体看一下计算过程:

  • 首先是计算 textStyle:
var effectiveTextStyle = data.style;
if (effectiveTextStyle == null || effectiveTextStyle.inherit) {
effectiveTextStyle = defaultTextStyle.merge(effectiveTextStyle);
}
if (boldTextOverride) {
effectiveTextStyle = effectiveTextStyle
.merge(const TextStyle(fontWeight: FontWeight.bold));
}
assert(effectiveTextStyle.fontSize != null, 'must be has a font size.');
  • 第1行的 ​​data​​​ 是 ​​RubyTextData​
  • 第2行的 ​​.inherit​​​ 表示是否继承父节点的 style,比如 ​​TextSpan​
  • 第5行的 ​​boldTextOverride​​ 用于判断父节点是否设置了加粗
  • 然后计算 ​​rubyTextStyle​​:
final defaultRubyTextStyle = effectiveTextStyle.merge(
TextStyle(fontSize: effectiveTextStyle.fontSize! / 1.5),
);

// ruby text style
var effectiveRubyTextStyle = data.rubyStyle;
if (effectiveRubyTextStyle == null || effectiveRubyTextStyle.inherit) {
effectiveRubyTextStyle =
defaultRubyTextStyle.merge(effectiveRubyTextStyle);
}
if (boldTextOverride) {
effectiveRubyTextStyle = effectiveRubyTextStyle
.merge(const TextStyle(fontWeight: FontWeight.bold));
}
  • rubyText 的 fontSize 是 text 的 2/3

这两个 styles 计算出来之后,为了使 ruby 和 text 上下两行左右对齐,就可以进一步计算出 letter space 了。

如果 ruby 或 text 为空或者只有一个字符,则不需要计算,因为单个字符不存在 letter space。

计算的逻辑比较简单:

  • 分别计算出 ruby 和 text 的宽度
  • 然后计算两个宽度的差值
  • 如果 ruby 宽度小于 text,则将差值作为 letter space 平均分配到 ruby 中去,反之亦然

其中 ​​_measurementWidth​​ 这个函数比较关键:

double _measurementWidth(
String text,
TextStyle style, {
TextDirection textDirection = TextDirection.rtl,
}) {
final textPainter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: textDirection,
textAlign: TextAlign.center,
)..layout();
return textPainter.width;
}

它的逻辑是用样式(style)和文本(text)构造出一个 ​​TextPainter​​​ 对象,然后调用 ​​layout()​​​ 进行布局,最后通过 ​​width​​ 属性获取宽度值。当然,这一切都是在内存中进行的,并没有渲染出图形。

以上就是 ​​RubyText​​ 的源码分析,逻辑很简单,欢迎评论区交流,也可加 vx: feelang