Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Documentazione

Canvas

Un canvas è una view in cui si disegna con le operazioni dell’API HTML Canvas 2D: grafici, anelli di progresso, indicatori, firme, icone personalizzate, qualsiasi cosa sia più semplice da disegnare che da costruire con delle view. In Rust, canvas(..) mostra ciò che un Painter ha registrato; in TSX, Canvas da nsh accetta una funzione draw che registra il disegno in un’unica passata. La piattaforma lo riproduce in modo nativo (Core Graphics su iOS, android.graphics.Canvas su Android) alla risoluzione dello schermo, quindi i disegni restano nitidi a qualsiasi densità di pixel.

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 accetta tutto ciò che accetta una view (stili, classi, gestori di tocco, accessibilità, ref) più il proprio disegno. Ha bisogno di una dimensione dal suo stile o dal layout; il suo contenuto è il disegno. Le coordinate sono in punti, con l’origine in alto a sinistra.

#Quando viene eseguito il disegno

In Rust, canvas(move || ..) registra all’interno di un binding: la closure viene eseguita una volta, e di nuovo quando cambia un signal che ha letto. Un disegno che dipende dalla dimensione del canvas mantiene in un signal la dimensione ottenuta da .on_layout(..) e la legge, come sopra. In TSX, draw(cx, { width, height }) viene eseguito:

  • una volta che la dimensione del canvas è nota (dopo il primo layout);
  • di nuovo quando il canvas viene ridimensionato;
  • di nuovo quando cambia un signal che ha letto.

Ogni esecuzione sostituisce l’intero disegno. Leggere i signal durante il disegno è il modo in cui un canvas segue i tuoi dati:

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

Le chiamate di disegno si limitano a registrare: il disegno viene realizzato dalla piattaforma al termine della registrazione, quindi i pixel non possono essere riletti. La registrazione viene inviata alla piattaforma come un’unica operazione.

#Le operazioni 2D

Le operazioni sono un sottoinsieme dell’API HTML Canvas 2D: CanvasRenderingContext2D in TSX, e i metodi di Painter in Rust, con nomi in stile Rust (begin_path, fill_rect, line_width, set_transform, draw_image, …; colori tramite fill_color / stroke_color, gradienti tramite fill_style / stroke_style). Ciò che è supportato:

Stato

MembroRustDescrizione
save(), restore().save(), .restore()Salvano e ripristinano lo stato di disegno (stili, trasformazione, clip).
fillStyle, strokeStyle.fill_color(..), .fill_style(..), .stroke_color(..), .stroke_style(..)Un colore CSS, un gradiente o un valore animato che interpola i colori.
lineWidth, lineCap, lineJoin, miterLimit.line_width(..), .line_cap(..), .line_join(..), .miter_limit(..)Forma del tratto.
setLineDash(segments), getLineDash(), lineDashOffset.line_dash(..), .line_dash_offset(..)Tratteggi.
globalAlpha.global_alpha(..)Opacità di ciò che viene disegnato dopo, da 0 a 1.
globalCompositeOperation.composite(Composite::DestinationOut)Come si combina: "source-over", "destination-out", "multiply", "screen", …
shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY.shadow(color, blur, offset)Ombre.
font.font(..)Forma abbreviata CSS font: "600 14px Inter" (dimensioni in px, pt o rem); small-caps e le forme numeriche tabular-nums, oldstyle-nums, … possono precedere la dimensione.
fontVariantNumeric.font_variant_numeric(..)"tabular-nums" rende tutte le cifre della stessa larghezza, così i numeri che cambiano (contatori, prezzi, etichette degli assi) non tremolano; anche "proportional-nums", "oldstyle-nums", "lining-nums", "normal". Un’aggiunta di NSH (font-variant-numeric di CSS); impostare font lo mantiene. I browser lo ignorano.
textAlign, textBaseline.text_align(..), .text_baseline(..)Posizionamento del testo.

Trasformazioni: translate(x, y), scale(x, y), rotate(angle) (radianti, in senso orario), transform(a, b, c, d, e, f), setTransform(a, b, c, d, e, f), resetTransform().

Percorsi: beginPath(), moveTo, lineTo, quadraticCurveTo, bezierCurveTo, arc, arcTo, ellipse, rect, roundRect(x, y, w, h, radii), closePath(); poi fill(rule?), stroke() e clip(rule?), ciascuno accettabile anche con un percorso (fill_path, stroke_path, clip_path in Rust). Le regole di riempimento sono "nonzero" e "evenodd".

Rettangoli: fillRect, strokeRect, clearRect.

Testo: fillText(text, x, y, maxWidth?), strokeText(...), measureText(text) (misurato in modo sincrono dal motore di testo della piattaforma con il font corrente; solo width). In Rust, measure_text(font, text) restituisce la larghezza.

Hit testing: isPointInPath(path?, x, y, rule?) e isPointInStroke(path?, x, y) (path_contains, stroke_contains, is_point_in_path e is_point_in_stroke su un Painter) rispondono in modo sincrono, calcolati dalla geometria registrata (la trasformazione corrente, il percorso, lineWidth e lineCap). Conserva il disegno e i percorsi che hai disegnato per trovare la forma sotto un tocco:

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

I tratti vengono verificati con giunzioni arrotondate e senza tratteggi; le coordinate fornite come valori animati contano come 0.

Gradienti: createLinearGradient, createRadialGradient, createConicGradient, ciascuno con addColorStop(offset, color) (CanvasGradient::linear, radial, conic e add_color_stop in Rust).

Immagini: drawImage(image, dx, dy), drawImage(image, dx, dy, dw, dh), drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) (draw_image(url, dest) e draw_image_part(..) in Rust).

cx.canvas.width e cx.canvas.height restituiscono la dimensione del canvas in punti in TSX.

L’insieme supportato: operazioni di disegno. I pixel sono scritti dalla piattaforma, quindi getImageData / putImageData e altri accessi ai pixel ne sono esclusi, così come filter e le immagini SVG in drawImage. Un colore o un font non valido scarta quella singola operazione, come in HTML Canvas.

#Percorsi e dati di percorso SVG

Un percorso costruito una sola volta può essere riempito, tracciato o usato come clip molte volte: svg_path(d) in Rust, Path2D in TSX. Entrambi accettano dati di percorso SVG, analizzati in modo nativo, il che semplifica la creazione di icone:

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 ha gli stessi metodi di percorso del contesto (moveTo, arc, roundRect, …) e addPath(other). new Path2D(other) copia un percorso.

#Gradienti

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 gradiente conico (createConicGradient(startAngle, x, y), CanvasGradient::conic(start_angle, center)) fa ruotare i colori attorno a un punto; il suo angolo iniziale è in radianti, in senso orario a partire dall’asse x. Un gradiente conico su un arco tracciato produce un anello di progresso con gradiente.

#Testo

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)

Le famiglie di font sono le stesse degli stili: famiglie di sistema e nomi dei font inclusi nella tua app (vedi Fonts).

#Immagini

Per disegnare un’immagine serve un URL in Rust (quello di un asset è LOGO.url), e un URL o un asset importato (import logo from "./logo.png") in TSX. L’immagine viene caricata in modo nativo e il canvas ridisegna una volta completato il caricamento, quindi non devi tenere traccia del caricamento. Gli URL remoti sono soggetti alle network capabilities dell’app.

I canvas su iOS, macOS, Android, Windows, Linux e GPUI disegnano immagini bitmap (PNG, JPEG, GIF, WebP, …); su queste piattaforme, una sorgente SVG è un’immagine che non si carica, e il log ne riporta il nome. Mostra le immagini SVG con <Image>, oppure disegna le loro forme come percorsi Path2D. Sul web, un canvas disegna ciò che disegna il browser, SVG incluso.

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 a nove argomenti (draw_image_part) accetta il rettangolo sorgente nei pixel dell’immagine.

#Guidare i disegni con valori animati

Qualsiasi argomento numerico di una chiamata di disegno, e i paint di riempimento e di tratto, può essere un valore animato o un nodo da esso derivato. Tali argomenti vengono letti mentre la piattaforma disegna e, quando il valore si anima, la piattaforma ridisegna da sola, a ogni frame, senza registrare di nuovo il disegno e senza eseguire alcun tuo codice.

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

L’angolo finale dell’arco è progress.mul(2π).sub(π/2): un nodo, quindi l’anello si espande in modo nativo per 900 ms mentre il disegno è stato registrato una sola volta (e una seconda volta per l’etichetta, che legge un signal). La lancetta dei secondi di un orologio funziona allo stesso modo: rotate(seconds.mul(π / 30)) con seconds animato in modo lineare.

I colori si animano nello stesso modo: .fill_style(&t.interpolate_colors(&[0.0, 1.0], &[rgb(0x54aeff), rgb(0xcf222e)])) in Rust, cx.fillStyle = t.interpolate({ inputRange: [0, 1], outputRange: ["#54aeff", "#cf222e"] }) in TSX.

#Tocco su un canvas

Un canvas è una view, quindi accetta pressioni e il responder system. Il disegno a mano libera registra i punti in un signal e li disegna:

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

#Prestazioni

  • Un disegno viene registrato in Rust (in TSX, come chiamate JS al contesto) e arriva alla piattaforma come un’unica operazione; non c’è alcun attraversamento del bridge per ogni chiamata.
  • Ogni disegno registra di nuovo tutto. Mantieni il lavoro proporzionale a ciò che viene mostrato, e tieni i dati che cambiano a ogni frame in valori animati anziché in signal.
  • Dividi un disegno che ha una parte statica e una che cambia di frequente in due canvas sovrapposti, così se ne ridisegna uno solo.
  • All’interno di una scroll view con zoom, i canvas vengono ridisegnati alla risoluzione dello zoom al termine dello zoom.

#Correlati