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

Documentazione

Moduli e tastiera

L’inserimento di testo avviene tramite l’elemento input, input() in Rust e <Input> in TSX: una UITextField o una UITextView su iOS, una EditText su Android. Digitazione, caret, selezione, correzione automatica e autofill sono quelli nativi della piattaforma; la tua app riceve il testo tramite eventi e può controllarlo. NSH mantiene inoltre da solo gli input con il focus sopra la tastiera software e ti fornisce l’altezza della tastiera come valore reattivo per i casi che non riesce a gestire da sé.

guide-forms.tsx
guide-forms.tsxLive
import { type InputRef, type JSX, mount, Scroll, signal, Text, View } from "nsh";
import { Input } from "nsh/base";
import { button, buttonText, desktop, field, Inspector, Panel, Stage, ui, wide } from "./palette";

const label = () => ({ fontSize: 13, color: ui().muted });

function App() {
  const name = signal("");
  const email = signal("");
  const code = signal("");
  const notes = signal("");
  const notesHeight = signal(0);
  const focused = signal("nothing");
  const sent = signal("");
  let emailInput!: InputRef;
  let passwordInput!: InputRef;
  let notesInput!: InputRef;

  let tries = 0;
  const submit = () => sent.set(`#${++tries}: signed up ${name() || "nobody"} <${email() || "no e-mail"}>`);

  type Style = () => object;
  const nameInput = (style?: Style) => (
    <Input
      {...field({ style })}
      value={name()}
      onChange={({ value }) => name.set(value)}
      placeholder="Ada Lovelace"
      autoCapitalize="words"
      autoComplete="name"
      returnKeyType="next"
      submitBehavior="submit"
      onSubmit={() => emailInput.focus()}
      onFocus={() => focused.set("name")}
    />
  );
  const emailField = (style?: Style) => (
    <Input
      ref={emailInput}
      {...field({ style })}
      value={email()}
      onChange={({ value }) => email.set(value.trim().toLowerCase())}
      placeholder="ada@example.com"
      keyboardType="email-address"
      autoCapitalize="none"
      autoCorrect={false}
      autoComplete="email"
      returnKeyType="next"
      submitBehavior="submit"
      onSubmit={() => passwordInput.focus()}
      onFocus={() => focused.set("e-mail")}
    />
  );
  const passwordField = (style?: Style) => (
    <Input
      ref={passwordInput}
      {...field({ style })}
      masked
      autoComplete="new-password"
      placeholder="At least 8 characters"
      returnKeyType="next"
      submitBehavior="submit"
      onSubmit={() => notesInput.focus()}
      onFocus={() => focused.set("password")}
    />
  );
  const codeField = (style?: Style) => (
    <Input
      {...field({ style })}
      value={code()}
      onChange={({ value }) => code.set(value.replace(/[^0-9]/g, ""))}
      keyboardType="number-pad"
      autoComplete="one-time-code"
      maxLength={6}
      onFocus={() => focused.set("code")}
    />
  );
  const notesField = (style?: Style) => (
    <Input
      ref={notesInput}
      multiline
      maxLength={200}
      value={notes()}
      onChange={({ value }) => notes.set(value)}
      onContentSizeChange={({ size }) => notesHeight.set(Math.min(size.height, 120))}
      placeholder="Return adds a line"
      {...field({ style })}
      onFocus={() => focused.set("notes")}
      onBlur={() => focused.set("nothing")}
    />
  );

  // A phone: one scrolling column, full-width fields and button.
  const column = () => (
    <Scroll style={{ flex: 1, backgroundColor: ui().page }} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled">
      <View style={{ padding: 16, paddingTop: 24, gap: 12 }}>
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>Sign up</Text>
        <Text style={label()}>Focused: {focused()}</Text>

        <Text style={label()}>Name (controlled, return goes to the next field)</Text>
        {nameInput()}

        <Text style={label()}>E-mail</Text>
        {emailField()}

        <Text style={label()}>Password (uncontrolled)</Text>
        {passwordField()}

        <Text style={label()}>Code (number pad, 6 digits)</Text>
        {codeField(() => ({ letterSpacing: 8, textAlign: "center", fontSize: 22 }))}

        <Text style={label()}>About you (grows with its text, 200 max)</Text>
        {notesField(() => ({ height: Math.max(notesHeight(), 22) + 20 }))}
        <Text style={{ ...label(), textAlign: "right" }}>{`${notes().length}/200`}</Text>

        <View onPress={submit} {...button("primary")}>
          <Text style={buttonText("primary")}>Sign up</Text>
        </View>
        <Text style={{ color: ui().up }}>{sent()}</Text>
      </View>
    </Scroll>
  );

  // A wide window: a 360-point form, each label above its field, and its
  // state beside it.
  const group = (title: string, input: JSX.Element, help?: JSX.Element) => (
    <View style={{ gap: 6 }}>
      <Text style={{ fontSize: 13, fontWeight: "500", color: ui().text }}>{title}</Text>
      {input}
      {help}
    </View>
  );
  const form = () => (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>Sign up</Text>
      <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
        <Stage width={360} gap={14}>
          {group("Name", nameInput(), <Text style={label()}>Controlled; Return goes to the next field</Text>)}
          {group("E-mail", emailField())}
          {group("Password", passwordField(), <Text style={label()}>Uncontrolled</Text>)}
          {group("Code", codeField(() => ({ width: 160, letterSpacing: 6, textAlign: "center", fontSize: 16 })), <Text style={label()}>6 digits</Text>)}
          {group(
            "About you",
            notesField(() => ({ height: Math.max(notesHeight(), 20) + 14 })),
            <Text style={{ ...label(), textAlign: "right", fontVariant: ["tabular-nums"] }}>{`${notes().length}/200`}</Text>,
          )}
          <View onPress={submit} {...button("primary")}>
            <Text style={buttonText("primary")}>Sign up</Text>
          </View>
        </Stage>
        <Inspector>
          <Panel title="onFocus · onBlur">
            <Text style={{ fontSize: 13, color: ui().text }}>Focused: {focused()}</Text>
          </Panel>
          <Panel title="onSubmit" minHeight={64}>
            <Text style={{ fontSize: 13, color: ui().up }}>{sent()}</Text>
          </Panel>
          <Text style={{ fontSize: 12, color: ui().faint }}>
            {desktop ? "Tab moves between fields; Return in a field goes to the next one." : "Return goes to the next field."}
          </Text>
        </Inspector>
      </View>
    </View>
  );

  return <>{() => (wide() ? form() : column())}</>;
}

mount(App);

Un input accetta stili come qualsiasi vista (metodi di stile tipizzati in Rust, stili e classi Tailwind in TSX), quindi i campi di un form possono avere l’aspetto della tua app. Un campo con etichetta è un breve componente tuo:

use nsh_core::prelude::*;

fn field(label: &'static str, value: Signal<String>, masked: bool) -> impl IntoElement {
    let focused = signal(false);
    view()
        .gap_1()
        .child(text(label).text_sm().text_color(rgb(0x737373)))
        .child(
            input()
                .value(move || value.get())
                .on_change(move |text: &str| value.set(text.to_owned()))
                .masked(masked)
                .on_focus(move || focused.set(true))
                .on_blur(move || focused.set(false))
                .rounded_lg()
                .border_1()
                .px_3()
                .py_2()
                .text_base()
                .border_color(move || if focused.get() { rgb(0x0969da) } else { rgb(0xd0d7de) }),
        )
}

#Input controllati e non controllati

Un input è controllato quando gli passi il suo valore (.value(..), TSX value): mostra ciò che contiene il tuo signal e tu aggiorni il signal a partire dall’evento di modifica (.on_change(..), TSX onChange).

use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let name = signal(String::new());
    view()
        .flex_1()
        .p(px(16.0))
        .pt(px(60.0))
        .gap(px(8.0))
        .child(
            input()
                .value(move || name.get())
                .on_change(move |text: &str| name.set(text.to_owned()))
                .placeholder("Your name")
                .text_size(px(17.0))
                .border_1()
                .border_color(rgb(0xd0d7de))
                .rounded(px(10.0))
                .p(px(12.0)),
        )
        .child(text(move || {
            let name = name.get();
            format!("Hello, {}", if name.is_empty() { "stranger" } else { &name })
        }))
}

Poiché controlli il valore, puoi trasformare ciò che l’utente digita: .on_change(move |t: &str| tag.set(t.to_uppercase())) converte in maiuscolo, onChange={({ value }) => code.set(value.replace(/[^0-9]/g, ""))} mantiene solo le cifre.

#Metodi di input

I metodi di input cinesi, giapponesi e coreani compongono il testo prima di confermarlo: il pinyin diventa 腾 una volta scelto un carattere. onCompositionChange (Rust .on_composition_change(..)) riporta la composizione man mano che procede, { composing: true, text: "teng" } mentre l’utente digita e { composing: false, text: "" } quando termina; il testo confermato arriva a onChange prima della fine. Su iOS, Android, macOS e sul web, onChange riporta anche il testo in composizione, poiché quei sistemi lo mantengono nell’input; su Windows, Linux e GPUI riporta il testo confermato. Un input controllato mantiene attiva la composizione qualunque cosa faccia l’app con il valore: un value impostato mentre l’utente compone viene mostrato al termine della composizione, quindi un input che converte in maiuscolo o filtra il proprio testo accetta il pinyin così com’è digitato.

Una casella di ricerca che cerca solo il testo confermato:

let query = signal(String::new());
let composing = signal(false);
input()
    .value(move || query.get())
    .on_change(move |text: &str| {
        query.set(text.to_owned());
        if !composing.get() {
            search(text);
        }
    })
    .on_composition_change(move |e: &Composition| {
        composing.set(e.composing);
        if !e.composing {
            search(&query.get());
        }
    })

Un input è non controllato quando non ha un valore: mantiene il proprio testo, a partire dal valore predefinito (.default_value(..), TSX defaultValue), e lo leggi dall’evento di modifica o di invio quando ti serve. Usalo per i campi che leggi solo all’invio, come una password.

#Props

In Rust, ogni prop è un metodo di input() con nome in snake case (.placeholder(..), .masked(true), .max_length(6), .return_key_type(ReturnKeyType::Next), .selected_range((0, 4))), con questi nomi particolari: .keyboard_type(KeyboardType::Email) e le altre varianti di KeyboardType, .auto_fill(AutoFill::Email) per autoComplete, .keyboard_appearance(..) e .input_accessory(..) per le props ios={{ … }}. Ogni evento è un metodo di on_: .on_change(|text: &str| ..), .on_submit(|text: &str| ..), .on_focus(..), .on_blur(..), .on_selection_change(|s: &Selection| ..), .on_key_press(|key: &str| ..), .on_content_size_change(|size: &Size<Points>| ..).

PropTipoPredefinitoDescrizione
valuestringIl testo, controllato da JS.
defaultValuestringIl testo iniziale di un input non controllato.
placeholderstringMostrato finché l’input è vuoto.
maskedbooleanfalseNasconde il testo (password).
keyboardTypeKeyboardType"default"Il layout della tastiera.
returnKeyTypeReturnKeyType"default"L’etichetta del tasto di invio.
autoCapitalize"none" | "sentences" | "words" | "characters""sentences"Maiuscole automatiche.
autoCorrectbooleantrueLe correzioni del sistema.
autoCompleteAutoCompleteCosa contiene l’input, per l’autofill.
multilinebooleanfalsePiù righe; il tasto di invio inserisce un’interruzione di riga a meno che submitBehavior non indichi altro.
maxLengthnumberIl massimo numero di caratteri che l’utente può inserire.
readonlybooleanfalseL’utente può selezionare e copiare il testo ma non modificarlo.
autoFocusbooleanfalseRiceve il focus quando appare.
submitBehavior"submit" | "blurAndSubmit" | "newline"riga singola "blurAndSubmit", multilinea "newline"Cosa fa il tasto di invio: invia e mantiene il focus, invia e toglie il focus, oppure inserisce un’interruzione di riga (solo multilinea).
pasteBehavior"insert" | "report""insert"Cosa fa un incolla: inserisce il suo testo, oppure nulla (incolla).
caretbooleantrueIl caret viene disegnato; false lo nasconde.
ios={{ keyboardAppearance }}"default" | "light" | "dark""default"L’aspetto della tastiera (iOS).
selectedRange{ start: number; end?: number }La selezione controllata, in unità UTF-16.
allowFontScalingbooleantrueSegue la dimensione del testo di sistema.
maxFontSizeMultipliernumberIl limite superiore della scala di sistema (0: nessuno).
ios={{ inputAccessoryViewID }}stringiOS: la barra mostrata sopra la tastiera (vedi sotto).
ios={{ textContentType }}TextContentTypeI nomi di iOS per i valori di autoComplete.

Eventi:

EventoPayloadQuando
onChange{ value }Il testo è cambiato: value è il testo attuale.
onSubmit{ text }Il tasto di invio è stato premuto in un input che invia (riga singola, oppure submitBehavior "submit" / "blurAndSubmit").
onFocus / onBlurL’input ha ottenuto / perso il focus.
onSelectionChange{ start, end }Il caret o la selezione si sono spostati (unità UTF-16).
onPaste{ text, html, files, uris }L’utente ha incollato: testo, il suo HTML (Linux, web), immagini come File, URL di file locali (incolla).
onCaretMove{ bounds, pageBounds }Il caret si è spostato: dove si trova nell’input e nella finestra (il caret).
onKeyPress{ key }Prima che il testo cambi: key è il testo digitato, "Backspace" o "Enter".
onContentSizeChange{ size }La dimensione del testo (size) è cambiata (alla larghezza dell’input se multilinea), mentre viene digitato; un input vuoto riporta una riga vuota, qualunque sia il suo placeholder.
onCompositionChange{ composing, text }La composizione di un metodo di input è iniziata, cambiata o terminata (vedi Metodi di input).

Un input si stila come il testo: fontSize, color, fontFamily, textAlign, letterSpacing e il resto degli stili del testo, oltre a bordi, padding e sfondi. Lo stile di focus (.focus(|s| ..) in Rust, la variante Tailwind focus: o focusStyle in TSX) si applica mentre l’input ha il focus. Il colore del placeholder è .placeholder_color(..) (la chiave di stile placeholderColor, placeholder:text-*), quello del caret è .caret_color(..) (caretColor, caret-*) e quello dell’evidenziazione della selezione è .selection_color(..) (selectionColor, selection:text-*); UIKit colora il caret e la selezione con un unico colore, quello del caret se impostato:

input()
    .placeholder("Search")
    .rounded_lg()
    .border_1()
    .border_color(rgb(0xd4d4d8))
    .px_3()
    .py_2()
    .text_base()
    .focus(|s| s.border_color(rgb(0x3b82f6)))
    .placeholder_color(rgb(0xa1a1aa))
    .selection_color(rgb(0x2563eb))

#Tastiere e autofill

Il tipo di tastiera (.keyboard_type(..), TSX keyboardType) sceglie il layout:

RustTSXTastiera
KeyboardType::Default"default"Testo.
KeyboardType::Email"email-address"Testo con @ e . a portata di mano.
KeyboardType::Url"url"Testo con / e .com.
KeyboardType::Number"number-pad"Solo cifre: nessun segno, nessun separatore decimale (codici, PIN).
KeyboardType::Decimal"decimal-pad"Cifre e un separatore decimale.
KeyboardType::Numeric"numeric"Come in React Native: il tastierino decimale di iOS; su Android cifre con un segno e un separatore decimale.
KeyboardType::NumbersAndPunctuation"numbers-and-punctuation"Cifre, un segno meno e punteggiatura (la tastiera di numeri e punteggiatura di iOS; su Android cifre con un segno e un separatore decimale). Usala per i numeri negativi su iOS.
KeyboardType::Phone"phone-pad"Un tastierino telefonico.

Su iOS, .keyboard_appearance(KeyboardAppearance::Dark) (TSX ios={{ keyboardAppearance: "dark" }}; oppure light, predefinito) dà alla tastiera un aspetto chiaro o scuro indipendentemente dall’aspetto del sistema, utile per un’app scura in modalità chiara. Le tastiere di Android seguono sempre il tema del sistema.

L’autofill (.auto_fill(AutoFill::Email), TSX autoComplete) dice al sistema cosa contiene il campo, in modo che possa proporre nomi, indirizzi, password e codici monouso salvati provenienti dai messaggi. AutoFill di Rust ha una variante per ogni tipo (Name, GivenName, FamilyName, Email, Tel, Username, Password, NewPassword, OneTimeCode, StreetAddress, PostalCode, Country, CreditCardNumber, Url, Birthdate, …, e Off). TSX accetta questi valori: "name", "given-name", "family-name", "email", "tel", "username", "current-password", "new-password", "one-time-code", "street-address", "postal-code", "country", "cc-number", "cc-exp", "cc-csc", "url", "birthdate-full" e altri, oppure "off"; sono accettati anche i nomi propri di iOS ("emailAddress", "oneTimeCode", …), in autoComplete o ios={{ textContentType }}.

use nsh_core::element::AutoFill;

input()
    .keyboard_type(KeyboardType::Email)
    .auto_capitalize(AutoCapitalize::None)
    .auto_correct(false)
    .auto_fill(AutoFill::Email)
    .placeholder("E-mail")
input().masked(true).auto_fill(AutoFill::NewPassword).placeholder("Password")
input()
    .keyboard_type(KeyboardType::Number)
    .auto_fill(AutoFill::OneTimeCode)
    .max_length(6)
    .placeholder("Code")

#Il tasto di invio e lo spostamento tra i campi

Il tipo di tasto di invio (.return_key_type(ReturnKeyType::Next), TSX returnKeyType) ne definisce l’etichetta: default, done, go, next, search o send. Il comportamento di invio (.submit_behavior(..), TSX submitBehavior) stabilisce cosa succede quando lo si preme:

RustTSXInvioPredefinito per
SubmitBehavior::BlurAndSubmit"blurAndSubmit"genera l’evento di invio, poi l’input perde il focusinput a riga singola
SubmitBehavior::Submit"submit"genera l’evento di invio; l’input mantiene il focus
SubmitBehavior::Newline"newline"inserisce un’interruzione di riga (solo input multilinea)input multilinea

Un input multilinea con "submit" o "blurAndSubmit" invia alla pressione del tasto, come fanno i composer delle chat, e continua ad andare a capo; con una tastiera fisica (desktop, un Mac, la tastiera di un tablet) Shift+Invio oppure Option / Alt+Invio inserisce un’interruzione di riga. Un invio che conferma la composizione di un metodo di input (per esempio input cinese o giapponese) si limita a confermarla e non invia mai.

Per passare al campo successivo, conserva i ref degli input (element_ref() e .element_ref(&r) in Rust, ref in TSX) e chiama focus():

use nsh_core::prelude::*;

pub fn sign_in() -> impl IntoElement {
    let email = signal(String::new());
    let password = element_ref();
    let submit = move || log::info!("sign in {}", email.get());
    view()
        .flex_1()
        .p(px(16.0))
        .pt(px(60.0))
        .gap(px(12.0))
        .child(
            input()
                .value(move || email.get())
                .on_change(move |t: &str| email.set(t.to_owned()))
                .keyboard_type(KeyboardType::Email)
                .auto_capitalize(AutoCapitalize::None)
                .return_key_type(ReturnKeyType::Next)
                .submit_behavior(SubmitBehavior::Submit)
                .on_submit(move |_: &str| password.focus())
                .placeholder("E-mail"),
        )
        .child(
            input()
                .element_ref(&password)
                .masked(true)
                .return_key_type(ReturnKeyType::Go)
                .on_submit(move |_: &str| submit())
                .placeholder("Password"),
        )
}

Inviare senza togliere il focus (SubmitBehavior::Submit, submitBehavior="submit") sul primo campo mantiene la tastiera visibile mentre il focus si sposta, così non si abbassa e non risale. Un input di chat che invia con il tasto di invio e resta con il focus invia allo stesso modo (vedi il composer più sotto).

Sia la lunghezza massima sia il comportamento di invio sono applicati in modo nativo: la piattaforma chiede «questo carattere può essere aggiunto?» e «l’invio deve terminare la modifica?» mentre l’utente digita, e NSH risponde in base alle props senza attendere JS.

#Il focus da codice

Il ref di un input offre:

RustTSXDescrizione
focus()focus()Dà il focus all’input e mostra la tastiera.
blur()blur()Gli toglie il focus.
set_selection(start, end)setSelection(start, end?)Seleziona start..end (unità UTF-16); un caret quando sono uguali (TSX: senza end).

Su macOS e sul web, focus() e blur() sul ref di una vista che accetta le pressioni spostano il focus da tastiera come fa Tab (focus da tastiera).

dismiss_keyboard() in Rust e Keyboard.dismiss() da nsh in TSX tolgono il focus a qualunque input lo abbia:

view().on_press(|| dismiss_keyboard())

#Selezione

L’evento di selezione (.on_selection_change(..), TSX onSelectionChange) riporta il caret e la selezione; il set_selection del ref o l’intervallo selezionato (.selected_range(..), TSX selectedRange) li impostano. Le posizioni sono unità di codice UTF-16, come gli indici delle stringhe JavaScript, quindi in TSX text.slice(start, end) è il testo selezionato; in Rust, ritaglia le unità UTF-16 del testo.

/// `start..end` of `text` in UTF-16 units, as inputs count.
fn utf16_slice(text: &str, start: u32, end: u32) -> String {
    let units: Vec<u16> = text.encode_utf16().collect();
    let end = (end as usize).min(units.len());
    String::from_utf16_lossy(&units[(start as usize).min(end)..end])
}

let content = signal("Select part of this text".to_owned());
let range = signal(Selection::default());
let field = element_ref();

view()
    .child(
        input()
            .element_ref(&field)
            .value(move || content.get())
            .on_change(move |t: &str| content.set(t.to_owned()))
            .on_selection_change(move |r: &Selection| range.set(*r)),
    )
    .child(text(move || {
        let r = range.get();
        format!("Selected: \"{}\"", content.with(|t| utf16_slice(t, r.start, r.end)))
    }))
    .child(
        view()
            .on_press(move || {
                let len = content.with(|t| t.encode_utf16().count()) as u32;
                field.set_selection(0, len);
            })
            .child(text("Select all")),
    )

#Il caret

L’evento caret (.on_caret_move(..), TSX onCaretMove) indica dove si trova il caret, in modo che ciò che appartiene al caret possa aprirsi lì: i suggerimenti per una menzione con @, il menu di un comando slash, un selettore di emoji. bounds è un Bounds nell’input (origin e size, con padding e scorrimento dell’input applicati), largo quanto la piattaforma disegna il caret e alto quanto la sua riga; pageBounds è lo stesso riquadro nella finestra (o nel dialogo in cui si trova l’input), che un overlay prende come proprio anchor. L’evento arriva mentre l’input ha il focus, ogni volta che il caret si sposta: con la selezione e quando il testo dell’input scorre o va a capo su altre righe. In Rust un overlay viene posizionato rispetto a un riquadro con .anchor_bounds(..).

let draft = signal(String::new());
let end = signal(0u32);
let caret = signal(None::<Bounds<Points>>);

view()
    .child(
        input()
            .multiline(true)
            .value(move || draft.get())
            .on_change(move |t: &str| draft.set(t.to_owned()))
            .on_selection_change(move |s: &Selection| end.set(s.end))
            .on_caret_move(move |e: &CaretEvent| {
                // Suggestions while the word before the caret starts with "@".
                let typed = draft.with(|t| utf16_slice(t, 0, end.get()));
                let word = typed.rsplit(char::is_whitespace).next().unwrap_or("");
                caret.set(word.starts_with('@').then_some(e.page_bounds));
            })
            .p_2(),
    )
    .child(when(
        move || caret.get().is_some(),
        move || {
            overlay()
                .anchor_bounds(move || caret.get().unwrap_or_default())
                .placement(Placement::Bottom)
                .align(Align::Start)
                .offset(px(4.0))
                .child(text("@ada"))
                .child(text("@grace"))
        },
    ))

pageBounds è dove si trovava il caret quando è arrivato l’evento: una pagina che scorre sotto l’input sposta l’input ma non invia alcun evento, quindi chiudi i suggerimenti quando scorre, oppure ancorali all’input stesso.

#Incollare immagini e file

L’evento di incolla (.on_paste(..), TSX onPaste) indica cosa ha incollato l’utente, tramite la scorciatoia da tastiera, un comando Incolla di un menu o l’immagine di una tastiera: text, html, files e uris. html è lo stesso contenuto con la sua formattazione, come l’ha copiato un browser, una suite per ufficio o un editor di rich text (su Linux e sul web). files è ciò che gli appunti contenevano come dati, come uno screenshot o un’immagine copiata da una pagina o da un’altra app: File i cui byte l’app può caricare o mostrare, con nome image.png (o il tipo che è). uris sono gli URL file: dei file copiati in un file manager, come quelli di un file drop. La piattaforma legge gli appunti nel momento in cui l’utente incolla, e solo allora.

L’input inserisce da sé il testo incollato, come fa sempre: l’evento arriva prima di onChange per quel testo. Immagini e file non vengono mai inseriti, poiché un input contiene testo. Con pasteBehavior "report" l’input non inserisce nulla e l’app decide cosa fa un incolla: inviare un testo lungo come allegato, oppure inserirne una versione ripulita. L’input decide subito, in base alla prop, perché il sistema glielo chiede sul momento.

let attachments = signal(Vec::<PastedFile>::new());

input()
    .multiline(true)
    .placeholder("Message")
    .on_paste(move |e: &PasteEvent| {
        // A screenshot pasted into the message goes along as an image.
        attachments.update(|a| a.extend(e.files.iter().cloned()));
    })
    .p_2()

Carica un file incollato come qualsiasi File, con fetch e FormData (form.append("image", file)); i suoi byte restano nativi finché non vengono inviati. Su Android, le tastiere che inseriscono immagini (GIF, sticker) le riportano allo stesso modo. Su Linux gli appunti vengono letti in modo asincrono, quindi l’evento può arrivare dopo onChange del testo che inserisce. Gli input mascherati su macOS mantengono l’incolla di AppKit e non riportano nulla.

#Input multilinea che crescono

Un input multilinea (.multiline(true), TSX multiline) fa scorrere il proprio testo all’interno della propria altezza. Per farlo crescere insieme al contenuto, riporta l’altezza dell’evento di dimensione del contenuto (.on_content_size_change(..), TSX onContentSizeChange) nel suo stile, con un limite:

let height = signal(px(0.0));

input()
    .multiline(true)
    .placeholder("Write a comment")
    .on_content_size_change(move |e: &Size<Points>| height.set(e.height.min(px(120.0))))
    .text_size(px(16.0))
    .p(px(10.0))
    .border_1()
    .border_color(rgb(0xd0d7de))
    .rounded(px(10.0))
    .h(move || height.get().max(px(20.0)) + px(20.0))

L’altezza riportata è quella del testo (alla larghezza dell’input), quindi aggiungi tu il padding verticale, come sopra.

#Mantenere gli input sopra la tastiera

#Dentro le scroll view: automatico

Quando la tastiera compare sopra un input con focus che si trova dentro una scroll view o una lista, NSH applica alla scroll view un inset inferiore alto quanto la tastiera e fa scorrere l’input fino a renderlo visibile. Questo avviene in modo nativo e non richiede codice. Un form lungo è semplicemente una scroll view di input:

scroll()
    .flex_1()
    .keyboard_dismiss_mode(KeyboardDismissMode::OnDrag)
    .keyboard_taps(KeyboardTaps::Handled)
    .child(view().p(px(16.0)).gap(px(12.0)) /* inputs */)

La modalità di chiusura (.keyboard_dismiss_mode(..), TSX keyboardDismissMode: none, on drag, interactive) permette di nascondere la tastiera trascinando il form; i tap gestiti (.keyboard_taps(KeyboardTaps::Handled), TSX keyboardShouldPersistTaps="handled") permettono a un tap su un pulsante di andare a buon fine mentre la tastiera è visibile, invece di limitarsi a chiuderla. Vedi Scrolling.

#Contenuto fuori dalle scroll view

Su iOS, quando la tastiera coprirebbe un input con focus che non si trova in nessuna scroll view, NSH sposta verso l’alto il contenuto della sua schermata (o del suo dialogo) finché l’input non è appena sopra la tastiera, seguendo la curva della tastiera, e lo riporta giù quando la tastiera si nasconde o l’input perde il focus, come fa SwiftUI (su Android, una finestra in modalità adjustPan viene spostata dal sistema). Lo fa solo per ciò che il layout dell’app lascia coperto: un’app che crea lo spazio da sé, come mostrato sotto, non viene spostata di nuovo.

Questo sposta l’intera pagina. Una barra dei messaggi fissata in fondo a una schermata dovrebbe invece seguire la tastiera mentre il contenuto sopra di essa si restringe: aggiungi un padding inferiore alla schermata con il env(keyboard-inset-height) di CSS (paddingBottom: "env(keyboard-inset-height)", Tailwind pb-[env(keyboard-inset-height)], Rust .pb_keyboard()): crea in basso uno spazio alto quanto la tastiera, animato in modo nativo insieme alla tastiera.

use nsh_core::prelude::*;

pub fn chat_screen() -> impl IntoElement {
    let draft = signal(String::new());
    let send = move || {
        let text = draft.get();
        if !text.trim().is_empty() {
            log::info!("send {text}");
        }
        draft.set(String::new());
    };
    view()
        .flex_1()
        .pb_keyboard()
        .child(scroll().flex_1().keyboard_dismiss_mode(KeyboardDismissMode::Interactive) /* messages */)
        .child(
            view()
                .flex_row()
                .p(px(8.0))
                // Above the home indicator, unless the keyboard covers it.
                .pb(move || {
                    px(8.0) + if keyboard_height() > px(0.0) { px(0.0) } else { safe_area().bottom }
                })
                .child(
                    input()
                        .value(move || draft.get())
                        .on_change(move |t: &str| draft.set(t.to_owned()))
                        .multiline(true)
                        .submit_behavior(SubmitBehavior::Submit)
                        .return_key_type(ReturnKeyType::Send)
                        .on_submit(move |_: &str| send())
                        .placeholder("Message")
                        .flex_1()
                        .max_h(px(120.0)),
                ),
        )
}

Il padding (.pb_keyboard()) restringe il contenuto sopra, mentre lo sfondo raggiunge il fondo della schermata; un margine inferiore (marginBottom: "env(keyboard-inset-height)", .mb_keyboard()) fa terminare l’intera vista in corrispondenza del bordo superiore della tastiera. calc(env(keyboard-inset-height) + <length>) (Rust .pb_keyboard_plus(..), .mb_keyboard_plus(..)) aggiunge una lunghezza, per esempio meno l’altezza di una tab bar sotto la vista.

L’altezza piena è adatta a una vista che raggiunge il fondo della superficie, come fa il contenuto di una schermata. In un host Android la cui finestra si ridimensiona per la tastiera, è la finestra stessa a restringersi e la variabile vale 0. Vedi Keyboard.

#L’accessor della tastiera

keyboard_height() e keyboard_duration() in Rust, e keyboardFrame() da nsh in TSX (un { height, duration }), sono reattivi: indicano quanto la tastiera si estende nella superficie (0 quando è nascosta; l’area sicura inferiore sotto di essa è inclusa) e la durata della sua animazione in millisecondi. Usali per qualsiasi caso personalizzato: nascondere un pulsante flottante durante la digitazione o dimensionare un pannello. Ciò che cambia con essi nell’aggiornamento in cui inizia il movimento della tastiera si muove insieme alla tastiera, secondo la sua curva e con i tempi calcolati dall’inizio del suo movimento (LayoutCurve::Keyboard), come fa env(keyboard-inset-height); un’animazione configurata dall’app per quell’aggiornamento (LayoutAnimation.configureNext) ha la precedenza.

view().child(when(
    || keyboard_height() == px(0.0),
    || view().absolute().right(px(16.0)).bottom(px(16.0)), /* … */
))

#Una barra sopra la tastiera (iOS): InputAccessoryView

input_accessory_view(id) in Rust e InputAccessoryView da nsh/mobile in TSX mostrano una barra agganciata alla parte superiore della tastiera mentre ha il focus un input che la indica: il inputAccessoryView di iOS. Si muove in modo nativo con la tastiera, anche durante la chiusura interattiva. Non occupa spazio nel punto in cui è collocata nell’albero; gli input vi fanno riferimento tramite il suo id (.input_accessory(id), TSX ios={{ inputAccessoryViewID }} e nativeID):

use nsh_core::prelude::*;

pub fn note() -> impl IntoElement {
    let content = signal(String::new());
    let field = element_ref();
    view()
        .p(px(16.0))
        .child(
            input()
                .element_ref(&field)
                .value(move || content.get())
                .on_change(move |t: &str| content.set(t.to_owned()))
                .input_accessory("note-bar")
                .placeholder("A note"),
        )
        .child(
            input_accessory_view("note-bar").bg(rgb(0xf6f8fa)).child(
                view()
                    .flex_row()
                    .items_center()
                    .p(px(8.0))
                    .child(
                        text(move || format!("{} characters", content.with(|t| t.chars().count())))
                            .flex_1()
                            .text_color(rgb(0x57606a)),
                    )
                    .child(
                        text("Done")
                            .on_press(move || field.blur())
                            .text_color(rgb(0x0969da))
                            .font_weight(FontWeight::SEMIBOLD),
                    ),
            ),
        )
}

La barra è larga quanto la finestra e alta quanto il suo contenuto. Gli input accessory sono una funzionalità di iOS: su Android la barra è una vista vuota e nascosta, quindi in quel caso colloca altrove questi controlli, per esempio sopra l’input (os() == Os::Android in Rust, platform.os === "android" in TSX).

#Correlati