最近在用 Flutter 做一个日语类 App,需要用到上面这种展示效果。HTML 的 ruby
标签可以达到这个目的,可惜 Flutter 不行,只能自己动手实现。
我一开始想得比较简单,不就是一个 Row
或者 Wrap
里面嵌入许多 Column
吗,直到读完 RubyText 的源码,我才发现是我肤浅了。
如上图所示,日语中的汉字和它的读音跟中文的汉字不一样,不是以字为单位,也就是说一个汉字可能对应多个假名,一个假名也可能对应多个汉字(比较少见),自然就会带来一个问题:汉字和假名,上下两行,一个长一个短,单纯用 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
用于 textrubyStyle
用于 rubytextDirection
表示文本方向
然后,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
:
我们来逐行分析一下 build
方法的内部实现逻辑:
final defaultTextStyle = DefaultTextStyle.of(context).style;
final boldTextOverride = MediaQuery.boldTextOverride(context);
defaultTextStyle
值来自 DefaultTextStyle
:
意思是,如果没有给后代 Text widgets 指定 style,Flutter 将会默认使用 DefaultTextStyle
,这个值肯定也是通过父节点一级一级计算出来的。
继续往下分析,这里用到了 flutter hooks 的 useMemorized
:
final result = useMemoized(
() {
//...
},
[defaultTextStyle, boldTextOverride, data],,
)
通过注释可以知道,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
用于判断父节点是否设置了加粗
- 第1行的
-
然后计算
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