SDNode
为了更加方便地管理场景中的可编程对象,我们把这些对象组织为一棵场景树。场景树上的每一个节点都是一个 sd.SDNode。最简单的 sd.SDNode 不包含任何子节点。稍微复杂一点的 sd.SDNode,例如 sd.Array,则会包含其他 sd.SDNode 并自动管理自己内部持有的 sd.SDNode。用户也可以选择手动管理一些场景树上的父子关系,以完成更复杂的效果。
API 列表
sd.SDNode 是一个抽象类,它是所有场景上可编程对象的基类,所以不应该直接创建 sd.SDNode。但我们仍然可以介绍一下 sd.SDNode 的各类方法的签名。我们可以把 sd.SDNode 的方法大致分为:构造方法、元信息获取、图层管理、父子管理、动画管理、响应式系统和进入退出管理这几个类别。
class SDNode {
// 构造方法
constructor(target: SDNode | RenderNode);
// 元信息
type(type: string): this;
fixAspect(): boolean;
// 图层管理
layer(): RenderNode;
layer(name: string): RenderNode;
newLayer(name: string): this;
attachTo(layer: SDNode): this;
attachTo(layer: RenderNode): this;
// 父子管理
childAs(name: string, child: SDNode, rule: Rule): this;
childAs(name: string, child: SDNode): this;
childAs(child: SDNode, rule: Rule): this;
childAs(child: SDNode): this;
child(name: string): SDNode;
hasChild(child: string | SDNode): SDNode;
eraseChild(name: string): SDNode;
eraseChild(child: SDNode): SDNode;
// 动画管理
startAnimate(duration: number): this;
startAnimate(other: SDNode): this;
startAnimate(start: number, end: number): this;
startAnimate(): this;
endAnimate(): this;
isAnimating(): boolean;
delay(): number;
after(delay: number): this;
after(other: SDNode): this;
duration(): number;
// 响应式系统
freeze(): this;
unfreeze(): this;
freezing(): boolean;
rule(): (parent: SDNode, child: SDNode) => void;
rule(rule: (parent: SDNode, child: SDNode) => void): this;
eraseRule(): this;
effect(name: string, callback: () => void): this;
uneffect(name: string): this;
uneffectAll(): this;
// 交互系统
drag(type: true): this;
drag(type: false | null | undefined);
drag(onDrag: (dx: number, dy: number) => [number, number]): this;
clickable(type: true): this;
clickable(type: false | null | undefined);
onClick(onClick: (node: this) => void): this;
onDblClick(onClick: (node: this) => void): this;
// 进入退出管理
onEnter(enter: (element: SDNode, move: () => void) => void): this;
onEnter(): (element: SDNode) => void | undefined;
onEnterDefault(enter: (element: SDNode, move: () => void) => void): this;
triggerEnter(): this;
entering(): boolean;
onExit(): (element: SDNode) => void | undefined;
onExit(exit: (element: SDNode) => void): this;
onExitDefault(exit: (element: SDNode) => void): this;
triggerExit(): this;
}
构造方法
第一种构造方法是这样的:
class SDNode {
constructor(target: RenderNode);
}
可以认为 sd.RenderNode 就是一张画布,此构造方法把 sd.SDNode 绘制该画布上。值得注意的是,先绘制上画布的图形会被放置在靠下的图层,后绘制上画布的图形会被放置在靠上的图层。例如:
const svg = sd.svg();
const rect = new sd.Rect(svg);
const circle = new sd.Circle(svg).dx(20).dy(20); // 此圆形会在方形的上方
第二种构造方法是这样的:
class SDNode {
constructor(target: SDNode);
}
这里并不是把 target 作为被创建对象的父节点的意思,而是让被创建对象绘制于 target 的图层上。例如:
const svg = sd.svg();
const rect = new sd.Rect(svg);
const circle = new sd.Circle(svg).dx(20).dy(20); // 此圆形会在方形的上方
const ellipse = new sd.Ellipse(rect).rx(40).dy(20).cx(0); // 此椭圆并不会在圆形的上方,而是会在方形之上,圆形之下
如果我们把原始 SVG 画布上的内容拿出来看的话,大概长这样:
<svg>
`
<g type="Rect">
<rect 省略一些属性></rect>
<g type="Ellipse">
<ellipse 省略一些属性></ellipse>
</g>
</g>
<g type="Circle">
<circle 省略一些属性></circle>
</g>
</svg>
其中的 g 就表示一个 sd.SDNode 的图层,可以看见 sd.Ellipse 是在 sd.Rect 的图层之内创建的。对于其他种类的画布,构造方法的行为也是类似的,在此不赘述。
元信息
对用户而言几乎用不到。
图层管理
class SDNode {
// 图层管理
layer(): RenderNode; // 获取自带图层
layer(name: string): RenderNode; // 获取名为 name 的图层
newLayer(name: string): this; // 新建一个名为 name 的图层
attachTo(target: SDNode): this; // 将自己贴到 target 的自带图层的末尾
attachTo(target: RenderNode): this; // 将自己贴到 target 这张图层上
}
在每个 sd.SDNode 被创建时会自带一个图层。同时也可以新建图层。每次在图层上方绘制。
const svg = sd.svg();
const C = sd.color();
const rect = new sd.Rect(svg);
rect.newLayer("l1");
rect.newLayer("l2");
const redCircle = new sd.Circle(rect.layer("l1")).color(C.red).cy(0).cx(0);
const blueCircle = new sd.Circle(rect).color(C.blue).cy(40).cx(40);
// 也可以用 new sd.Circle(rect.layer()) 把 blueCircle 绘制在 rect 自带图层的末尾
const greenCircle = new sd.Circle(rect.layer("l2")).color(C.green).r(40).cy(0).cx(40);
// 三个圆形从底往上的显示顺序应该是,红色在最下层,绿色在中间,蓝色在最上层
如果我们把原始 SVG 画布上的内容拿出来看的话,大概长这样:
<svg>
<g type="Rect" 这是Rect的自带图层>
<rect 省略一些属性></rect>
<g layer="l1" 这是Rect新建的名为l1的图层>
<g type="Circle" 这是Circle的自带图层>
<circle 红色的圆></circle>
</g>
</g>
<g layer="l2" 这是Rect新建的名为l2的图层>
<g type="Circle" 这是Circle的自带图层>
<circle 绿色的圆></circle>
</g>
</g>
<g type="Circle" 这是Circle的自带图层>
<circle 蓝色的圆></circle>
</g>
</g>
</svg>
动画管理
class SDNode {
// 动画管理
startAnimate(duration: number): this; // 开启一段动画编排,持续时长 duration
startAnimate(other: SDNode): this; // 开启一段动画编排,动画编排的信息和 other 节点一致
startAnimate(start: number, end: number): this; // 开启一段动画编排,动画的持续区间为 [start, end]
startAnimate(): this; // 开启一段动画编排,默认持续时长 300ms
endAnimate(): this; // 结束一段动画编排
isAnimating(): boolean; // 判断是否正处于动画编排中
delay(): number; // 获取动画编排的起始时间
after(delay: number): this;
after(other: SDNode): this;
duration(): number; // 获取当前动画编排的持续时长
}
在场景树上的各种节点 sd.SDNode 有各种各样的属性。可以通过相应的方法去访问对应属性。修改对应属性后,其效果会生硬地呈现出来。例如:
const svg = sd.svg();
const rect = new sd.Rect(svg).x(100); // 设置 rect 的 x 坐标为 100
console.log(rect.x()); // 100
sd.main(async () => {
await sd.pause(); // 暂停一下,需要按 N 才能执行后续代码
rect.x(200); // 将 rect 的 x 坐标改为 200
console.log(rect.x()); // 200
});
如果我们希望让这个修改的视觉呈现更舒缓一些,应该怎么办呢?只需要简单地将修改放进 startAnimate 和 endAnimate 之间即可。
const svg = sd.svg();
const rect = new sd.Rect(svg).x(100); // 设置 rect 的 x 坐标为 100
console.log(rect.x()); // 100
sd.main(async () => {
await sd.pause();
rect.startAnimate().x(200).endAnimate(); // 将 rect 的 x 坐标改为 200
console.log(rect.x()); // 200
});
来点更好玩的,我们可以在 startAnimate 和 endAnimate 之间连续完成多个修改操作。这些修改都会被自动动画化。
const svg = sd.svg();
const rect = new sd.Rect(svg); // x = 0, y = 0
sd.main(async () => {
await sd.pause();
rect.startAnimate().x(100).y(100).endAnimate(); // 在一段动画编排中完成多个属性修改
});
我们把由 startAnimate 引领,由 endAnimate 结尾,中间一系列属性修改操作构成的整体,叫做一次动画编排。
我们可以为动画编排设定不同的持续时长。例如:
const svg = sd.svg();
const rects = [];
for (let i = 0; i < 5; i++) rects.push(new sd.Rect(svg).y(i * 50));
sd.main(async () => {
await sd.pause();
// 使用 startAnimate(duration: number),其中 duration 表示动画持续时间,以 ms 为单位
// 如果不填写 duration,则动画持续时长默认为 300ms
for (let i = 0; i < 5; i++)
rects[i]
.startAnimate((i + 1) * 300)
.x(600)
.endAnimate();
});
响应式系统
class SDNode {
// 响应式系统
freeze(): this; // 冻结当前节点的响应式作用(性能优化,通常不会使用)
unfreeze(): this; // 解冻当前节点的响应式作用(性能优化,通常不会使用)
freezing(): boolean; // 判断当前节点是否处于冻结状态(性能优化,通常不会使用)
rule(): (parent: SDNode, child: SDNode) => void; // 获取父子节点之间的布局规则
rule(rule: (parent: SDNode, child: SDNode) => void): this; // 定义父子节点之间的布局规则
eraseRule(): this; // 删除父子节点之间的布局规则
effect(name: string, callback: () => void): this; // 定义一个名为 name 的响应式作用
uneffect(name: string): this; // 取消名为 name 的响应式作用
uneffectAll(): this; // 取消所有响应式作用
}
在动画场景中难免会出现类似这样的需求:让某个文本元素始终位于某个数组上方中间的位置,无论数组如何移动或者缩放。这要求文本元素能够自动监听数组的位置的改变,这种情况下,我们认为文本元素的坐标相对于数组而言是具有响应关系的。注意到响应关系还具有传递性,如果全权交给程序员负责,心智负担过重。所以本框架提供了一套工具辅助完成响应关系的构建和追踪。
第一种定义响应关系的方法是通过 effect 来完成的:
const svg = sd.svg();
const r1 = new sd.Rect(svg);
const r2 = new sd.Rect(svg);
r2.effect("centerAtR1", () => {
// 定义一个名为 centerAtR1 的响应式作用
r2.width(r1.width() / 2);
r2.height(r1.height() / 2);
r2.center(r1.center());
});
试一试,当修改
r1的各项属性时,r2的各项属性会随之改变,确保r2始终位于r1的中心位置,并且r2的长宽始终是r1的一半。
但这样遇到动画后有一个问题。
sd.main(async () => {
await sd.pause();
r1.startAnimate(1000).x(100).endAnimate();
});
当
r1进入动画状态后,r2并没有随之进入动画状态,因为r1和r2是两个独立的个体,并没有任何说明指导r1的动画编排必然会导致r2的动画编排。作者在最初设计时,决定当一个节点进入动画编排状态后,其所有子节点会随之自动进入动画编排状态。所以把r2设置r1的子节点则可解决这个问题。
r1.childAs(r2); // 将 r2 作为 r1 的子节点(后续父子管理章会展开解释,在这里先用着)
sd.main(async () => {
await sd.pause();
r1.startAnimate().x(100).endAnimate();
});
作者观察到,大部分动画场景下的响应式作用,其实只出现在父子之间,所以另外提供了一种定义响应关系的方法:
const svg = sd.svg();
const r1 = new sd.Rect(svg);
const r2 = new sd.Rect(svg);
r1.childAs(r2);
r2.rule((parent, child) => {
child.width(parent.width() / 2);
child.height(parent.height() / 2);
child.center(parent.center());
});
rule 定义的是一种父子之间的响应式关系,其形式为读取父节点的某些属性,经过一系列计算后,得出子节点的某些属性。
事实上,在 sd 动画框架中内置了一个 sd.rule 系统,其内预设了一些常见的父子布局规则。在通常情况中是不需要自己手动编写这样的布局规则的,使用预设的规则足矣,只有在极其需要定制化的时候才有必要手动编写。
父子管理
class SDNode {
// 父子管理
childAs(name: string, child: SDNode, rule: Rule): this; // 添加一个名为 name 的 child,且定义 rule
childAs(name: string, child: SDNode): this; // 添加一个名为 name 的 child
childAs(child: SDNode, rule: Rule): this; // 添加一个匿名 child,且定义 rule
childAs(child: SDNode): this; // 添加一个匿名 child
child(name: string): SDNode; // 获取名为 name 的 child
hasChild(child: string | SDNode): SDNode; // 判断某个 child 是否存在
eraseChild(child: string | SDNode): SDNode; // 删除某个 child
}
childAs 方法让添加一个子节点变得非常灵活,一个子节点可以是匿名的,可以是非匿名的。在非匿名的情况下,未来可以通过名字去获取或者删除该子节点。同时,可以在添加子节点的时候顺便定义父子之间的响应式作用,例如简化 rule 篇的代码:
const svg = sd.svg();
const r1 = new sd.Rect(svg).childAs(new sd.Rect(svg), (parent, child) => {
child.width(parent.width() / 2);
child.height(parent.height() / 2);
child.center(parent.center());
});
进入退出管理
class SDNode {
// 进入退出管理
onEnter(): (element: SDNode) => void | undefined; // 获取进入阶段的过渡方法
onEnter(enter: (element: SDNode, move: () => void) => void): this; // 设置进入阶段的过渡方法
onEnterDefault(enter: (element: SDNode, move: () => void) => void): this; // 设置进入阶段的默认过渡方法
triggerEnter(): this; // 触发进入阶段的过渡方法(框架内部使用)
entering(): boolean; // 判断是否正处于进入阶段
onExit(): (element: SDNode) => void | undefined; // 获取退出阶段的过渡方法
onExit(exit: (element: SDNode) => void): this; // 设置退出阶段的过渡方法
onExitDefault(exit: (element: SDNode) => void): this; // 设置退出阶段的默认过渡方法
triggerExit(): this; // 触发退出阶段(框架内部使用)
}
当一个节点成为另一个节点的子节点,我们称为进入阶段。当一个子节点被移除时,我们称为退出阶段。进入和退出阶段显然都需要使用动画,来让此过程更加舒缓。
对于进入阶段而言,各种预设定义在 sd.enter 中。
const svg = sd.svg();
const R = sd.rule();
const EN = sd.enter();
const EX = sd.exit();
const [fa1, child1] = [new sd.Rect(svg), new sd.Circle(svg).x(100)];
const [fa2, child2] = [new sd.Rect(svg).y(50), new sd.Circle(svg).y(50).x(100)];
sd.main(async () => {
await sd.pause();
fa1.startAnimate().childAs(child1.onEnter(EN.appear()), R.centerOnly()).endAnimate();
fa2.startAnimate().childAs(child2.onEnter(EN.moveTo()), R.centerOnly()).endAnimate();
});
对于退出阶段而言,各种预设定义在 sd.exit 中。
sd.main(async () => {
// ...
await sd.pause();
fa1.startAnimate().eraseChild(child1.onExit(EX.fade())).endAnimate();
fa2.startAnimate().eraseChild(child2.onExit(EX.drop())).endAnimate();
});
交互系统
略