このページは機械翻訳のため、誤りを含む場合があります。 英語の原文を読む

要素

Canvas

Canvas は、HTML Canvas 2D API と同じ 2D 描画 API を使って自分で描画するビューです。チャート、ゲージ、リング、署名、カスタムシェイプなどに使えます。描画コードは操作を記録し、プラットフォームがそれをネイティブに、画面の解像度で再生します(iOS と Mac Catalyst では Core Graphics、Android では android.graphics.Canvas)。Rust では描画を記録した Painter である canvas(painter) で作成し、TSX では nsh の <Canvas> とその draw 関数で作成します。

el-canvas.tsx
el-canvas.tsxLive
import { Canvas, mount, signal, Text, View } from "nsh";
import { createAnimatedValue, Easing } from "nsh";
import { platform, windowSize } from "nsh";
import { button, buttonText, tint, ui } from "./palette";

const TAU = Math.PI * 2;

// Twelve months of made-up values, 0–100.
const values = [32, 41, 38, 52, 47, 63, 58, 71, 66, 80, 74, 88];
const months = "JFMAMJJASOND";

function Button(props: { label: string; onPress: () => void }) {
  return (
    <View onPress={props.onPress} {...button()}>
      <Text style={buttonText()}>{props.label}</Text>
    </View>
  );
}

function App() {
  // The month under the finger or pointer: `draw` reads it, so moving redraws.
  const picked = signal(values.length - 1);
  const pick = (x: number, width: number) =>
    picked.set(Math.max(0, Math.min(values.length - 1, Math.round(((x - 20) / (width - 40)) * (values.length - 1)))));
  let chartWidth = 0;

  // The dial's angle is a native animated value: it turns without running `draw`.
  const angle = createAnimatedValue(0);
  const turns = signal(0);
  const spin = () => {
    turns.update((n) => n + 1);
    angle.timing(turns() * TAU, { duration: 900, easing: Easing.out(Easing.cubic) });
  };

  const wide = () => windowSize().width >= 700;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>canvas</Text>
      <Text style={{ color: ui().muted }}>
        {platform.desktop ? "Drag across the chart with the mouse" : "Drag your finger across the chart"} · {months[picked()]}{" "}
        = {values[picked()]}
      </Text>

      <View style={{ flex: 1, flexDirection: wide() ? "row" : "column", gap: 14 }}>
        <Canvas
          style={{ ...ui().inset, flex: 1, minHeight: 200, borderRadius: 14 }}
          onLayout={({ bounds }) => (chartWidth = bounds.size.width)}
          onStartShouldSetResponder={() => true}
          onResponderGrant={(e) => {
            pick(e.locationX, chartWidth);
            return true;
          }}
          onResponderMove={(e) => pick(e.locationX, chartWidth)}
          draw={(cx, { width, height }) => {
            const pad = 20;
            const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
            const y = (v: number) => height - pad - 16 - (v / 100) * (height - 2 * pad - 32);
            const line = () => {
              cx.beginPath();
              values.forEach((v, i) => (i === 0 ? cx.moveTo(x(i), y(v)) : cx.lineTo(x(i), y(v))));
            };

            // Grid lines, dashed.
            cx.strokeStyle = ui().border;
            cx.lineWidth = 1;
            cx.setLineDash([4, 4]);
            for (const v of [25, 50, 75]) {
              cx.beginPath();
              cx.moveTo(pad, y(v));
              cx.lineTo(width - pad, y(v));
              cx.stroke();
            }
            cx.setLineDash([]);

            // The area under the line: a path filled with a linear gradient.
            const area = cx.createLinearGradient(0, pad, 0, height - pad);
            area.addColorStop(0, tint(ui().accent, 0.32));
            area.addColorStop(1, tint(ui().accent, 0));
            line();
            cx.lineTo(x(values.length - 1), y(0));
            cx.lineTo(x(0), y(0));
            cx.closePath();
            cx.fillStyle = area;
            cx.fill();

            line();
            cx.strokeStyle = ui().accent;
            cx.lineWidth = 2.5;
            cx.lineJoin = "round";
            cx.stroke();

            // Month labels: text.
            cx.font = "11px system-ui";
            cx.textAlign = "center";
            cx.textBaseline = "alphabetic";
            values.forEach((_, i) => {
              cx.fillStyle = i === picked() ? ui().text : ui().faint;
              cx.fillText(months[i], x(i), height - 10);
            });

            // The picked point, its guide and a bubble with its value.
            const i = picked();
            const px = x(i);
            const py = y(values[i]);
            cx.strokeStyle = tint(ui().text, 0.25);
            cx.beginPath();
            cx.moveTo(px, pad);
            cx.lineTo(px, y(0));
            cx.stroke();
            cx.shadowColor = tint(ui().accent, 0.6);
            cx.shadowBlur = 10;
            cx.fillStyle = ui().accent;
            cx.beginPath();
            cx.arc(px, py, 6, 0, TAU);
            cx.fill();
            cx.shadowBlur = 0;
            const label = String(values[i]);
            cx.font = "600 13px system-ui";
            const w = cx.measureText(label).width + 16;
            const bx = Math.max(pad, Math.min(width - pad - w, px - w / 2));
            cx.fillStyle = ui().text;
            cx.beginPath();
            cx.roundRect(bx, py - 36, w, 22, 6);
            cx.fill();
            cx.fillStyle = ui().page;
            cx.textBaseline = "middle";
            cx.fillText(label, bx + w / 2, py - 25);
          }}
        />

        <View style={{ ...ui().card, borderRadius: 14, padding: 16, gap: 12, alignItems: "center", width: wide() ? 240 : undefined, alignSelf: wide() ? "flex-start" : undefined }}>
          <Canvas
            style={{ width: 120, height: 120 }}
            draw={(cx, { width, height }) => {
              const r = width / 2 - 6;
              const c = cx.createConicGradient(0, width / 2, height / 2);
              c.addColorStop(0, ui().info);
              c.addColorStop(0.5, ui().violet);
              c.addColorStop(1, ui().info);
              cx.fillStyle = c;
              cx.beginPath();
              cx.arc(width / 2, height / 2, r, 0, TAU);
              cx.fill();
              cx.translate(width / 2, height / 2);
              cx.rotate(angle); // animates natively
              cx.fillStyle = ui().page;
              cx.beginPath();
              cx.moveTo(0, -r + 10);
              cx.lineTo(8, 0);
              cx.lineTo(-8, 0);
              cx.closePath();
              cx.fill();
              cx.beginPath();
              cx.arc(0, 0, 10, 0, TAU);
              cx.fill();
            }}
          />
          <Text style={{ color: ui().muted, textAlign: "center" }}>Turns: {turns()} (a native animated value)</Text>
          <Button label="Spin" onPress={spin} />
        </View>
      </View>
    </View>
  );
}

mount(App);
use std::f32::consts::{FRAC_PI_2, TAU};

use nsh_core::{canvas::LineCap, prelude::*};

/// A progress ring, 120 points square.
fn ring(progress: f32) -> impl IntoElement {
    let (center, radius) = (60.0, 50.0);
    canvas(
        Painter::new()
            .line_width(12.0)
            .line_cap(LineCap::Round)
            .stroke_color(rgb(0xe5e7eb))
            .begin_path()
            .arc(center, center, radius, 0.0, TAU)
            .stroke()
            .stroke_color(rgb(0x6366f1))
            .begin_path()
            .arc(center, center, radius, -FRAC_PI_2, -FRAC_PI_2 + progress * TAU)
            .stroke(),
    )
    .size(px(120.0))
}

TSX では、Canvas がネイティブの Canvas ビューを作成して測定し、コンテキストと Canvas のサイズを渡して draw 関数を実行します(Canvas API を参照)。Rust では、canvas(..) は Painter またはそれを返すクロージャを受け取ります。クロージャは、読み取っている値が変化すると再び描画します。描画がサイズに依存する場合、サイズは on_layout から取得します。

#Props

Canvas は、ビューの props(スタイル、プレスおよびジェスチャーの props、onLayout、アクセシビリティ、ref など)に加えて、次の props を受け取ります。

プロパティRust型説明
drawcanvas(painter)(cx: CanvasRenderingContext2D, size: { width: number; height: number }) => voidCanvas を描画します。サイズが確定すると実行され、Canvas のサイズが変わったときと、読み取っているシグナルが変化したときにも再度実行されます。

Canvas のサイズはスタイル(幅 / 高さ、flex、アスペクト比)で指定します。Canvas のサイズは、スタイルが与えるサイズそのものになります。

#再描画のタイミング

描画はエフェクトと同様に追跡されます。描画の中でシグナルを読み取ると、その描画がそのシグナルを購読します。

let values = signal(vec![12.0_f32, 40.0, 28.0, 64.0]);
let width = signal(0.0_f32);
let height = 160.0;

canvas(move || {
    let data = values.get(); // a change of values redraws
    let step = width.get() / data.len() as f32;
    data.iter().enumerate().fold(Painter::new().fill_color(rgb(0x0969da)), |p, (i, v)| {
        p.fill_rect(i as f32 * step + 4.0, height - v, step - 8.0, *v)
    })
})
.h(px(height))
.on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))

数値と色には、ネイティブアニメーション値(CanvasNumber、CanvasPaint。Rust では数値を指定できる場所ならどこでも &AnimatedValue)も使えます。この場合、アニメーション中はプラットフォームが自動的に再描画するため、アプリのコードでフレームごとに処理する必要はありません。

use std::f32::consts::TAU;

let angle = animated_value(0.0);
angle.timing(TAU, 1000.0, Easing::Linear);

canvas(
    Painter::new()
        .translate(40.0, 40.0)
        .rotate(&angle) // animates natively
        .fill_color(rgb(0xcf222e))
        .fill_rect(-20.0, -4.0, 40.0, 8.0),
)
.size(px(80.0))

#コンテキスト

CanvasRenderingContext2D はブラウザのもののサブセットで、Painter には同じ操作がスネークケースのメソッドとして用意されています(move_to、line_to、fill_rect、fill_text、stroke_color など)。対応している操作は次のとおりです。ステートスタック(save、restore)、変換、パス(moveTo、lineTo、arc、arcTo、ellipse、rect、roundRect、ベジェ曲線)、fill / stroke / clip(SVG パスデータを受け付ける Path2D にも対応。Rust では fill_svg / stroke_svg)、矩形、テキスト(fillText、strokeText、measureText)、線形 / 放射状 / 円錐グラデーション、破線、シャドウ、globalAlpha、globalCompositeOperation、drawImage(URL またはアセットによる画像)。すべてのメンバーは Canvas API リファレンスに掲載しています。チャートやアニメーショングラフィックスの描画については、Canvas ガイドで順を追って説明しています。

座標の単位はポイントで、原点は左上です。measureText は同期的に動作し、width を返します。Rust の Painter はヒットテストにも対応しています(is_point_in_path、is_point_in_stroke)。

このコンテキストが描画するのはベクター操作と画像です。ピクセルアクセス(getImageData / putImageData)、filter、SVG 画像には対応していません。

#イベント

ビューイベントに対応しています。onPress、onLayout、ジェスチャー、タッチです。描画座標に対するタップを処理するには、onTouchEnd で locationX / locationY を読み取るか、onResponderMove(Rust では .on_responder_move(..)、e.location と組み合わせて使用)でドラッグを追跡します。

#Ref メソッド

独自のものはありません。