Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Documentación

Canvas

Un canvas es una vista en la que dibujas con las operaciones de la HTML Canvas 2D API: gráficos, anillos de progreso, medidores, firmas, iconos personalizados, cualquier cosa que sea más fácil de dibujar que de construir con vistas. En Rust, canvas(..) muestra lo que registró un Painter; en TSX, Canvas de nsh recibe una función draw que registra el dibujo de una sola vez. La plataforma lo reproduce de forma nativa (Core Graphics en iOS, android.graphics.Canvas en Android) a la resolución de la pantalla, de modo que los dibujos se mantienen nítidos con cualquier densidad de píxeles.

guide-canvas.tsx
guide-canvas.tsxLive
import { Canvas, type GestureResponderEvent, mount, Path2D, signal, Text, View } from "nsh";
import { createAnimatedValue, Easing } from "nsh";
import { button, buttonText, Inspector, Panel, tint, ui, wide } from "./palette";

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} {...button()}>
      <Text style={buttonText()}>{props.label}</Text>
    </View>
  );
}

function App() {
  // The ring's sweep is an animated value: animating it redraws without running `draw`.
  const progress = createAnimatedValue(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];

  const ring = () => (
    <Canvas
      style={{ width: 110, height: 110 }}
      draw={(cx, { width, height }) => {
        const centerX = width / 2;
        const cy = height / 2;
        const r = width / 2 - 8;
        cx.lineWidth = 10;
        cx.lineCap = "round";
        cx.strokeStyle = ui().border;
        cx.beginPath();
        cx.arc(centerX, cy, r, 0, TAU);
        cx.stroke();
        const g = cx.createConicGradient(-Math.PI / 2, centerX, cy);
        g.addColorStop(0, ui().up);
        g.addColorStop(1, ui().accent);
        cx.strokeStyle = g;
        cx.beginPath();
        cx.arc(centerX, cy, r, -Math.PI / 2, progress.mul(TAU).sub(Math.PI / 2));
        cx.stroke();
        cx.fillStyle = ui().text;
        cx.font = "600 18px system-ui";
        cx.textAlign = "center";
        cx.textBaseline = "middle";
        cx.fillText(`${Math.round(target() * 100)}%`, centerX, cy);
      }}
    />
  );
  const heartCanvas = () => (
    <Canvas
      style={{ width: 48, height: 48 }}
      draw={(cx) => {
        cx.scale(2, 2);
        cx.shadowColor = tint(ui().down, 0.45);
        cx.shadowBlur = 4;
        cx.fillStyle = ui().down;
        cx.fill(heart);
      }}
    />
  );
  const chart = (height: number) => (
    <Canvas
      style={{ ...ui().inset, height, borderRadius: 12 }}
      draw={(cx, { 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 = cx.createLinearGradient(0, pad, 0, height - pad);
        area.addColorStop(0, tint(ui().accent, 0.28));
        area.addColorStop(1, tint(ui().accent, 0));
        cx.beginPath();
        values.forEach((v, i) => (i === 0 ? cx.moveTo(x(i), y(v)) : cx.lineTo(x(i), y(v))));
        cx.lineTo(x(values.length - 1), height - pad);
        cx.lineTo(x(0), height - pad);
        cx.closePath();
        cx.fillStyle = area;
        cx.fill();
        cx.beginPath();
        values.forEach((v, i) => (i === 0 ? cx.moveTo(x(i), y(v)) : cx.lineTo(x(i), y(v))));
        cx.strokeStyle = ui().accent;
        cx.lineWidth = 2.5;
        cx.lineJoin = "round";
        cx.stroke();
      }}
    />
  );
  const newData = () => <Button label="New data" onPress={() => data.set(data().map(() => Math.round(10 + Math.random() * 85)))} />;
  const pad = () => (
    <Canvas
      style={{ ...ui().inset, flex: 1, borderRadius: 12 }}
      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={(cx, { width }) => {
        cx.fillStyle = ui().faint;
        cx.font = "13px system-ui";
        cx.textAlign = "center";
        cx.fillText("Draw here", width / 2, 20);
        cx.strokeStyle = ui().text;
        cx.lineWidth = 3;
        cx.lineCap = "round";
        cx.lineJoin = "round";
        for (const s of strokes()) {
          cx.beginPath();
          s.forEach(([px, py], i) => (i === 0 ? cx.moveTo(px, py) : cx.lineTo(px, py)));
          cx.stroke();
        }
      }}
    />
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 16, paddingTop: 24, gap: 14 }}>
      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <View style={{ flex: 1, minWidth: 0, gap: 14 }}>
              {chart(200)}
              {pad()}
            </View>
            <Inspector>
              <Panel title="An animated value in draw">
                <View style={{ gap: 10 }}>
                  <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
                    {ring()}
                    {heartCanvas()}
                  </View>
                  <Button label="Animate the ring" onPress={go} />
                </View>
              </Panel>
              <Panel title="A signal in draw">
                <View style={{ gap: 10 }}>
                  <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>[{data().join(", ")}]</Text>
                  {newData()}
                </View>
              </Panel>
              <Panel title="Responder events">
                <View style={{ gap: 10 }}>
                  <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
                    {strokes().length} strokes · {strokes().reduce((n, s) => n + s.length, 0)} points
                  </Text>
                  <Button label="Clear" onPress={() => strokes.set([])} />
                </View>
              </Panel>
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
              {ring()}
              <View style={{ gap: 10 }}>
                <Button label="Animate the ring" onPress={go} />
                {heartCanvas()}
              </View>
            </View>
            {chart(150)}
            {newData()}
            {pad()}
          </>
        )
      }
    </View>
  );
}

mount(App);
use std::f32::consts::TAU;

use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let area = signal((0.0_f32, 0.0_f32));
    view().flex_1().p_6().pt(px(60.0)).child(
        canvas(move || {
            let (w, h) = area.get();
            Painter::new()
                .fill_color(rgb(0x0969da))
                .begin_path()
                .arc(w / 2.0, h / 2.0, 60.0, 0.0, TAU)
                .fill()
                .stroke_color(rgb(0x1f2328))
                .line_width(4.0)
                .stroke_rect(bounds(point(px(20.0), px(20.0)), size(px(w - 40.0), px(h - 40.0))))
        })
        .h(px(200.0))
        .rounded(px(12.0))
        .bg(white())
        .on_layout(move |frame: &Bounds<Points>| area.set((frame.size.width.0, frame.size.height.0))),
    )
}

Un canvas acepta todo lo que acepta una vista (estilos, clases, manejadores táctiles, accesibilidad, refs) además de su dibujo. Necesita un tamaño que provenga de su estilo o de su layout; su contenido es el dibujo. Las coordenadas están en puntos, con el origen en la esquina superior izquierda.

#Cuándo se hace el dibujo

En Rust, canvas(move || ..) registra dentro de un binding: el closure se ejecuta una vez, y de nuevo cuando cambia un signal que haya leído. Un dibujo que depende del tamaño del canvas guarda en un signal el tamaño obtenido de .on_layout(..) y lo lee, como se muestra arriba. En TSX, draw(cx, { width, height }) se ejecuta:

  • una vez que se conoce el tamaño del canvas (después de su primer layout);
  • de nuevo cuando se cambia el tamaño del canvas;
  • de nuevo cuando cambia un signal que haya leído.

Cada ejecución reemplaza todo el dibujo. Leer signals mientras se dibuja es la forma en que un canvas sigue tus datos:

let values = signal(vec![12.0_f32, 30.0, 22.0, 48.0, 36.0, 60.0]);
let width = signal(0.0_f32);
let height = 160.0;

canvas(move || {
    let w = width.get();
    values.with(|v| {
        let x = |i: usize| i as f32 / (v.len() - 1) as f32 * w;
        let y = |n: f32| height - n / 100.0 * height;
        let mut p = Painter::new().begin_path().move_to(x(0), y(v[0]));
        for (i, &n) in v.iter().enumerate().skip(1) {
            p = p.line_to(x(i), y(n));
        }
        p.stroke_color(rgb(0x3b82f6)).line_width(2.5).line_join(LineJoin::Round).stroke()
    })
})
.h(px(height))
.on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))

Las llamadas de dibujo solo registran: el dibujo lo hace la plataforma cuando termina el registro, por lo que no se pueden leer los píxeles de vuelta. El registro se envía a la plataforma como una sola operación.

#Las operaciones 2D

Las operaciones son un subconjunto de la HTML Canvas 2D API: CanvasRenderingContext2D en TSX, y los métodos de Painter en Rust, con nombres de Rust (begin_path, fill_rect, line_width, set_transform, draw_image, …; colores mediante fill_color / stroke_color, degradados mediante fill_style / stroke_style). Lo que se admite:

Estado

Miembro Rust Descripción
save(), restore() .save(), .restore() Apilan y desapilan el estado de dibujo (estilos, transformación, recorte).
fillStyle, strokeStyle .fill_color(..), .fill_style(..), .stroke_color(..), .stroke_style(..) Un color CSS, un degradado o un valor animado que interpola colores.
lineWidth, lineCap, lineJoin, miterLimit .line_width(..), .line_cap(..), .line_join(..), .miter_limit(..) Forma del trazo.
setLineDash(segments), getLineDash(), lineDashOffset .line_dash(..), .line_dash_offset(..) Guiones.
globalAlpha .global_alpha(..) Opacidad de lo que se dibuja a continuación, de 0 a 1.
globalCompositeOperation .composite(Composite::DestinationOut) Cómo se combina: "source-over", "destination-out", "multiply", "screen", …
shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY .shadow(color, blur, offset) Sombras.
font .font(..) Forma abreviada font de CSS: "600 14px Inter" (tamaños en px, pt o rem); small-caps y las formas numéricas tabular-nums, oldstyle-nums, … pueden ir antes del tamaño.
fontVariantNumeric .font_variant_numeric(..) "tabular-nums" hace que todas las cifras tengan el mismo ancho, de modo que los números cambiantes (contadores, precios, etiquetas de ejes) no tiemblen; también "proportional-nums", "oldstyle-nums", "lining-nums", "normal". Es una adición de NSH (el font-variant-numeric de CSS); al asignar font se conserva. Los navegadores la ignoran.
textAlign, textBaseline .text_align(..), .text_baseline(..) Posición del texto.

Transformaciones: translate(x, y), scale(x, y), rotate(angle) (radianes, en sentido horario), transform(a, b, c, d, e, f), setTransform(a, b, c, d, e, f), resetTransform().

Paths: beginPath(), moveTo, lineTo, quadraticCurveTo, bezierCurveTo, arc, arcTo, ellipse, rect, roundRect(x, y, w, h, radii), closePath(); luego fill(rule?), stroke() y clip(rule?), cada uno de los cuales también acepta un path (fill_path, stroke_path, clip_path en Rust). Las reglas de relleno son "nonzero" y "evenodd".

Rectángulos: fillRect, strokeRect, clearRect.

Texto: fillText(text, x, y, maxWidth?), strokeText(...), measureText(text) (medido de forma síncrona por el motor de texto de la plataforma con el font actual; solo width). En Rust, measure_text(font, text) devuelve el ancho.

Hit testing: isPointInPath(path?, x, y, rule?) y isPointInStroke(path?, x, y) (path_contains, stroke_contains, is_point_in_path y is_point_in_stroke en un Painter) responden de forma síncrona, calculados a partir de la geometría registrada (la transformación actual, el path, lineWidth y lineCap). Conserva el dibujo y los paths que dibujaste para encontrar la forma que queda bajo un toque:

let slices: Rc<Vec<Path>> = Rc::new(vec![svg_path("M50 50 L100 50 A50 50 0 0 1 50 100 Z") /* , ... */]);
let draw = {
    let slices = slices.clone();
    move || slices.iter().fold(Painter::new(), |p, s| p.fill_path(s, FillRule::NonZero))
};

canvas(draw.clone())
    .size(px(100.0))
    .claim_touches(TouchClaim::on_start())
    .on_responder_release(move |e: &ResponderEvent| {
        let drawing = draw();
        if let Some(hit) = slices.iter().position(|s| drawing.path_contains(s, e.location, FillRule::NonZero)) {
            selected.set(Some(hit));
        }
    })

Los trazos se comprueban con uniones redondeadas y sin guiones; las coordenadas dadas como valores animados cuentan como 0.

Degradados: createLinearGradient, createRadialGradient, createConicGradient, cada uno con addColorStop(offset, color) (CanvasGradient::linear, radial, conic y add_color_stop en Rust).

Imágenes: drawImage(image, dx, dy), drawImage(image, dx, dy, dw, dh), drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) (draw_image(url, dest) y draw_image_part(..) en Rust).

cx.canvas.width y cx.canvas.height devuelven el tamaño del canvas en puntos en TSX.

El conjunto admitido: operaciones de dibujo. Los píxeles los escribe la plataforma, por lo que getImageData / putImageData y cualquier otro acceso a píxeles quedan fuera, al igual que filter y las imágenes SVG en drawImage. Un color o una fuente no válidos descartan esa operación concreta, como en HTML Canvas.

#Paths y datos de path SVG

Un path construido una sola vez puede rellenarse, trazarse o usarse como recorte muchas veces: svg_path(d) en Rust, Path2D en TSX. Ambos aceptan datos de path SVG, que se analizan de forma nativa, lo que facilita crear iconos:

use nsh_core::canvas::{FillRule, svg_path};

const HEART: &str = "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";

fn heart(size: f32, color: Rgba) -> impl IntoElement {
    let path = svg_path(HEART);
    canvas(
        Painter::new()
            .scale(size / 24.0, size / 24.0) // the path is drawn on a 24-point grid
            .fill_color(color)
            .fill_path(&path, FillRule::NonZero),
    )
    .size(px(size))
}

Un Path2D tiene los mismos métodos de path que el contexto (moveTo, arc, roundRect, …) y addPath(other). new Path2D(other) copia un path.

#Degradados

canvas(move || {
    let (w, h) = (width.get(), 120.0);
    let linear = CanvasGradient::linear(point(px(0.0), px(0.0)), point(px(w), px(0.0)))
        .add_color_stop(percentage(0.0), rgb(0x22d3ee))
        .add_color_stop(percentage(1.0), rgb(0x6366f1));
    let center = point(px(w / 2.0), px(h * 0.75));
    let radial = CanvasGradient::radial(center, px(4.0), center, px(40.0))
        .add_color_stop(percentage(0.0), rgb(0xfde68a))
        .add_color_stop(percentage(1.0), rgb(0xf97316));
    Painter::new()
        .fill_style(linear)
        .fill_rect(0.0, 0.0, w, h / 2.0)
        .fill_style(radial)
        .begin_path()
        .arc(w / 2.0, h * 0.75, 28.0, 0.0, TAU)
        .fill()
})
.h(px(120.0))
.on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))

Un degradado cónico (createConicGradient(startAngle, x, y), CanvasGradient::conic(start_angle, center)) recorre los colores alrededor de un punto; su ángulo inicial está en radianes, en sentido horario desde el eje x. Un degradado cónico sobre un arco trazado produce un anillo de progreso con degradado.

#Texto

Painter::new()
    .font("600 20px system-ui")
    .fill_color(rgb(0x1f2328))
    .text_align(CanvasTextAlign::Center)
    .text_baseline(CanvasTextBaseline::Middle)
    .fill_text("72%", width / 2.0, height / 2.0)
    .font("11px system-ui")
    .fill_text("100", 32.0 - 6.0 - measure_text("11px system-ui", "100").0, 16.0)

Las familias tipográficas son las mismas que en los estilos: familias del sistema y los nombres de las fuentes que incluye tu app (consulta Fuentes).

#Imágenes

Dibujar una imagen requiere una URL en Rust (la de un asset es LOGO.url), y una URL o un asset importado (import logo from "./logo.png") en TSX. La imagen se carga de forma nativa y el canvas vuelve a dibujar cuando termina de cargarse, por lo que no necesitas seguir la carga tú mismo. Las URL remotas están sujetas a las capacidades de red de la app.

Los canvas en iOS, macOS, Android, Windows, Linux y GPUI dibujan imágenes de mapa de bits (PNG, JPEG, GIF, WebP, …); en ellos, una fuente SVG es una imagen que no se carga, y el log la nombra. Muestra las imágenes SVG con <Image>, o dibuja sus formas como paths Path2D. En la web, un canvas dibuja lo que dibuja el navegador, SVG incluido.

const LOGO: ImageAsset = asset!("logo.png");

canvas(
    Painter::new()
        .save()
        .begin_path()
        .arc(60.0, 60.0, 56.0, 0.0, TAU)
        .clip() // a round avatar
        .draw_image(LOGO.url, bounds(point(px(4.0), px(4.0)), size(px(112.0), px(112.0))))
        .restore(),
)
.size(px(120.0))

La forma de nueve argumentos (draw_image_part) toma el rectángulo de origen en los píxeles de la imagen.

#Controlar los dibujos con valores animados

Cualquier argumento numérico de una llamada de dibujo, y los paints de relleno y de trazo, pueden ser un valor animado o un nodo derivado de él. Estos argumentos se leen mientras la plataforma dibuja y, cuando el valor se anima, la plataforma vuelve a dibujar por sí sola, en cada fotograma, sin registrar el dibujo de nuevo ni ejecutar nada de tu código.

use std::f32::consts::{FRAC_PI_2, TAU};

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

pub fn ring() -> impl IntoElement {
    let progress = animated_value(0.3);
    let label = signal(30_u32);
    let go = move |to: f32| {
        label.set((to * 100.0).round() as u32);
        progress.timing(to, 900.0, Easing::Curve(Curve::Cubic, EaseMode::InOut));
    };
    let (center_x, cy, r) = (70.0, 70.0, 60.0);
    view()
        .flex_1()
        .items_center()
        .justify_center()
        .gap_4()
        .child(
            canvas(move || {
                // The end angle reads the animated value on every frame.
                let end = progress.mul(TAU).sub(FRAC_PI_2);
                Painter::new()
                    .line_width(12.0)
                    .line_cap(LineCap::Round)
                    .stroke_color(rgb(0xe5e7eb))
                    .begin_path()
                    .arc(center_x, cy, r, 0.0, TAU)
                    .stroke()
                    .stroke_color(rgb(0x6366f1))
                    .begin_path()
                    .arc(center_x, cy, r, -FRAC_PI_2, &end)
                    .stroke()
                    .fill_color(rgb(0x1f2328))
                    .font("600 22px system-ui")
                    .text_align(CanvasTextAlign::Center)
                    .text_baseline(CanvasTextBaseline::Middle)
                    .fill_text(&format!("{}%", label.get()), center_x, cy)
            })
            .size(px(140.0)),
        )
        .child(
            text("Animate")
                .text_color(rgb(0x0969da))
                .on_press(move || go(if label.get_untracked() < 50 { 0.85 } else { 0.2 })),
        )
}

El ángulo final del arco es progress.mul(2π).sub(π/2): un nodo, de modo que el anillo avanza de forma nativa durante 900 ms mientras el dibujo se registró una sola vez (y una vez más para la etiqueta, que lee un signal). La manecilla de los segundos de un reloj funciona igual: rotate(seconds.mul(π / 30)) con seconds animado de forma lineal.

Los colores se animan de la misma manera: .fill_style(&t.interpolate_colors(&[0.0, 1.0], &[rgb(0x54aeff), rgb(0xcf222e)])) en Rust, cx.fillStyle = t.interpolate({ inputRange: [0, 1], outputRange: ["#54aeff", "#cf222e"] }) en TSX.

#Toques en un canvas

Un canvas es una vista, por lo que acepta pulsaciones y el responder system. El dibujo con el dedo registra puntos en un signal y los dibuja:

let strokes = signal(Vec::<Vec<(f32, f32)>>::new());

canvas(move || {
    strokes.with(|strokes| {
        let mut p = Painter::new().line_width(3.0).line_cap(LineCap::Round).line_join(LineJoin::Round);
        for stroke in strokes {
            p = p.begin_path();
            for (i, &(x, y)) in stroke.iter().enumerate() {
                p = if i == 0 { p.move_to(x, y) } else { p.line_to(x, y) };
            }
            p = p.stroke();
        }
        p
    })
})
.h(px(240.0))
.claim_touches(TouchClaim::on_start())
.keep_touches(true) // a scroll view around must not take the touches
.on_responder_grant(move |e: &ResponderEvent| {
    strokes.update(|s| s.push(vec![(e.location.x.0, e.location.y.0)]))
})
.on_responder_move(move |e: &ResponderEvent| {
    strokes.update(|s| {
        if let Some(stroke) = s.last_mut() {
            stroke.push((e.location.x.0, e.location.y.0));
        }
    })
})

#Rendimiento

  • Un dibujo se registra en Rust (en TSX, como llamadas JS al contexto) y viaja a la plataforma como una sola operación; no hay cruce del bridge por cada llamada.
  • Cada dibujo vuelve a registrar todo. Mantén el trabajo proporcional a lo que se muestra, y guarda los datos que cambian en cada fotograma en valores animados en lugar de en signals.
  • Divide un dibujo que tiene una parte estática y otra que cambia con frecuencia en dos canvas apilados uno encima del otro, de modo que solo se vuelva a dibujar uno.
  • Dentro de un scroll view con zoom, los canvas se vuelven a dibujar a la resolución ampliada cuando termina el zoom.

#Relacionado