lilili_ui_core 0.4.1
lilili_ui_core: ^0.4.1 copied to clipboard
Fluent Modifier/Box chaining, Vue-inspired reactive state (ref/computed/watch), slots (SlotHost/Slot/ScopedSlot), and directives (v-if/v-show/v-model) helpers for flattening Flutter widget trees.
lilili_ui_core #
用链式 Modifier + Box 容器和 Vue 风格的 wgtIf/wgtFor 语法糖,
把深层嵌套的 Flutter Widget 树拍平成可读的链式写法。
对比示例 #
旧写法:
GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Align(
alignment: Alignment.centerLeft,
child: const Text('Hello'),
),
),
),
)
新写法:
const Text('Hello').modifier(
Modifier()
.onTap(() {})
.background(Colors.blue.shade50, borderRadius: BorderRadius.circular(12))
.padding(16)
.align(Alignment.centerLeft),
)
onTap 应最先声明(最外层)以覆盖整个装饰与内边距区域。
Modifier 应用顺序 #
先声明的修饰项在外层,后声明的在内层:
Modifier().background(Colors.red).padding(16)
// 等价于:
Container(color: Colors.red, child: Padding(padding: EdgeInsets.all(16), child: ...))
border() 与 background() 的层叠 #
border() 的描边画在 child 之上(等价于 foregroundDecoration),background() 画在 child 之下。
两者顺序任意组合都不会互相遮挡:
// ✅ 描边可见
Modifier().border(color: Colors.grey, width: 1).background(Colors.white)
这是 0.4.1 修复的一个真实故障:此前 border() 和 background() 都用普通的
DecoratedBox(背景层)。按上面的顺序写时,内层的背景色矩形与外层描边尺寸完全一致
——DecoratedBox 不像 Container 会按边框宽度给 child 加 padding,而 Border.all 默认
画在框线内侧——于是背景把整圈描边刷掉。描边静默失效,flutter analyze 也不会提示。
注意描边不占布局空间:它覆盖 child 边缘 width 逻辑像素。需要内容避开描边时自己补
.padding(width)。
expanded() / flexible() 的注意事项 #
expanded()/flexible() 必须是链条中最先声明的修饰项,否则不会对 Row/Column 的布局生效:
// ✅ 生效
Modifier().expanded().padding(16)
// ❌ 不生效(expanded 被 padding 产生的 RenderObject 挡在里面),
// 会触发 Flutter 的 ParentDataWidget 误用报错("Incorrect use of
// ParentDataWidget"),而不是静默失效
Modifier().padding(16).expanded()
wgtIf / wgtFor #
Column(
children: [
wgtIf(isLoggedIn, UserPanel()),
...wgtFor(items, (item, i) => ItemTile(item)),
],
)
响应式状态 #
从零实现、零依赖的 Vue 风格响应式系统:ref/computed 自动追踪依赖,
更新通过微任务批处理冲刷,配合 Watch/ReactiveWidget 驱动界面重建。
ref / computed #
final count = ref(0);
final doubled = computed(() => count.value * 2);
count.value++; // 读写都要经过 .value
print(doubled.value); // 2
computed 是惰性缓存的:依赖变化时只做失效标记并向下游传播,真正的重算推迟到下次读取(.value)时;被标记为"可能变了"的节点会先做一次依赖版本校验,未变则零成本转回干净状态。
watchEffect / watch / nextTick #
// watchEffect:立即执行一次,之后依赖变化会自动重跑
final n = ref(0);
final log = <int>[];
final stop = watchEffect(() {
log.add(n.value);
});
print(log); // [0]:立即执行了一次
// watch:监听某个 ref/computed 或 getter,拿到新旧值回调,默认不立即执行
final stopWatch = watch<int>(n, (newValue, oldValue) {
print('n: $oldValue -> $newValue');
});
// 同一轮同步代码里连续写多次,watchEffect 只会在冲刷时重跑一次(微任务批处理):
n.value = 1;
n.value = 2;
n.value = 3;
print(log); // 仍是 [0]:本轮重跑还没冲刷
await nextTick();
print(log); // [0, 3]:冲刷完成后只多了一条,值是最终值
stop(); // 停止 watchEffect
stopWatch(); // 停止 watch
Watch vs ReactiveWidget #
-
Watch:包一小段builder,只有 builder 内读到的依赖变化时才重建这一小块子树, 适合页面里只有局部数据是响应式的场景(细粒度、性能更好)。Watch(() => Text('count: ${count.value}')) -
ReactiveWidget:替代StatelessWidget作为整个组件的基类,build里读到的任何依赖变化 都会让整个 Widget 重建,写法上更省事,适合整页/整个组件都依赖响应式状态的场景。class CounterPage extends ReactiveWidget { const CounterPage({super.key}); @override Widget build(BuildContext context) => Text('count: ${count.value}'); }
store 模式 #
没有 Vue 的 reactive(),用一个普通 class 把相关的 ref/computed 组织起来即可:
class CounterStore {
final count = ref(0);
late final doubled = computed(() => count.value * 2);
}
final store = CounterStore();
store.count.value++;
与 Vue 的差异 #
- 需要
.value:Dart 没有 Vue 编译期的<script setup>解包魔法,读写ref/computed永远要显式写.value。 - 没有
reactive():对象/集合没有自动深度代理,把状态拆成多个ref字段放进一个 class (store 模式)即可,不要期望给普通对象加reactive()就变响应式。 - 微任务批处理只影响”重跑/重建”,不影响
.value本身:watchEffect重跑、Watch/ReactiveWidget的 Widget 重建是微任务批处理的——同一轮同步代码里连续写多次, 只会在下一个微任务冲刷时合并执行一次,需要await nextTick()才能等到重跑/重建生效。 但Ref/Computed的.value直接读永远是最新值:Ref写入是同步的,Computed在被读取时会同步惰性重算,不需要也不受nextTick()影响。 - computed 的 getter 若抛出异常:异常会在微任务冲刷阶段(effect 重跑/Widget 重建时)或下一次
.value读取时浮出,而不是在写入ref.value的语句处同步抛出;需要全局兜底时用runZonedGuarded或PlatformDispatcher.onError。
插槽 #
Vue 风格的插槽系统:使用方通过 SlotContent/SlotContent.builder<T> 往组件里“塞内容”,
组件作者通过 SlotHost + Slot/ScopedSlot<T> 声明“内容放哪”,两边通过槽名匹配,互不感知实现细节。
使用方视角:填槽 #
组件通常提供一个 slots: List<Widget> 参数。往里面塞 SlotContent(具名槽)、
普通 Widget(默认槽)、SlotContent.builder<T>(作用域槽):
DemoCard(slots: [
// 具名槽:对应组件内部的 Slot('header')
const SlotContent(
name: 'header',
child: Text('自定义标题 🎉', style: TextStyle(fontWeight: FontWeight.bold)),
),
// 默认槽:不用 SlotContent 包裹,直接放 Widget,对应组件内部的 Slot.def()
const Text('这行进默认槽'),
// 作用域槽:组件会反向传数据(这里是 String 类型的 fruit)回来,由使用方决定怎么渲染
SlotContent.builder<String>('fruit', (context, f) => Text('🍎 $f(作用域槽渲染)')),
])
不传 slots(或传空列表)时,组件会渲染各个槽自带的 fallback。
组件作者视角:声明槽位 #
组件内部用 SlotHost 包裹布局,用 Slot/ScopedSlot<T> 标记每个槽渲染的位置
(以下是 example 中 DemoCard 的精简版):
class DemoCard extends StatelessWidget {
const DemoCard({super.key, this.slots = const []});
final List<Widget> slots;
@override
Widget build(BuildContext context) {
return SlotHost(
slots: slots,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 具名槽,带 fallback:没人填 header 时显示默认标题
const Slot('header',
fallback: Text('默认标题', style: TextStyle(fontWeight: FontWeight.bold))),
// 默认槽:接收未被 SlotContent 包裹的内容
const Slot.def(),
// 作用域槽:把 fruit 数据传给使用方注册的渲染函数
ScopedSlot<String>('fruit',
data: '苹果', fallback: (context, f) => Text('- $f')),
],
),
);
}
}
Slot(name)/Slot(name, fallback: ...):具名槽占位,name为null等价默认槽。Slot.def()/Slot.def(fallback: ...):默认槽占位的快捷写法(等价Slot(null))。ScopedSlot<T>(name, data: ..., fallback: ...):作用域槽占位,把data反向传给 使用方通过SlotContent.builder<T>注册的渲染函数。
与 Vue slot 的对应关系 #
| Vue | lilili_ui_core |
|---|---|
<slot name="x">(组件内声明具名槽) |
Slot('x') |
v-slot:x(使用方填具名槽) |
SlotContent(name: 'x', child: ...) |
| scoped slot(作用域槽) | 组件内 ScopedSlot<T>('x', data: ...) + 使用方 SlotContent.builder<T>('x', (context, data) => ...) |
| default slot(默认槽) | 组件内 Slot.def() + 使用方不用 SlotContent 包裹、直接放 Widget |
错误路径 #
插槽系统在开发期(assert)和运行期(FlutterError)各做了检查,帮助尽早定位用错的地方:
- 重名断言:同一个组件的
slots列表里对同一个槽名传了两个SlotContent,SlotHost会触发 assert("Duplicate SlotContent for slot ...")——每个槽名每次只能填一份内容。 - 无 Host 断言:
Slot/ScopedSlot在没有被SlotHost包裹的子树里使用会触发 assert ("... was used outside of a SlotHost.")——组件作者必须先用SlotHost包裹布局。 - 形态错配(
FlutterError):槽的“形态”对不上会在运行期抛错——Slot匹配到了SlotContent.builder(作用域槽)会报错提示改用ScopedSlot<T>; 反过来ScopedSlot匹配到了普通SlotContent(child: ...)也会报错提示改用Slot。 - 泛型不匹配(
FlutterError):ScopedSlot<T>匹配到的SlotContent.builder<U>若T != U,会抛出类型不匹配的FlutterError,避免运行期把数据错误地传给不兼容的渲染函数。
注意事项 #
- 默认槽内容数量在 1 与多个之间变化时,渲染结构会在“直接渲染”与“包 Column”间切换,
子树 State 会丢失;需要保 State 时给内容加
GlobalKey。 - 槽内容在消费它的组件的树位置构建:内容里再写
Slot/ScopedSlot引用的是那个 组件自己的槽表,不能向外层组件转发(与 Vue 的父作用域语义不同)。 - 无
SlotHost时:debug 模式触发断言;release 模式静默降级为fallback/SizedBox.shrink()。 - 同一具名槽可被多个
Slot占位处渲染(每处各构建一次),因此槽内容里不要使用GlobalKey。 - 作用域槽必须写显式泛型:
SlotContent.builder<String>(...)——省略时T推断为Object?,会导致ScopedSlot<T>类型不匹配报错。
指令 #
Vue 风格的常用指令简写:条件渲染/隐藏的三种方式,以及 v-model 双向绑定组件族。
三种"隐藏"方式怎么选 #
| 方式 | 是否占布局空间 | State 是否保留 | 适用场景 |
|---|---|---|---|
wgtIf(condition, child) |
否(不渲染,返回 fallback,默认 SizedBox.shrink()) |
否(child 被销毁) | 条件完全不存在时——如未登录不渲染用户面板 |
Modifier().visible(bool) |
否(替换为 SizedBox.shrink()) |
否(child 被销毁) | 链式写法里临时按布尔值整体移除某段 UI,且不关心切回来后 State 是否保留 |
Modifier().show(bool) / .showWhen(ReadonlyRef<bool>) |
否(Offstage 只是不绘制/不参与命中测试,不占布局空间) |
是(child 用 Offstage 包裹,不销毁) |
需要频繁切换显隐、且切回来要保留滚动位置/输入内容/动画状态等 State 的场景(真正的 v-show 语义) |
wgtIf/visible() 本质都是"渲染 A 还是渲染 B",条件为假时那棵子树整个从 Element 树上摘掉,
State 随之销毁重建;show()/showWhen() 用 Offstage 把子树留在树上,只是跳过绘制与命中测试,
State 全程存活,但同样不占布局空间(Offstage 在布局阶段把子树尺寸视为零)。
三者辨析:wgtIf(销毁子树、不占位)、visible()(销毁子树、不占位)、show()/showWhen()
(保留子树 State、不占位)——show/visible 的区别只在于State 是否保留,与是否占位无关
(三者都不占布局空间)。
// wgtIf:条件渲染,销毁式
wgtIf(isLoggedIn, const UserPanel())
// visible():链式写法里的销毁式隐藏,占位等价于 SizedBox.shrink()
someWidget.modifier(Modifier().visible(showPanel))
// show():Offstage,保留 State,静态布尔值
someWidget.modifier(Modifier().show(showPanel))
showWhen + Computed #
showWhen 接受 ReadonlyRef<bool>(Ref<bool> 或 Computed<bool> 都可以),随依赖变化自动切换,
不需要手动包 Watch:
final query = ref('');
final hasResults = computed(() => query.value.isNotEmpty);
SearchResultsPanel().modifier(
Modifier().showWhen(hasResults), // query 变化 -> hasResults 重算 -> 面板自动显隐,State 保留
)
v-model:六个双向绑定组件 #
全部要求 model 是 Ref<T>(可写),内部用 Watch 订阅并在交互回调里写回 model.value。
// VTextField:内管 controller,不接受外部 controller
final name = ref('');
VTextField(model: name, decoration: const InputDecoration(labelText: '姓名'))
// VCheckbox
final agreed = ref(false);
VCheckbox(model: agreed)
// VSwitch:对外参数名是 activeColor(内部映射到 Switch 的 activeThumbColor)
final enabled = ref(true);
VSwitch(model: enabled, activeColor: Colors.green)
// VSlider
final volume = ref(0.5);
VSlider(model: volume, min: 0, max: 1)
// VRadio<T>:多个 VRadio 共享同一个 model,选中态 = model.value == value
final size = ref('M');
Column(children: [
VRadio<String>(value: 'S', model: size),
VRadio<String>(value: 'M', model: size),
VRadio<String>(value: 'L', model: size),
])
// VDropdown<T>:items 直接传数据列表,itemLabel/itemBuilder 可选
final fruit = ref('apple');
VDropdown<String>(
model: fruit,
items: const ['apple', 'banana', 'cherry'],
itemLabel: (item) => item.toUpperCase(), // 可选,不传则用 toString()
)
VRadio 已知局限:内部用自定义 RadioGroupRegistry no-op shim 实现(而非原生
RadioGroup Widget)——分组互斥依赖同一组 VRadio 共享同一个 model Ref;registry
为 no-op shim,不支持键盘方向键在组内的原生遍历语义。
VDropdown 注意事项:model.value 必须始终存在于 items 中,否则会触发
DropdownButton 的断言——异步加载 items(如网络请求返回选项列表)时要特别注意这一点,
避免 model.value 短暂指向一个尚未在 items 里出现的值。
VSlider 注意事项:显示值会被 clamp 到 [min, max];model 超出该范围时会在
下一帧被自动回写为 clamp 后的值(无需用户拖动滑块),因此 UI 与 model 只会在
当前帧内短暂不一致。
VTextField 设计取舍 #
- 不接受外部
controller:TextEditingController的生命周期完全由VTextField内部管理 (initState创建、dispose释放),组件不暴露controller参数。需要外部持有/操作 controller(比如手动选中文本、监听光标)时,请用原生TextField自行搭配watch绑定, 不要期望VTextField支持这种用法。 model必须是Ref<String>,不能是Computed<String>:VTextField会在用户输入时把 文本写回model.value(widget.model.value = _controller.text),而Computed是只读的 (Computed<T>实现ReadonlyRef<T>但不实现Ref<T>),传Computed会在编译期就报类型错误—— 这是刻意的设计取舍,而不是遗漏:v-model 天然双向,只读的派生值不构成合法的绑定目标。- IME composing 期间的对账策略:中文/日文等输入法组词过程中(
TextEditingValue.composing为有效且非折叠区间),VTextField不会把半成品文本写回model,也不会把 composing 期间到达的 外部model更新立刻应用到controller(否则会打断正在拼字的输入法),而是:- 记录 composing 开始前的文本快照,并挂起(暂存)composing 期间到达的外部新值;
- composing 结束时对账:如果用户最终提交的文本 不同于 composing 前的快照
(即用户确实输入了内容)——用户输入胜出,丢弃挂起的外部值,正常写回
model; - 如果用户最终文本 等于 composing 前的快照(即用户取消/清空了本次输入,等效于什么都没打)——
挂起的外部值胜出,此时才把它应用到
controller并同步到model。
已支持的 Modifier #
padding · size/width/height · align/center · background(可选 borderRadius)·
borderRadius(裁剪) · border · opacity · visible · show/showWhen · onTap · onLongPress ·
expanded/flexible