QORMQORM v0.8.4 docs 开始使用 EN

第一个场景

场景是 QORM 的 UI 入口。一个场景有一个 id 和一个根节点 root;节点树描述 UI。文本内容放在 text 字段里,{{ state.x }} 会插值全局状态。

最小场景

{
  "type": "scene",
  "id": "main",
  "root": { "type": "text", "id": "hello", "text": "Hello QORM" }
}

带布局的场景

容器节点(column / row)使用 style(内边距、间距、背景)和 layout(尺寸、对齐)来排布它们的子节点。

{
  "type": "scene",
  "id": "main",
  "root": {
    "type": "column",
    "id": "root",
    "style":  { "padding": 24, "gap": 12 },
    "layout": { "width": "fill", "height": "fill", "align": "center" },
    "children": [
      { "type": "text",   "id": "title",  "text": "Title" },
      { "type": "button", "id": "submit", "text": "Submit", "onPress": "save" }
    ]
  }
}

列表与条目作用域

listdata 绑定到一个数组,并重复渲染一份 renderItem 模板。在该模板内部, 当前行是 item,它的位置是 index(从 0 开始),外加 first / last 两个标志。

{
  "type": "list", "id": "rows", "data": "{{ state.items }}",
  "renderItem": {
    "type": "row", "children": [
      { "type": "text", "text": "{{ index + 1 }}." },
      { "type": "text", "text": "{{ item.title }}" }
    ]
  }
}

index / first / last 是独立的作用域名字,因此同名的数据字段不会被遮蔽: {{ item.index }} 读的是你的数据,{{ index }} 读的是循环位置。

嵌套列表会在内层重新绑定 item。给外层列表一个 as 别名即可让它保持可达—— "as": "section" 会绑定 section 以及 sectionIndex / sectionFirst / sectionLast:

{
  "type": "list", "data": "{{ state.sections }}", "as": "section",
  "renderItem": {
    "type": "column", "children": [
      { "type": "text", "text": "{{ section.title }}" },
      { "type": "list", "data": "{{ section.rows }}",
        "renderItem": { "type": "text", "text": "{{ section.title }} / {{ item }}" } }
    ]
  }
}

非法或保留的别名会退回 item,并在加载期给出警告。gridview 接受同样的 as 和同样的作用域名字。

值得知道的列表属性

属性作用
separator在条目之间画一条细线——true,或 { "height": 1, "inset": 16, "color": "…" }。绝不画在末项之后
pageSize + page内建分页:只渲染 data 的一个窗口。page 从 1 开始并会钳制到有效范围,页码越界时显示最后一页而不是空白
groupBy + sectionHeader连续相等的键切分为分节并加上分节标题。渲染器绝不重排你的数据——请先排序
sticky + stickyTop滚动时分节标题吸顶(sticky 默认为 true);stickyTop 让它停在 appbar 之下
onRefresh下拉刷新;派发指定的动作
reorderable + onReorder长按拖拽重排
{
  "type": "list", "id": "contacts", "data": "{{ state.contacts }}",
  "groupBy": "letter", "sectionHeader": "{{ item.letter }}", "stickyTop": 44,
  "separator": true, "pageSize": 50, "page": "{{ state.page }}",
  "onRefresh": "reloadContacts",
  "renderItem": { "type": "listtile", "text": "{{ item.name }}" }
}

index 始终相对于完整的 data,而不是当前页,所以序号会跨页连续。groupByreorderable 不会同时生效(分节标题会让拖拽索引错位),而 reorderable 优先于 onRefresh(两者会争抢同一个拖拽手势)。

注意 virtualize 只是一个渲染提示(content-visibility),不是真正的窗口化—— 完整的条目标记仍会输出。当你需要 DOM 本身变小时,请用 pageSize

标签页与面板

tabs 的标签文字来自一个 tabs 数组,面板来自 children,两者按下标配对:

{
  "type": "tabs", "id": "detail",
  "tabs": ["Overview", "Specs", "Reviews"],
  "active": "{{ state.tab }}",
  "scrollable": true, "swipe": true, "lazy": true,
  "indicator": "pill", "indicatorColor": "var(--accent)",
  "onChange": "tabChanged",
  "children": [
    { "type": "text", "id": "t1", "text": "Overview…" },
    { "type": "text", "id": "t2", "text": "Specs…" },
    { "type": "text", "id": "t3", "text": "Reviews…" }
  ]
}
属性作用
active当前打开的是第几个标签页,从 0 开始。越界会钳制回范围内
scrollable横向可滚动的标签条;无论标签页是怎么切换的,当前标签都会自动滚入视野
swipe在面板上横向拖动即切到相邻标签页。它触发的是那个标签自己的控件,所以三种模式下行为一致;两端不循环
lazy只渲染当前面板
indicator · indicatorColor"pill""none"(其它值都是默认的下划线),颜色由 indicatorColor 决定

active 绑定到一个 {{ state.x }} 路径,标签页就变成受控的: 标签条自己写入那个状态键,因此切换标签完全不需要 action 文件,任何绑定同一个键的 节点也会随之联动。像 {{ state.i + 1 }} 这样的表达式不是纯路径,只会渲染成一个 只读的值。

onChange 在每次切换时触发,并额外合入两个参数:index(0 起的位置,字符串) 与 tab(标签文字)。lazy 要求标签页是受控的、或者带有 onChange——两者都没有 时切换纯粹发生在浏览器里,只能显示已经在 DOM 里的面板,因此 lazy 会被忽略。 indicatorColor 要求节点有一个普通标识符形式的 id,并且它的值会经过一张严格的 字符白名单:任何可能提前结束 CSS 声明的内容都会被整体丢弃,而不是原样输出。

单元格里放组件的表格

tabledatatablecolumns 渲染 data。一个列可以是一个键字符串,也可以是 一个带 value(数据键)、label(表头文字)、widthsticky 的对象。

默认单元格就是 obj[key] 的纯文本。给表格一个column 的子节点,该列就改为 渲染你的组件:

{
  "type": "table", "id": "people",
  "data": "{{ state.people }}",
  "columns": [
    { "value": "name", "label": "Name", "width": 180, "sticky": true },
    { "value": "role", "label": "Role" }
  ],
  "stickyHeader": true, "stickyTop": 44,
  "scrollX": true, "maxHeight": 420, "minWidth": 720,
  "children": [
    { "type": "tag", "id": "role", "column": "role", "label": "{{ row.role }}" },
    { "type": "text", "id": "bio", "detail": true, "label": "Details", "text": "{{ row.bio }}" }
  ]
}

在单元格模板内部,当前行是 row,它的位置是 rowIndex / rowFirst / rowLast, 单元格本身是 cell——{{ cell.value }}{{ cell.column }}{{ cell.index }}。 这与列表用的是同一套别名机制,所以 "as": "person" 会把这组名字改成 person / personIndex / personFirst / personLast,外层列表的 item 仍然可以同时读到。

detail: true 的子节点不是列:它在每一行下面渲染一个可展开行,用的是浏览器 自己的折叠控件。它的 label(或 text)是摘要行,在行作用域里求值。

sticky 属于列对象外,其余外观键都写在表格节点上:

效果
stickyHeader + stickyTop表头在表体滚动时固定;stickyTop 把它停在 appbar 下面
scrollX表格在自己的盒子里横向滚动
maxHeight表体在该高度内纵向滚动
minWidth开启 scrollX 时避免列被压扁
列的 sticky横向滚动时冻结该前置列

datatable 在自己的选择、排序属性之外同样支持以上全部。两者都没有内建分页—— 参见第一个动作

手风琴、树、时间线、轮播

组件值得知道的属性
accordionactive 是当前展开面板的下标(可绑定、会钳制、默认第一个)。single: true 让面板互斥——展开一个就收起其余;默认是各自独立开合
treecollapsed: true 让所有分支初始收起。数据项自己的 expanded 字段仍会覆盖它自己那一个节点
timelineitems 的每一项可以带 time(渲染在标题上方)、color(圆点颜色)与 icon(内置 SVG 图标集里的名字,画在圆点里;未知名字退回普通圆点)
carouselautoplay: 4000 按时钟推进(下限 250 毫秒,指针悬停或浏览器标签页隐藏时暂停,到末尾回卷)。indicators: true 渲染一行可点的圆点,当前点由实时滚动位置推导

会折叠的大标题

largetitle(别名 sliverappbarcupertinolargetitle)绘制 iOS 大标题头,并且 默认会折叠:紧凑条吸顶,大标题在它后面向上滚走并交叉淡入紧凑标题。

{ "type": "largetitle", "id": "hdr", "label": "Places", "subtitle": "8 saved nearby",
  "children": [ { "type": "icon", "id": "hdr-plus", "name": "plus", "size": 20 } ] }

出现之前逐字节一致。

折叠本身只是普通的 sticky 定位,因此到处都能用;交叉淡入在支持滚动驱动动画的 浏览器上走 CSS,不支持的浏览器则由一小段脚本驱动同一批声明。

底部弹层(sheet)

sheet(别名 bottomsheetdraggablesheetdraggablescrollablesheetmodalbottomsheet)是一个可以在若干吸附档位之间拖动的底部面板。

{ "type": "sheet", "id": "detail", "open": "{{ state.detail }}",
  "title": "Blue Bottle Coffee",
  "snapPoints": [0.3, 0.6, 0.92], "initialSnap": 1,
  "onSnap": { "name": "setSnap" },
  "children": [ { "type": "text", "id": "d1", "text": "Ferry Building · 240m" } ] }
属性作用
open取值为假时什么都不渲染——没有隐藏的标记。不写这个键则弹层始终存在
snapPoints档位阶梯,取值是所在盒子高度的比例,升序排列。大于 1 的值按百分比读(900.9)。它本身也可以是一个绑定到数组的表达式。默认 [0.25, 0.5, 0.9]
initialSnap阶梯的下标;越界回退到最低档
onSnap一个动作,每次拖拽停稳时派发。它会为每个档位各注册一次,并各自带上参数 snap——该档位的下标,因此动作里用 {{ snap }} 就知道停在哪
onClose弹层被关闭时派发
title · handle · backdrop标题行、拖拽把手(false 去掉)与背景遮罩(false 去掉)

如果 open 绑定的是一个纯状态路径,且 dismissable 不为 false,点击遮罩就会 经由内建的 dismiss 关闭弹层,常见场景因此不需要任何 action 文件。把弹层甩到最低 档位的 60% 以下,也会以同样的方式关闭。没有 Escape 键关闭。

只有把手那一行会占用拖拽手势——内容照常滚动——所以弹层里可以放列表、可滑动行或 可重排列表,两种手势不会互相打架。

examples/places 把这三样放在了一起:会折叠的大标题、一块毛玻璃面板,以及一个在 三个档位之间拖动的弹层。

qorm run examples/places

交互态样式

伪状态样式键描述节点如何响应交互,不需要 JavaScript,也不需要额外节点:

{
  "type": "button", "id": "save", "text": "Save",
  "style": {
    "hoverBackground": "#0a84ff", "hoverColor": "#ffffff",
    "pressedScale": 0.97,
    "focusBorderColor": "var(--accent)",
    "disabled": "{{ state.saving }}", "disabledOpacity": 0.4
  }
}

八个键是 hoverBackgroundhoverColorhoverOpacitypressedScalepressedOpacityfocusBorderColordisableddisabledOpacity。悬停样式 只在真正支持悬停的设备上生效,因此一次点按不会把节点卡在悬停态;disabled 同时会输出 aria-disabled

widthheight 接受像素数字、"fill",或带单位的字符串——"50%""30vw""40vh""120px"。层叠与弹性行为则由 zIndexalignSelfflexShrink 提供。

图片

srcalt 会被插值,因此图片可以放在 renderItem 里。三个属性覆盖了一张 远程图片会经历的各个状态:

{ "type": "image", "id": "hero",
  "src": "{{ item.photo }}", "alt": "{{ item.caption }}",
  "placeholder": "#e5e5ea",
  "fallback": "/assets/missing.png",
  "fit": "cover" }

placeholderfallback 是静态值,不是绑定。

在运行时选择组件

节点的 type 本身可以是一个绑定,在每次渲染时按当前作用域解析。一份列表模板 因此能按行渲染不同的组件,而不必堆叠 if 分支:

{
  "type": "list", "id": "feed", "data": "{{ state.messages }}",
  "renderItem": { "type": "{{ item.kind }}", "text": "{{ item.text }}", "src": "{{ item.src }}" }
}

kind"text""image" 时,同一行会渲染成文本节点或图片。如果该绑定 解析为空,或解析出的名字没有对应组件,节点会退化为渲染器的未知节点占位,并且 诊断信息会点名该表达式——所以拼写错误是看得见的,而不是悄无声息。

表达式语言本身,参见表达式