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)。

内边距:padding / paddingSymmetric / paddingOnly / paddingInsets

Modifier().padding(16)                                  // 四边等距
Modifier().paddingSymmetric(horizontal: 16, vertical: 8)
Modifier().paddingOnly(left: 4, bottom: 12)
Modifier().paddingInsets(const EdgeInsetsDirectional.only(start: 8))  // 逃生口

同样是 0.5.0 把 padding(dynamic) 拆开的结果——原来传 num 以外的非 EdgeInsetsGeometry 值(比如字符串 '16')要跑到运行时才抛 ArgumentError。

背景:background / gradient / decoration 三选一

三个方法按类型分开,传错东西在 flutter analyze 阶段就报错:

Modifier().background(Colors.white, borderRadius: BorderRadius.circular(12))  // 纯色
Modifier().gradient(const LinearGradient(colors: [...]), borderRadius: ...)   // 渐变
Modifier().decoration(const ShapeDecoration(shape: StadiumBorder(), ...))     // 自定义

0.5.0 之前 background() 收 dynamic,传 LinearGradient 编译期毫无提示、跑到运行时才抛 ArgumentError —— 这是一个真实踩过的坑,也是这次拆成三个方法的原因。

expanded() / flexible()

链条里 expanded()/flexible() 写在哪个位置都可以,applyTo 会把生成的 Flexible 提到最外层(0.5.0 起;此前写在后面会触发 Flutter 的 "Incorrect use of ParentDataWidget")。 一条链里只能声明一次,重复声明会命中 assert。

// 以下两种写法等价,都生效
Modifier().expanded().padding(16)
Modifier().padding(16).expanded()

但 Modifier 级的 flex 只管到 Box 自己。 如果 Box 外面还套了一层你自己的 Widget (贴纸壳、Card、Stack…),Flexible 就被关在壳里了 —— 而且当壳内部恰好有 Row/Column 时, 它会被那一层无声认领:不报错,只是外层 Row 的真实子节点没有约束,表现为文字被裁、 卡片不铺满。这种时候用 Widget 级扩展,把 flex 写在最外面那层之后:

Row(children: [
  Box(modifier: const Modifier().padding(8), child: const Text('按钮'))
      .stickerBox()    // 自己的外壳扩展
      .expanded(),     // ✅ flex 落在 Row 的真实子节点上
]);

// ❌ Flexible 被 stickerBox 内部的 Column 认领,Row 的子节点没有 flex
Box(modifier: const Modifier().expanded(), child: ...).stickerBox()

Widget.expanded({int flex = 1}) / Widget.flexible({int flex = 1}) 就是 Expanded(...) / Flexible(...) 的等价包装。

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 的差异

  1. 需要 .value:Dart 没有 Vue 编译期的 <script setup> 解包魔法,读写 ref/computed 永远要显式写 .value。
  2. 没有 reactive():对象/集合没有自动深度代理,把状态拆成多个 ref 字段放进一个 class (store 模式)即可,不要期望给普通对象加 reactive() 就变响应式。
  3. 微任务批处理只影响”重跑/重建”,不影响 .value 本身:watchEffect 重跑、 Watch/ReactiveWidget 的 Widget 重建是微任务批处理的——同一轮同步代码里连续写多次, 只会在下一个微任务冲刷时合并执行一次,需要 await nextTick() 才能等到重跑/重建生效。 但 Ref/Computed 的 .value 直接读永远是最新值:Ref 写入是同步的,Computed 在被读取时会同步惰性重算,不需要也不受 nextTick() 影响。
  4. 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)各做了检查,帮助尽早定位用错的地方:

  1. 重名断言:同一个组件的 slots 列表里对同一个槽名传了两个 SlotContent, SlotHost 会触发 assert("Duplicate SlotContent for slot ...")——每个槽名每次只能填一份内容。
  2. 无 Host 断言:Slot/ScopedSlot 在没有被 SlotHost 包裹的子树里使用会触发 assert ("... was used outside of a SlotHost.")——组件作者必须先用 SlotHost 包裹布局。
  3. 形态错配(FlutterError):槽的“形态”对不上会在运行期抛错—— Slot 匹配到了 SlotContent.builder(作用域槽)会报错提示改用 ScopedSlot<T>; 反过来 ScopedSlot 匹配到了普通 SlotContent(child: ...) 也会报错提示改用 Slot。
  4. 泛型不匹配(FlutterError):ScopedSlot<T> 匹配到的 SlotContent.builder<U> 若 T != U,会抛出类型不匹配的 FlutterError,避免运行期把数据错误地传给不兼容的渲染函数。

注意事项

  1. 默认槽内容数量在 1 与多个之间变化时,渲染结构会在“直接渲染”与“包 Column”间切换, 子树 State 会丢失;需要保 State 时给内容加 GlobalKey。
  2. 槽内容在消费它的组件的树位置构建:内容里再写 Slot/ScopedSlot 引用的是那个 组件自己的槽表,不能向外层组件转发(与 Vue 的父作用域语义不同)。
  3. 无 SlotHost 时:debug 模式触发断言;release 模式静默降级为 fallback/SizedBox.shrink()。
  4. 同一具名槽可被多个 Slot 占位处渲染(每处各构建一次),因此槽内容里不要使用 GlobalKey。
  5. 作用域槽必须写显式泛型: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(否则会打断正在拼字的输入法),而是:
    1. 记录 composing 开始前的文本快照,并挂起(暂存)composing 期间到达的外部新值;
    2. composing 结束时对账:如果用户最终提交的文本 不同于 composing 前的快照 (即用户确实输入了内容)——用户输入胜出,丢弃挂起的外部值,正常写回 model;
    3. 如果用户最终文本 等于 composing 前的快照(即用户取消/清空了本次输入,等效于什么都没打)—— 挂起的外部值胜出,此时才把它应用到 controller 并同步到 model。

已支持的 Modifier

padding/paddingSymmetric/paddingOnly/paddingInsets · size/width/height · align/center · background/gradient(均可选 borderRadius)/decoration · borderRadius(裁剪) · border · opacity · visible · show/showWhen · onTap · onLongPress · expanded/flexible

Widget 级扩展:.modifier(m) · .expanded()/.flexible() · num.gapH/num.gapW

Libraries

lilili_ui_core