指南

画布

nsh/canvas 中的 Canvas 是一个用 HTML Canvas 2D API 绘制的视图:图表、进度环、仪表盘、签名、自定义图标,凡是画出来比用视图拼出来更容易的东西都适合。你的 draw 函数一次性录制整幅绘制;平台以原生方式回放(iOS 上是 Core Graphics,Android 上是 android.graphics.Canvas),按屏幕分辨率绘制,所以在任何像素密度下都很清晰。

guide-canvas.tsx 在线编辑 在新标签页打开
import { type GestureResponderEvent, mount, signal, Text, View } from "nsh";
import { createNativeValue, Easing } from "nsh/animated";
import { Canvas, Path2D } from "nsh/canvas";

const TAU = Math.PI * 2;
const heart = new Path2D("M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z");

function Button(props: { label: string; onPress: () => void }) {
  return (
    <View onPress={props.onPress} activeOpacity={0.6} style={{ paddingHorizontal: 14, paddingVertical: 9, borderRadius: 10, backgroundColor: "#0969da" }}>
      <Text style={{ color: "#ffffff", fontWeight: "600" }}>{props.label}</Text>
    </View>
  );
}

function App() {
  // The ring's sweep is a native value: animating it redraws without running `draw`.
  const progress = createNativeValue(0.35);
  const target = signal(0.35);
  const go = () => {
    const to = Math.round(Math.random() * 100) / 100;
    target.set(to);
    progress.timing(to, { duration: 800, easing: Easing.inOut(Easing.cubic) });
  };

  // The chart reads a signal: changing it runs `draw` again.
  const data = signal([12, 30, 22, 48, 36, 60, 52, 75]);

  // Finger drawing through the responder system.
  const strokes = signal<[number, number][][]>([]);
  const point = (e: GestureResponderEvent): [number, number] => [e.locationX, e.locationY];

  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 16, paddingTop: 24, gap: 14 }}>
      <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
        <Canvas
          style={{ width: 110, height: 110 }}
          draw={(ctx, { width, height }) => {
            const cx = width / 2;
            const cy = height / 2;
            const r = width / 2 - 8;
            ctx.lineWidth = 10;
            ctx.lineCap = "round";
            ctx.strokeStyle = "#e5e7eb";
            ctx.beginPath();
            ctx.arc(cx, cy, r, 0, TAU);
            ctx.stroke();
            const g = ctx.createConicGradient(-Math.PI / 2, cx, cy);
            g.addColorStop(0, "#22d3ee");
            g.addColorStop(1, "#6366f1");
            ctx.strokeStyle = g;
            ctx.beginPath();
            ctx.arc(cx, cy, r, -Math.PI / 2, progress.mul(TAU).sub(Math.PI / 2));
            ctx.stroke();
            ctx.fillStyle = "#1f2328";
            ctx.font = "600 18px system-ui";
            ctx.textAlign = "center";
            ctx.textBaseline = "middle";
            ctx.fillText(`${Math.round(target() * 100)}%`, cx, cy);
          }}
        />
        <View style={{ gap: 10 }}>
          <Button label="Animate the ring" onPress={go} />
          <Canvas
            style={{ width: 48, height: 48 }}
            draw={(ctx) => {
              ctx.scale(2, 2);
              ctx.shadowColor = "rgba(207,34,46,0.4)";
              ctx.shadowBlur = 4;
              ctx.fillStyle = "#cf222e";
              ctx.fill(heart);
            }}
          />
        </View>
      </View>

      <Canvas
        style={{ height: 150, borderRadius: 10, backgroundColor: "#ffffff" }}
        draw={(ctx, { width, height }) => {
          const values = data();
          const pad = 16;
          const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
          const y = (v: number) => height - pad - (v / 100) * (height - 2 * pad);
          const area = ctx.createLinearGradient(0, pad, 0, height - pad);
          area.addColorStop(0, "rgba(59,130,246,0.35)");
          area.addColorStop(1, "rgba(59,130,246,0)");
          ctx.beginPath();
          values.forEach((v, i) => (i === 0 ? ctx.moveTo(x(i), y(v)) : ctx.lineTo(x(i), y(v))));
          ctx.lineTo(x(values.length - 1), height - pad);
          ctx.lineTo(x(0), height - pad);
          ctx.closePath();
          ctx.fillStyle = area;
          ctx.fill();
          ctx.beginPath();
          values.forEach((v, i) => (i === 0 ? ctx.moveTo(x(i), y(v)) : ctx.lineTo(x(i), y(v))));
          ctx.strokeStyle = "#3b82f6";
          ctx.lineWidth = 2.5;
          ctx.lineJoin = "round";
          ctx.stroke();
        }}
      />
      <Button label="New data" onPress={() => data.set(data().map(() => Math.round(10 + Math.random() * 85)))} />

      <Canvas
        style={{ flex: 1, borderRadius: 10, backgroundColor: "#ffffff" }}
        onStartShouldSetResponder={() => true}
        onResponderGrant={(e) => {
          strokes.set([...strokes(), [point(e)]]);
          return true;
        }}
        onResponderMove={(e) => {
          const all = strokes();
          strokes.set([...all.slice(0, -1), [...all[all.length - 1], point(e)]]);
        }}
        draw={(ctx, { width }) => {
          ctx.fillStyle = "#8c959f";
          ctx.font = "13px system-ui";
          ctx.textAlign = "center";
          ctx.fillText("Draw here", width / 2, 20);
          ctx.strokeStyle = "#1f2328";
          ctx.lineWidth = 3;
          ctx.lineCap = "round";
          ctx.lineJoin = "round";
          for (const s of strokes()) {
            ctx.beginPath();
            s.forEach(([px, py], i) => (i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)));
            ctx.stroke();
          }
        }}
      />
    </View>
  );
}

mount(App);
import { mount, View } from "nsh";
import { Canvas } from "nsh/canvas";

function App() {
  return (
    <View style={{ flex: 1, padding: 24, paddingTop: 60 }}>
      <Canvas
        style={{ height: 200, borderRadius: 12, backgroundColor: "#ffffff" }}
        draw={(ctx, { width, height }) => {
          ctx.fillStyle = "#0969da";
          ctx.beginPath();
          ctx.arc(width / 2, height / 2, 60, 0, Math.PI * 2);
          ctx.fill();
          ctx.strokeStyle = "#1f2328";
          ctx.lineWidth = 4;
          ctx.strokeRect(20, 20, width - 40, height - 40);
        }}
      />
    </View>
  );
}

mount(App);

Canvas 接受 View 的所有属性(样式、类、触摸事件、无障碍、ref),再加上 draw。它需要从样式或布局中得到尺寸;它的内容就是绘制结果。坐标以点为单位,原点在左上角。

#draw 何时运行

draw(ctx, { width, height }) 会在以下时机运行:

  • 画布尺寸确定后(第一次布局之后);
  • 画布尺寸改变时;
  • 它读取过的 signal 改变时。

每次运行都会替换整幅绘制。在 draw 中读取 signal,画布就能跟随你的数据:

const values = signal([12, 30, 22, 48, 36, 60]);

<Canvas
  style={{ height: 160 }}
  draw={(ctx, { width, height }) => {
    const v = values();
    const x = (i: number) => (i / (v.length - 1)) * width;
    const y = (n: number) => height - (n / 100) * height;
    ctx.beginPath();
    v.forEach((n, i) => (i === 0 ? ctx.moveTo(x(i), y(n)) : ctx.lineTo(x(i), y(n))));
    ctx.strokeStyle = "#3b82f6";
    ctx.lineWidth = 2.5;
    ctx.lineJoin = "round";
    ctx.stroke();
  }}
/>

上下文的调用只是录制:draw 运行期间什么也不会画出来,所以无法读回像素。draw 返回后,录制结果作为一个操作发送给平台。

#2D 上下文

CanvasRenderingContext2D 是 HTML Canvas 2D API 的一个子集。支持的内容:

状态

成员说明
save()、restore()压入和弹出绘制状态(样式、变换、裁剪)。
fillStyle、strokeStyleCSS 颜色、渐变,或插值为颜色的原生值。
lineWidth、lineCap、lineJoin、miterLimit描边形状。
setLineDash(segments)、getLineDash()、lineDashOffset虚线。
globalAlpha后续绘制的不透明度,0..1。
globalCompositeOperation合成方式:"source-over"、"destination-out"、"multiply"、"screen" 等。
shadowColor、shadowBlur、shadowOffsetX、shadowOffsetY阴影。
fontCSS font 简写:"600 14px Inter"(尺寸可用 px、pt 或 rem)。
textAlign、textBaseline文字位置。

变换:translate(x, y)、scale(x, y)、rotate(angle)(弧度,顺时针)、transform(a, b, c, d, e, f)、setTransform(a, b, c, d, e, f)、resetTransform()。

路径:beginPath()、moveTo、lineTo、quadraticCurveTo、bezierCurveTo、arc、arcTo、ellipse、rect、roundRect(x, y, w, h, radii)、closePath();然后是 fill(rule?)、stroke() 和 clip(rule?),它们也都接受 Path2D。填充规则为 "nonzero" 和 "evenodd"。

矩形:fillRect、strokeRect、clearRect。

文字:fillText(text, x, y, maxWidth?)、strokeText(...)、measureText(text)(由平台的文字引擎按当前 font 同步测量;只有 width)。

渐变:createLinearGradient、createRadialGradient、createConicGradient,都通过 addColorStop(offset, color) 添加色标。

图片:drawImage(image, dx, dy)、drawImage(image, dx, dy, dw, dh)、drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)。

ctx.canvas.width 和 ctx.canvas.height 给出画布以点为单位的尺寸。

不支持:getImageData / putImageData 等像素访问、filter、isPointInPath、在 drawImage 中使用 SVG 图片。与 HTML Canvas 一样,无效的颜色或字体只会让那一个操作被丢弃。

#路径与 Path2D

Path2D 保存一条构建一次、可多次填充、描边或裁剪的路径。它接受 SVG 路径数据,在原生端解析,用来画图标很方便:

import { Canvas, Path2D } from "nsh/canvas";

const heart = new Path2D("M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z");

function Heart(props: { size: number; color: string }) {
  return (
    <Canvas
      style={{ width: props.size, height: props.size }}
      draw={(ctx) => {
        ctx.scale(props.size / 24, props.size / 24); // 路径画在 24 点的网格上
        ctx.fillStyle = props.color;
        ctx.fill(heart);
      }}
    />
  );
}

Path2D 拥有与上下文相同的路径方法(moveTo、arc、roundRect 等)以及 addPath(other)。new Path2D(other) 复制一条路径。

#渐变

<Canvas
  style={{ height: 120 }}
  draw={(ctx, { width, height }) => {
    const linear = ctx.createLinearGradient(0, 0, width, 0);
    linear.addColorStop(0, "#22d3ee");
    linear.addColorStop(1, "#6366f1");
    ctx.fillStyle = linear;
    ctx.fillRect(0, 0, width, height / 2);

    const radial = ctx.createRadialGradient(width / 2, height * 0.75, 4, width / 2, height * 0.75, 40);
    radial.addColorStop(0, "#fde68a");
    radial.addColorStop(1, "#f97316");
    ctx.fillStyle = radial;
    ctx.beginPath();
    ctx.arc(width / 2, height * 0.75, 28, 0, Math.PI * 2);
    ctx.fill();
  }}
/>

createConicGradient(startAngle, x, y) 让颜色绕一个点旋转过渡;起始角以弧度表示,从 x 轴顺时针计算。把锥形渐变用在描边的圆弧上,就是渐变色的进度环。

#文字

ctx.font = "600 20px system-ui";
ctx.fillStyle = "#1f2328";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("72%", width / 2, height / 2);

ctx.font = "11px system-ui";
const label = "100";
ctx.fillText(label, 32 - 6 - ctx.measureText(label).width, 16);

字体族与样式中的相同:系统字体族以及应用导入的字体名称(见字体)。

#图片

drawImage 接受 URL、导入的资源(import logo from "./logo.png")或 { uri }。图片在原生端加载,加载完成后画布会重新绘制,你不需要自己跟踪加载状态。远程 URL 受应用的网络能力声明约束。

import { Canvas } from "nsh/canvas";
import logo from "./logo.png";

<Canvas
  style={{ width: 120, height: 120 }}
  draw={(ctx) => {
    ctx.save();
    ctx.beginPath();
    ctx.arc(60, 60, 56, 0, Math.PI * 2);
    ctx.clip(); // 圆形头像
    ctx.drawImage(logo, 4, 4, 112, 112);
    ctx.restore();
  }}
/>

九个参数的形式中,源矩形以图片的像素为单位。

#用原生值驱动动画

绘制调用中的任何数字参数,以及 fillStyle / strokeStyle,都可以是原生值或由它派生的节点。这类参数在平台绘制时才被读取;当原生值在动画中时,平台会自行逐帧重绘,不运行 draw,也不运行任何其他 JS。

import { mount, signal, Text, View } from "nsh";
import { createNativeValue, Easing } from "nsh/animated";
import { Canvas } from "nsh/canvas";

function Ring() {
  const progress = createNativeValue(0.3);
  const label = signal(30);
  const go = (to: number) => {
    label.set(Math.round(to * 100));
    progress.timing(to, { duration: 900, easing: Easing.inOut(Easing.cubic) });
  };
  return (
    <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
      <Canvas
        style={{ width: 140, height: 140 }}
        draw={(ctx, { width, height }) => {
          const cx = width / 2, cy = height / 2, r = width / 2 - 10;
          ctx.lineWidth = 12;
          ctx.lineCap = "round";
          ctx.strokeStyle = "#e5e7eb";
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, Math.PI * 2);
          ctx.stroke();
          ctx.strokeStyle = "#6366f1";
          ctx.beginPath();
          // 终止角在每一帧读取原生值。
          ctx.arc(cx, cy, r, -Math.PI / 2, progress.mul(Math.PI * 2).sub(Math.PI / 2));
          ctx.stroke();
          ctx.fillStyle = "#1f2328";
          ctx.font = "600 22px system-ui";
          ctx.textAlign = "center";
          ctx.textBaseline = "middle";
          ctx.fillText(`${label()}%`, cx, cy);
        }}
      />
      <View onPress={() => go(Math.random())}>
        <Text style={{ color: "#0969da" }}>Animate</Text>
      </View>
    </View>
  );
}

mount(Ring);

圆弧的终止角是 progress.mul(2π).sub(π/2):它是一个节点,所以进度环会在 900 毫秒内以原生方式扫过,而 draw 只运行了一次(标签读取了 signal,因此再运行一次)。时钟的秒针也是同样的做法:ctx.rotate(seconds.mul(Math.PI / 30)),其中 seconds 做线性动画。

颜色也可以这样动起来:ctx.fillStyle = t.interpolate({ inputRange: [0, 1], outputRange: ["#54aeff", "#cf222e"] })。

#画布上的触摸

画布是一个视图,因此接受按压事件和手势响应者系统。手写绘图就是把点记录到 signal 中再画出来:

const strokes = signal<[number, number][][]>([]);

<Canvas
  style={{ height: 240 }}
  onStartShouldSetResponder={() => true}
  onResponderGrant={(e) => {
    strokes.set([...strokes(), [[e.locationX, e.locationY]]]);
    return true; // 外层的滚动视图不能接管触摸
  }}
  onResponderMove={(e) => {
    const all = strokes();
    strokes.set([...all.slice(0, -1), [...all[all.length - 1], [e.locationX, e.locationY]]]);
  }}
  draw={(ctx) => {
    ctx.lineWidth = 3;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    for (const s of strokes()) {
      ctx.beginPath();
      s.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
      ctx.stroke();
    }
  }}
/>

#性能

  • JS 调用上下文时,绘制在 Rust 中录制,并作为一个操作发送给平台;不存在每次调用都跨越一次桥的开销。
  • draw 每次都重新录制全部内容。让 draw 中的工作量与显示的内容相称,把每帧都变化的数据放在原生值而不是 signal 中。
  • 如果一幅绘制既有静态部分又有频繁变化的部分,把它拆成上下叠放的两个画布,这样只有一个需要重绘。
  • 在可缩放的滚动视图中,缩放结束后画布会按缩放后的分辨率重新绘制。

#相关内容