SDNode

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
});

如果我们希望让这个修改的视觉呈现更舒缓一些,应该怎么办呢?只需要简单地将修改放进 startAnimateendAnimate 之间即可。

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
});

来点更好玩的,我们可以在 startAnimateendAnimate 之间连续完成多个修改操作。这些修改都会被自动动画化。

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 并没有随之进入动画状态,因为 r1r2 是两个独立的个体,并没有任何说明指导 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();
});

交互系统