一个文件,两种形状 — 不知道自己有多长的列表

5 分钟
目标画面课程最后一讲。conditional 让画面分岔,list 在不知道数量的情况下生成行。三条线十六讲在这里接上。

到目前为止,值变了形状不变。真实画面不是这样 — 没人等和三个人等,得显示不同的东西。

分岔的地方

{ "type": "conditional", "condition": "{{empty}}",
  "then":   { "type": "text", "text": "Nobody is waiting", ... },
  "orElse": { "type": "box", ... } }

为真走 then,为假走 orElse。两边都是普通节点,放什么都行。

这里用 {{empty}} 是要点。没有写成 {{waiting == 0}} 这样的表达式。决定条件的一方 是服务器,不是画面 — 「空」的定义一旦改变(比如决定预约不计入),要改的只有服务器。

不知数量的行

{ "type": "list", "items": "{{rows}}",
  "itemTemplate": { "type": "linear", "direction": "horizontal", "distribution": "spaceBetween",
    "children": [
      { "type": "text", "text": "{{item.no}}", ... },
      { "type": "text", "text": "{{item.since}}", ... } ] } }

items 指向一个数组,itemTemplate 是 一行 的形状。有多少行,哪儿都没写。

模板里用 {{item.*}} 取这一行的值,不会和外面的状态串名。

{{rows}} 放三个就三行,放十个就十行。文件仍然是 1,234 字节。

同一个文件渲染两次

这一讲的主张是「一个文件变成两种形状」。一张截图证明不了。

await render('step5', {'empty': true, 'waiting': 0, 'rows': <dynamic>[]},
    as: 'step5_empty', ...);

const rows = [
  {'no': '44', 'since': 'waiting 2 min'},
  {'no': '45', 'since': 'waiting 5 min'},
  {'no': '46', 'since': 'waiting 9 min'},
];
await render('step5', {'empty': false, 'waiting': rows.length, 'rows': rows},
    as: 'step5_filled', ...);

然后数实际搭起来了几行。

final built = [
  for (final r in rows)
    if (find.text(r['no']!).evaluate().isNotEmpty) r['no']!
];
step5_empty   1234 B, 16 lines, type "page" -> step5_empty.png
empty state shows: 1 placeholder, 0 rows
step5_filled  1234 B, 16 lines, type "page" -> step5_filled.png
filled state shows: 0 placeholder, 3 rows (44, 45, 46)

要数,是因为数量错了图也看得过去。三行只画两行,卡片照样显得是满的。

grep -qE '"(44|45|46)"' screens/step5.json && die "step5: the rows are baked in"
grep -q '"itemTemplate"' screens/step5.json || die "step5: no row template"
cmp -s captures/step5_empty.png captures/step5_filled.png && \
  die "step5: the conditional did not change anything"
grep -q 'empty state shows: 1 placeholder, 0 rows' $LOG || \
  die "step5: the empty branch is wrong"
grep -q 'filled state shows: 0 placeholder, 3 rows (44, 45, 46)' $LOG || \
  die "step5: the list did not build one row per item"

把 {{item.no}} 换成 44 再跑,第一行就抓住了。

五讲

step1  233 B, 4 lines, type "page" -> step1.png
step2  906 B, 12 lines, type "page" -> step2.png
step3  one file, two states, two pictures (27577 B vs 24734 B)
step4  pressed -> the host was asked for: queue.next, queue.take
step5  empty state shows: 1 placeholder, 0 rows
       filled state shows: 0 placeholder, 3 rows (44, 45, 46)
live   ui://desk — 546 B, 6 lines, type "page", title "Desk"
       the server now reports {"waiting":2} after one press

5 steps + the live loop · each checked against its own claim · one host, five screens, no widgets written by hand

最后一行就是这条线的要旨。五个画面做五件不同的事,画它们的代码只有一份。第 1 讲之后宿主的渲染部分没变过,多出来的只是 onToolCall 的函数体,和那些把截图看不见的东西记下来的检查行。

三条线接上的地方

course-server   六讲   开工具、拒绝、把画面作为资源送出、变了就通知
course-client   五讲   连上、询问、调用、接收画面、订阅
course-ui       五讲   收到的那个画面变成图

三条线讲的是同一间候诊室。服务器的 desk.admit 就是客户端的 callTool,服务器在 ui://desk 送出的 JSON 和这条线的 screens/*.json 是同一套语法。

这句话对不对,由最后一项检查来定。host/test/live_test.dart 真的启动服务器课程的成品服务器,读 ui://desk — 那是一个不在 screens/ 里的画面 — 原样渲染,再按下那个画面自己带着的 Admit one。

ui://desk — 546 B, 6 lines, type "page", title "Desk"
nothing about this screen is on disk here; it arrived just now
opened with 3 waiting
pressed Admit one
the server now reports {"waiting":2}

按一次,3 变成了 2。前后两张截图不同,检查也一并核对。把服务器的画面拷进这个文件夹,[ -e screens/desk.json ] 就会抓住 — 渲染一份本地副本,这一讲的主张就不成立了。

客户端那条线不用模拟服务器 — 五步全部真的启动服务器课程做的那台服务器再连上去。所以两条线一旦对不上,检查就会抓住。客户端第 3 讲确实抓住过一次,服务器一侧被错误合并的拒绝文案就是那时找出来的。

自己跑一遍

cd content/sample/course-ui
bash verify.sh
open captures/step5_empty.png captures/step5_filled.png

带走的

  1. conditional — 条件用状态接,不要写成表达式
  2. list + itemTemplate — 只写一行的形状
  3. 数行数 — 数量错了图也看得过去

接下来

三条线讲的是同一间候诊室。从下个月起,这块画面要下到人真正站着的地方 — 仓库、交接班、断了网的收银台。

运行示例

makemind-academy/course_ui/
git clone https://github.com/makemind-academy/course_ui
cd course_ui
bash verify.sh
在 GitHub 上打开 →