Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Dokumentation

Touch und Gesten

NSH kennt drei Ebenen der Touch-Verarbeitung. Presses (.on_press in Rust, onPress in TSX und ihre Verwandten) decken Buttons, Zeilen und Links ab, wobei das Feedback beim Drücken nativ gezeichnet wird. Native Gesten (Pan, Pinch und Rotation) bewegen animierte Werte im UI-Thread, sodass eine gezogene Karte dem Finger folgt, selbst wenn dein Code gerade beschäftigt ist. Rohe Touches und das Responder-System (.on_touch und .claim_touches mit den .on_responder_*-Handlern in Rust; onTouchStart, onResponderMove und PanResponder in TSX) geben deinem Code jede Fingerposition für alles, was die beiden anderen Ebenen nicht ausdrücken können, etwa Zeichnen.

guide-gestures.tsx
guide-gestures.tsxLive
import { mount, signal, Text, View } from "nsh";
import { createAnimatedValue } from "nsh";
import { PanResponder } from "nsh";
import { button, buttonText, desktop, focusRing, Inspector, Log, tint, ui, wide } from "./palette";

/** A card dragged on the UI thread: `pan` drives two animated values. */
function DragCard() {
  const x = createAnimatedValue(0);
  const y = createAnimatedValue(0);
  const flings = signal(0);
  return (
    <View style={{ ...ui().inset, height: 150, borderRadius: 12, alignItems: "center", justifyContent: "center", overflow: "hidden" }}>
      <View
        pan={{ x, y }}
        onPanEnd={(e) => {
          flings.update((n) => n + 1);
          // Spring home, starting with the release velocity.
          x.spring(0, { velocity: e.velocity.x, stiffness: 200, damping: 16 });
          y.spring(0, { velocity: e.velocity.y, stiffness: 200, damping: 16 });
        }}
        style={{
          width: 120,
          height: 80,
          borderRadius: 16,
          ...ui().accentFill,
          alignItems: "center",
          justifyContent: "center",
          translate: [x, y],
        }}
      >
        <Text style={{ color: ui().onAccent, fontWeight: "700" }}>Drag me</Text>
        <Text style={{ color: ui().onAccent, fontSize: 12 }}>released {flings()}×</Text>
      </View>
    </View>
  );
}

/** A dot moved with PanResponder: every move goes through JS. */
function ResponderDot() {
  const pos = signal({ x: 0, y: 0 });
  const state = signal("idle");
  let start = { x: 0, y: 0 };
  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onPanResponderGrant: () => {
      start = pos();
      state.set("dragging");
    },
    onPanResponderMove: (_, g) => pos.set({ x: start.x + g.dx, y: start.y + g.dy }),
    onPanResponderRelease: (_, g) => state.set(`released, vx ${g.vx.toFixed(0)} pt/s`),
    onPanResponderTerminate: () => state.set("taken over"),
  });
  return (
    <View style={{ gap: 6 }}>
      <View style={{ ...ui().inset, height: 110, borderRadius: 12, overflow: "hidden" }}>
        <View
          {...responder.panHandlers}
          style={{
            position: "absolute",
            left: 150,
            top: 30,
            width: 50,
            height: 50,
            borderRadius: 25,
            backgroundColor: ui().info,
            translate: [pos().x, pos().y],
          }}
        />
      </View>
      <Text style={{ color: ui().muted, fontSize: 13 }}>PanResponder: {state()}</Text>
    </View>
  );
}

function App() {
  const log = signal<string[]>([]);
  const add = (event: string) => log.update((l) => [event, ...l].slice(0, 4));
  const dot = signal(0);
  const under = signal(0);
  // With a pointer the buttons are as wide as their labels; on a phone they share the row.
  const share = desktop ? {} : { flex: 1 };
  const buttons = () => (
    <View style={{ flexDirection: "row", gap: desktop ? 8 : 10 }}>
      <View
        onPressDown={() => add("pressIn")}
        onPressUp={() => add("pressOut")}
        onPress={() => add("press")}
        onLongPress={() => add("longPress")}
        {...button("primary", { style: share })}
        activeStyle={{ opacity: 0.5 }}
      >
        <Text style={buttonText("primary")}>activeStyle opacity</Text>
      </View>
      <View
        onPress={() => add("underlay")}
        {...button("secondary", { style: share })}
        activeStyle={{ backgroundColor: ui().border, backgroundImage: "none" }}
      >
        <Text style={buttonText()}>activeStyle background</Text>
      </View>
      <View
        onPress={() => add("active:")}
        class={
          desktop
            ? "h-8 items-center justify-center rounded-lg bg-[#0f8a55] px-3 transition motion-reduce:transition-none hover:bg-[#12a368] active:scale-95 active:bg-[#0b6b42] dark:bg-[#45d49a] dark:hover:bg-[#5be0a9] dark:active:bg-[#2fae7b]"
            : "flex-1 items-center rounded-xl bg-[#0f8a55] p-3.5 transition motion-reduce:transition-none hover:bg-[#12a368] active:scale-95 active:bg-[#0b6b42] dark:bg-[#45d49a] dark:hover:bg-[#5be0a9] dark:active:bg-[#2fae7b]"
        }
        focusStyle={focusRing()}
      >
        <Text class={desktop ? "text-[13px] font-semibold text-white dark:text-[#0c0d0f]" : "font-semibold text-white dark:text-[#0c0d0f]"}>active:</Text>
      </View>
    </View>
  );
  const slop = () => (
    <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
      <View
        hitSlop={24}
        onPress={() => dot.update((n) => n + 1)}
        style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: ui().down, margin: 12 }}
        hoverStyle={{ opacity: 0.88 }}
        focusStyle={focusRing()}
        activeStyle={{ opacity: 0.7 }}
      />
      <Text style={{ flex: 1, color: ui().text }}>
        hitSlop 24: the dot takes {desktop ? "clicks" : "taps"} around it ({dot()})
      </Text>
    </View>
  );
  const overlay = () => (
    <View style={{ height: 52 }}>
      <View
        onPress={() => under.update((n) => n + 1)}
        style={{ flex: 1, borderRadius: 12, backgroundColor: tint(ui().up, 0.14), alignItems: "center", justifyContent: "center" }}
        hoverStyle={{ opacity: 0.88 }}
        focusStyle={focusRing()}
        activeStyle={{ opacity: 0.7 }}
      >
        <Text style={{ color: ui().up }}>Under an overlay: {under()}</Text>
      </View>
      <View
        style={{ pointerEvents: "none", position: "absolute", top: 0, left: 0, right: 0, bottom: 0, borderRadius: 12, borderWidth: 3, borderColor: ui().warn, borderStyle: "dashed" }}
      />
    </View>
  );
  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 }}>
              {buttons()}
              <Text style={{ color: ui().muted, fontSize: 13 }}>Click, hold, or move off a button while pressing</Text>
              {slop()}
              {overlay()}
              <DragCard />
              <ResponderDot />
            </View>
            <Inspector>
              <Log title="Press events, newest first" lines={log()} minHeight={92} />
            </Inspector>
          </View>
        ) : (
          <>
            {buttons()}
            <Text style={{ color: ui().muted, fontSize: 13 }}>{log().join(" · ") || "Tap, hold, or slide off a button"}</Text>
            {slop()}
            {overlay()}
            <DragCard />
            <ResponderDot />
          </>
        )
      }
    </View>
  );
}

mount(App);

Wähle die höchste Ebene, die die Aufgabe erfüllt: Je höher die Ebene, desto weniger Arbeit wandert vom UI-Thread zu deinem Code.

#Presses

Jedes Element akzeptiert die Press-Handler; es gibt keine separate „Touchable”-Komponente. Setze .on_press / onPress auf eine View, einen Text, ein Bild oder irgendein anderes Element.

use nsh_core::prelude::*;

#[component]
fn App() -> impl IntoElement {
    let count = signal(0_u32);
    view().flex_1().items_center().justify_center().child(
        view()
            .on_press(move || count.update(|n| *n += 1))
            .on_long_press(move || count.set(0))
            .active(|s| s.opacity(0.6))
            .role(AccessibilityRole::Button)
            .px(px(20.0))
            .py(px(12.0))
            .rounded(px(10.0))
            .bg(rgb(0x0969da))
            .child(
                text(move || format!("Pressed {} times", count.get()))
                    .text_color(white())
                    .font_weight(FontWeight::SEMIBOLD),
            ),
    )
}
Rust TSX Standard Beschreibung
.on_press(..) onPress Der Finger wurde innerhalb der View angehoben (nach einem Press, nicht nach einem Long Press).
.on_long_press(..) onLongPress Der Finger blieb für die Long-Press-Verzögerung unten. Der Press-Handler wird danach nicht aufgerufen.
.on_press_down(..) onPressDown Der Press hat begonnen.
.on_press_up(..) onPressUp Der Press ist beendet: angehoben, weggerutscht oder von einer Scroll View übernommen.
.long_press_delay(ms) delayLongPress 500 Millisekunden, bevor ein Press zum Long Press wird.
.disabled(..) disabled false Nimmt keine Presses an und zeigt kein Feedback; wendet den Disabled-Style an; Screenreader melden es als deaktiviert.

So verhalten sich Presses:

  • Nur die innerste pressable View reagiert. Eine pressable Zeile, die einen pressable Button enthält, erhält keinen Press, wenn der Button gedrückt wird.
  • In einer Scroll View wartet der Press etwa 130 ms, bevor er beginnt (und Feedback zeigt), sodass ein Drag, der auf einem Button beginnt, scrollt, statt ihn zu drücken.
  • Wegrutschen bricht ab. Bewegt sich der Finger aus der View heraus, endet der Press mit dem Press-up-Handler und ohne Press.
  • Deaktivieren entfernt die Press-Listener von dem, was gemountet ist, sodass die View überhaupt kein Feedback zeigt und der Disabled-Style gilt (.disabled_style(..), class="disabled:opacity-40").
  • Verschachtelte Text-Spans mit Press-Handler sind Links. Der Absatz ist eine einzige native View; der Press geht an den Span unter dem Finger. Siehe Text.

#Press-Feedback

Das Press-Feedback gehört der App: Styles, die die View annimmt, solange sie gedrückt, überfahren, fokussiert oder deaktiviert ist. Sie werden nativ in dem Moment angewendet, in dem sich der Zustand ändert, ohne Round Trip zu deinem Code. Jeder Zustand hat eine Rust-Methode, eine TSX-Style-Prop und eine Tailwind-Variante:

Rust TSX-Prop Klasse Beschreibung
.active(|s| ..) activeStyle active: Solange sie gedrückt wird.
.hover(|s| ..) hoverStyle hover: Solange ein Zeiger darüber ist (Desktops, iPad-Zeiger, Browser).
.focus(|s| ..) focusStyle focus: Solange sie den Fokus hat.
.focus_within(|s| ..) focusWithinStyle focus-within: Solange sie oder eine View in ihr den Fokus hat: der Rahmen eines Feldes um die Eingabe, die bearbeitet wird.
.disabled_style(|s| ..) disabledStyle disabled: Solange sie deaktiviert ist.
.ripple(Ripple { .. }) android={{ ripple }} Androids Material-Ripple (color, borderless, radius, foreground), zusätzlich zu den Styles.

Die Zustands-Styles legen sich über die eigenen Styles der View. Spätere Zustände gewinnen (hover, focus, active), der Disabled-Style gewinnt über alle, und die anderen gelten nicht, solange die View deaktiviert ist.

use nsh_core::{Ripple, prelude::*};

view().on_press(open).active(|s| s.opacity(0.5))

view().on_press(open).active(|s| s.bg(rgb(0xddf4ff))).p(px(16.0))

view()
    .on_press(open)
    .active(|s| s.opacity(0.6))
    .ripple(Ripple {
        color: Some(rgba(0x0969da4d).into()),
        ..Ripple::default()
    })

Ein randloser Ripple breitet sich außerhalb der View aus, was zu Icon-Buttons passt: Ripple { borderless: true, radius: Some(px(24.0)), .. }, android={{ ripple: { borderless: true, radius: 24 } }}. Siehe Plattformspezifischer Code.

Mit einer Transition (.transition(), die Klasse transition) wird der Zustandswechsel animiert. .group() (die Klasse group) macht eine View zu einer Gruppe, und .group_active(..) (group-active:) stylt ihre Kinder, solange sie gedrückt wird:

view()
    .on_press(save)
    .group()
    .rounded_xl()
    .bg(rgb(0x059669))
    .p_4()
    .transition()
    .active(|s| s.scale(0.95).bg(rgb(0x065f46)))
    .child(
        text("Save")
            .font_weight(FontWeight::SEMIBOLD)
            .text_color(white())
            .group_active(|s| s.opacity(0.85)),
    )

#Trefferbereiche: hitSlop

Der Hit Slop (.hit_slop(..), hitSlop) erweitert den Bereich, der Touches annimmt, über die Grenzen der View hinaus, ohne ihr Layout zu ändern. Nutze ihn für kleine Ziele wie Schließen-Buttons und Toggles.

view().hit_slop(EdgeInsets::all(px(12.0))).on_press(close).size(px(20.0))

view()
    .hit_slop(EdgeInsets { top: px(10.0), bottom: px(10.0), left: px(4.0), right: px(4.0) })
    .on_press(next)
Rust TSX Beschreibung
.hit_slop(EdgeInsets) hitSlop: number | { top?, right?, bottom?, left? } Punkte, die rund um die Grenzen hinzugefügt werden. EdgeInsets::all(..) und eine TSX-Zahl gelten für alle vier Seiten.

Halte den erweiterten Bereich innerhalb der Grenzen des Elternelements (gib der kleinen View etwas Margin oder Padding drumherum): Das Hit Testing erreicht ein Kind über sein Elternelement, daher gelangt ein Touch außerhalb des Elternelements nie zum Slop des Kindes.

#Kinder außerhalb ihres Elternelements

Ein Kind, das außerhalb der Grenzen seines Elternelements platziert ist (absolut positioniert mit sichtbarem overflow, etwa ein Dropdown unter seinem Feld), nimmt Touches dort an, wo es gezeichnet wird, wie jede andere View: NSH führt das Hit Testing für die Nachfahren, die über eine View hinausragen, nativ durch. Ein Elternelement, das seinen Überlauf abschneidet, schneidet sie ab, und dann werden sie dort weder angezeigt noch nehmen sie Touches an. Der Bereich wird aus dem Layout berechnet, daher nimmt ein Kind, das per Transform aus seinem Elternelement verschoben wurde, nur Touches innerhalb der Grenzen des Elternelements an.

Beachte die Zeichenreihenfolge: Ein Dropdown wird nur dann über den Geschwistern seines Feldes gezeichnet, wenn das Feld nach ihnen kommt oder ein höheres z-index hat, und Touches gehen an das, was oben gezeichnet wird.

#pointerEvents

Der Style-Key pointerEvents (.pointer_events(..) in Rust) bestimmt, welche Views eines Teilbaums das Ziel eines Touches sein können. Er wird nativ beim Hit Testing ausgewertet und kostet daher nichts pro Touch.

Rust TSX Die View Ihre Kinder
PointerEvents::Auto (Standard) "auto" Nimmt Touches an Nehmen Touches an
PointerEvents::None "none" Nie das Ziel Nie das Ziel, außer eines setzt "auto": Touches gehen durch zu dem, was dahinter liegt
PointerEvents::BoxNone "box-none" Nie das Ziel Nehmen Touches an
PointerEvents::BoxOnly "box-only" Nimmt Touches an Nie das Ziel

Typische Anwendungen: ein Overlay, das nicht blockieren darf, was darunter liegt (none), ein bildschirmfüllender Container, dessen leerer Raum Touches an den dahinterliegenden Inhalt durchlassen soll, während seine Buttons weiterhin funktionieren (box-none), und eine Zeile, die einen Press annehmen soll, auch wenn der Finger auf einem Kind landet (box-only).

view().flex_1().child(map()).child(
    // A floating toolbar: the empty area passes touches to the map.
    view()
        .pointer_events(PointerEvents::BoxNone)
        .absolute()
        .inset_0()
        .justify_end()
        .p(px(16.0))
        .child(
            view()
                .on_press(locate)
                .self_end()
                .p(px(12.0))
                .rounded(px(24.0))
                .bg(white())
                .child(text("Locate")),
        ),
)

"none" wird wie CSS-pointer-events vererbt: Eine View darin, die "auto" setzt, nimmt wieder Touches an. "box-none" und "box-only" gelten für die View, die sie setzt. Die Tailwind-Klassen pointer-events-none und pointer-events-auto setzen "none" und "auto", und als Style-Key akzeptiert pointerEvents auch Zustands-Styles (activeStyle, …) und Varianten.

#Native Gesten: Pan, Pinch und Rotation

Binde für Drags, Pinches und Rotationen, die etwas auf dem Bildschirm bewegen, animierte Werte an die Geste. Der Gesten-Recognizer der Plattform schreibt die Werte im UI-Thread, und die daran gebundenen Views bewegen sich im selben Frame; dein Code wird informiert, wenn die Geste beginnt und endet.

use nsh_core::prelude::*;

#[component]
fn App() -> impl IntoElement {
    let x = animated_value(0.0);
    let y = animated_value(0.0);
    view().flex_1().items_center().justify_center().child(
        view()
            .pan_values(Some(&x), Some(&y))
            .on_pan_end(move |e: &PanEvent| {
                // Spring back, starting at the speed the finger let go with.
                let spring = |velocity| Spring { velocity: Some(velocity), ..Spring::default() };
                x.spring(0.0, spring(e.velocity_x));
                y.spring(0.0, spring(e.velocity_y));
            })
            .size(px(120.0))
            .rounded(px(24.0))
            .bg(rgb(0x8250df))
            .bind_translate_x(&x)
            .bind_translate_y(&y),
    )
}
Rust TSX Beschreibung
.pan_values(Some(&x), Some(&y)) pan={{ x?, y? }} Drags mit einem Finger bewegen diese Werte, in Punkten.
.pinch_value(&scale) pinch={{ scale? }} Pinches mit zwei Fingern steuern scale (ein Faktor).
.rotation_value(&angle) rotation={{ angle? }} Rotationen mit zwei Fingern steuern angle, in Grad.
.on_pan_start / .on_pan_end onPanStart / onPanEnd Der Drag hat begonnen / ist beendet, mit einem PanEvent.
.on_pinch_start / .on_pinch_end onPinchStart / onPinchEnd Der Pinch hat begonnen / ist beendet: seine Skalierung und Geschwindigkeit (GestureEvent).
.on_rotation_start / .on_rotation_end onRotationStart / onRotationEnd Die Rotation hat begonnen / ist beendet: ihr Winkel (Rust: GestureEvent<Radians>) und ihre Geschwindigkeit.

Ein Pan-Event enthält die Translation (translation, ein Point: Punkte seit dem Start), die Geschwindigkeit (velocity_x / velocity_y; TSX: velocity, ein Point: Punkte pro Sekunde) und die gebundenen Werte in diesem Moment (value_x / value_y; TSX: value, { x?, y? } mit den Achsen, die pan bindet).

Das erledigt die native Seite für dich:

  • Ein Drag setzt dort an, wo der Wert steht. Beim Start werden die Werte gelesen und die Translation wird zu ihnen addiert; greifst du einen Wert, der noch animiert wird (eine Spring auf dem Weg zurück), stoppt die Animation an der aktuellen Stelle.
  • Nur eine Achse lässt die andere scrollen. Ist nur ein horizontaler Wert gebunden, beginnt der Drag erst, wenn sich der Finger stärker horizontal als vertikal bewegt, sodass eine horizontale Swipe-Zeile in einer vertikalen Scroll View weiterhin vertikal scrollt. Diese Entscheidung fällt nativ, wenn das System fragt, ob die Geste beginnen darf.
  • Pan, Pinch und Rotation laufen gemeinsam auf derselben View: Eine Karte kann gleichzeitig gezogen, gepincht und gedreht werden.
  • Loslass-Geschwindigkeit für Physik. Die Geschwindigkeit am Ende des Pans speist direkt die Geschwindigkeit einer Spring oder eines Decay; mit den Werten kannst du einen Snap-Punkt wählen.
// A card that snaps to the left or right edge.
view()
    .pan_values(Some(&x), None)
    .on_pan_end(move |e: &PanEvent| {
        let projected = e.value_x.unwrap_or(0.0) + e.velocity_x * 0.2;
        let to = if projected > 0.0 { 140.0 } else { -140.0 };
        x.spring(to, Spring { velocity: Some(e.velocity_x), ..Spring::default() });
    })
    .bind_translate_x(&x)

#Rohe Touches

Die Touch-Handler melden jeden Finger, wenn er aufsetzt, sich bewegt und abhebt. Sie sind nützlich für Logging, eigenes Hit Testing und das Reagieren auf Multi-Touch; sie beanspruchen den Touch nicht (Presses und Scroll Views funktionieren weiter).

In Rust hört .on_touch(..) jede Phase als TouchEvent: ihre phase (TouchPhase::Start, Move, End, Cancel), ihre timestamp und ihre points, jeden Finger, der unten ist, jeweils mit seiner id, seiner location in der View, auf der er aufgesetzt hat, seiner page-Position in der Surface und changed für die Finger, um die es im Event geht. In TSX hat jede Phase ihren eigenen Handler:

TSX Beschreibung
onTouchStart Ein Finger hat auf der View oder in ihr aufgesetzt.
onTouchMove Ein Finger hat sich bewegt. Bewegungen werden pro Engine-Turn zusammengefasst.
onTouchEnd Ein Finger wurde angehoben.
onTouchCancel Das System hat den Touch übernommen (eine Scroll View hat zu scrollen begonnen, ein Anruf kam herein).
onTouchStartCapture, onTouchMoveCapture, onTouchEndCapture, onTouchCancelCapture Dasselbe, von außen nach innen aufgerufen, vor den Bubbling-Handlern.

Jeder TSX-Handler erhält ein GestureResponderEvent:

Feld Beschreibung
identifier Identifiziert den Finger, solange er unten ist.
locationX, locationY Position in der View, auf der der Finger aufgesetzt hat (target).
pageX, pageY Position in der Surface.
target Die ID der View, auf der der Finger aufgesetzt hat.
timestamp Millisekunden.
touches Jeder Finger, der unten ist.
changedTouches Die Finger, um die es in diesem Event geht.
nativeEvent Das Event selbst, sodass auch e.nativeEvent.locationX funktioniert.
stopPropagation() Weiter außen liegende Views sehen dieses Touch-Event nicht.
use nsh_core::{TouchPhase, element::{TouchEvent}, prelude::*};

view().on_touch(|e: &TouchEvent| {
    let Some(p) = e.points.iter().find(|p| p.changed) else { return };
    match e.phase {
        TouchPhase::Start => log::info!("down {:?} {}", p.location, e.points.len()),
        TouchPhase::Move => log::info!("move {:?}", p.page),
        TouchPhase::End | TouchPhase::Cancel => log::info!("up"),
    }
})

#Wo sich eine View befindet: measure

Die Position eines Touches ist relativ zu der View, auf der der Finger aufgesetzt hat, und das kann ein Kind der View sein, die dich interessiert (der Knopf eines Sliders). Das Messen einer View sagt dir, wo sie sich befindet, sodass du stattdessen die Seitenposition verwenden kannst. In Rust setzt du ein element_ref() auf die View und wartest auf dessen measure(); in TSX rufst du measure() auf ihrer Ref auf:

let track = element_ref();
let origin = stored(px(0.0));
view()
    .element_ref(&track)
    .claim_touches(TouchClaim::on_start())
    .on_responder_grant(move |e: &ResponderEvent| {
        let x = e.page.x;
        spawn(async move {
            let page = track.measure().await.map_or(px(0.0), |m| m.page.x);
            origin.replace(page);
            seek(x - page);
        });
    })
    .on_responder_move(move |e: &ResponderEvent| seek(e.page.x - origin.get()))

Es wird aufgelöst, sobald die bisher vorgenommenen Änderungen ins Layout gebracht wurden: in Rust zu einem Measurement (frame, der Frame im Elternelement, wie ihn der Layout-Handler meldet, und page, die obere linke Ecke), in TSX zu { bounds, pagePosition }. Die Ecke liegt in der Surface oder in dem Dialog, in dem sich die View befindet, in denselben Koordinaten wie die Seitenpositionen der Touches. Die Offsets der Scroll Views und Listen darum herum werden berücksichtigt; Transforms (einschließlich Animationen) nicht. Für eine View, die nicht auf dem Bildschirm ist, wird es zu None / null aufgelöst.

#Das Responder-System

Wenn Ihr Code eine Touch-Sequenz selbst steuern muss (Zeichnen mit dem Finger, ein benutzerdefinierter Slider, eine Geste, die die nativen Recognizer nicht bieten), verwenden Sie das Gesture-Responder-System. Jeweils eine View ist der Responder und erhält die Bewegungen; die Views handeln untereinander aus, wer das ist.

Zum Responder werden:

Rust TSX Beschreibung
.claim_touches(TouchClaim::on_start()) onStartShouldSetResponder mit Rückgabe von true Wird abgefragt, wenn ein Finger aufsetzt, vom Ziel nach außen.
.claim_touches(TouchClaim::on_move()) onMoveShouldSetResponder mit Rückgabe von true Wird bei jeder Bewegung abgefragt, solange es keinen Responder gibt (oder um ihn zu übernehmen).
TouchClaim::on_start().capture() onStartShouldSetResponderCapture Wie oben, wird aber von außen nach innen abgefragt, noch vor den aufsteigenden (Bubbling-)Varianten. Ein Parent nutzt es, um seinen Children einen Touch abzunehmen.
TouchClaim::on_move().capture() onMoveShouldSetResponderCapture Die Variante für Bewegungen.

Ein Anspruch wird allein anhand der View entschieden, ohne dass Ihr Code läuft, es sei denn, er fragt nach: TouchClaim::on_move().ask(|r: &ClaimRequest| ..) beansprucht den Touch, wenn der Listener r.claim() aufruft, und entscheidet anhand der bisherigen Geste (r.event.gesture). Die TSX-Handler erhalten die Geste als zweites Argument, gestureState, mit denselben Feldern wie bei PanResponder (dx, dy, vx, vy, moveX, moveY, x0, y0, numberActiveTouches). Ein Swipe, der nur horizontale Bewegungen beansprucht: onMoveShouldSetResponder={(e, g) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy)}. Diese Entscheidung kommt dennoch eine Bewegung zu spät, sodass eine umgebende Scroll View möglicherweise zuerst zu scrollen beginnt; verwenden Sie für Drags innerhalb von Scroll Views die Pan-Aktivierung.

Während des Antwortens:

Rust TSX Beschreibung
.on_responder_grant(..) onResponderGrant Diese View ist jetzt der Responder. Rust hindert native Scroll Views standardmäßig daran, die Touches zu übernehmen (TouchClaim::block_native(false) erlaubt es ihnen); in TSX geben Sie dafür true zurück.
.on_responder_reject(..) onResponderReject Eine andere View ist der Responder und gibt ihn nicht ab.
.on_responder_start(..) onResponderStart Ein weiterer Finger ist aufgesetzt.
.on_responder_move(..) onResponderMove Ein Finger hat sich bewegt.
.on_responder_end(..) onResponderEnd Ein Finger wurde abgehoben.
.on_responder_release(..) onResponderRelease Der letzte Finger wurde abgehoben: Die Geste ist beendet.
.keep_touches(true) onResponderTerminationRequest mit Rückgabe von false Eine andere View möchte die Touches: Behalten Sie sie (standardmäßig werden sie abgegeben).
.on_responder_terminate(..) onResponderTerminate Eine andere View oder eine native Geste hat die Touches übernommen.

Der Responder einer Rust-Komponente bleibt von der Zuweisung (Grant) bis zum Ende seiner Touches gedrückt, egal wohin sich der Finger oder Pointer bewegt, so wie CSS’ :active gehalten wird, bis die Maustaste losgelassen wird: Seine .active(..)-Styles und die darin enthaltenen group-active:-Klassen behalten ihr Aussehen während des gesamten Drags (etwa bei einem Resize-Handle, das über seinen Bereich hinausgezogen wird).

Die Rust-Handler erhalten ein ResponderEvent: die location des Fingers in der View, auf der er aufgesetzt hat, seine page-Position sowie timestamp und gesture seit dem Grant.

use nsh_core::prelude::*;

fn sketch() -> impl IntoElement {
    let strokes = signal(Vec::<Vec<Point<Points>>>::new());
    canvas(move || {
        strokes.with(|all| {
            all.iter().fold(Painter::new().line_width(3.0), |p, s| {
                let mut p = p.begin_path();
                for (i, pt) in s.iter().enumerate() {
                    p = if i == 0 { p.move_to(pt.x, pt.y) } else { p.line_to(pt.x, pt.y) };
                }
                p.stroke()
            })
        })
    })
    .flex_1()
    // The responder: the scroll view around does not take the touches.
    .claim_touches(TouchClaim::on_start())
    .keep_touches(true)
    .on_responder_grant(move |e: &ResponderEvent| strokes.update(|all| all.push(vec![e.location])))
    .on_responder_move(move |e: &ResponderEvent| {
        strokes.update(|all| {
            if let Some(last) = all.last_mut() {
                last.push(e.location);
            }
        })
    })
}

So läuft es ab: Eine View mit einem beliebigen Touch- oder Responder-Handler lauscht nativ auf Touches; die Plattform meldet die Touches auf ihr (und in ihr) zusammen mit dem Pfad der lauschenden Views, und die Aushandlung läuft im Rust-Core von NSH, der die Ansprüche abfragt und Ihre Handler der Reihe nach aufruft: zuerst die Captures von der äußersten View nach innen, dann die übrigen vom Ziel nach außen. Daraus ergeben sich zwei Konsequenzen:

  • Native Scroll Views und Responder konkurrieren. Solange ein Responder sie blockiert, übernehmen die umgebenden Scroll Views keine Touches, bis er endet. Beginnt eine native Scroll View zuerst zu ziehen, gewinnt sie: Der Responder erhält den Terminate-Handler und die Touch-Handler ein Cancel.
  • Jede Bewegung ist ein Event für Ihren Code. Responder-gesteuerte Drags sind nur so flüssig, wie Ihr Code schnell ist; für Drags, die lediglich Views verschieben, sind gebundene Werte vorzuziehen.

#PanResponder

In Rust tragen die Responder-Events die Geste bereits in sich: ResponderEvent::gesture ist ein PanGesture mit der Distanz zum Start, der Geschwindigkeit und der Anzahl der Finger. In TSX umschließt PanResponder aus nsh das Responder-System und ergänzt denselben Gestenzustand; verteilen Sie dessen panHandlers per Spread auf eine View.

use nsh_core::prelude::*;

fn ball() -> impl IntoElement {
    let pos = signal((0.0_f32, 0.0_f32));
    let start = stored((0.0_f32, 0.0_f32));
    view()
        .size(px(60.0))
        .rounded(px(30.0))
        .bg(rgb(0x0969da))
        .style_with(move |s| {
            let (x, y) = pos.get();
            s.translate_x(px(x)).translate_y(px(y))
        })
        .claim_touches(TouchClaim::on_start())
        .on_responder_grant(move |_: &ResponderEvent| {
            start.replace(pos.get_untracked());
        })
        .on_responder_move(move |e: &ResponderEvent| {
            let (x, y) = start.get();
            let d = e.gesture.delta;
            pos.set((x + d.x.0, y + d.y.0));
        })
        .on_responder_release(move |e: &ResponderEvent| {
            log::info!("released at {} {} pt/s", e.gesture.vx, e.gesture.vy)
        })
}

Der Gestenzustand:

Rust (PanGesture) TSX (gestureState) Beschreibung
stateID Identifiziert die Geste.
position moveX, moveY Die jüngste Position des Schwerpunkts der Touches, in Surface-Punkten.
origin x0, y0 Wo der Schwerpunkt war, als der Responder zugewiesen wurde.
delta dx, dy Seit dem Grant zurückgelegte Distanz.
vx, vy vx, vy Geschwindigkeit, in Punkten pro Sekunde.
active_touches numberActiveTouches Aufgesetzte Finger.

PanGesture::begin und PanGesture::update verfolgen in Rust eine Geste anhand roher Touches (.on_touch). Die TSX-Callbacks erhalten (event, gestureState): onStartShouldSetPanResponder(Capture), onMoveShouldSetPanResponder(Capture), onPanResponderGrant, onPanResponderReject, onPanResponderStart, onPanResponderMove, onPanResponderEnd, onPanResponderRelease, onPanResponderTerminate, onPanResponderTerminationRequest und onShouldBlockNativeResponder. Der letzte ist standardmäßig true: Ein zugewiesener PanResponder hindert native Scroll Views daran, seine Touches zu übernehmen.

#Pans innerhalb von Scroll Views: panActivation

Ein Diagramm, das innerhalb einer vertikalen Scroll View seitlich abgetastet wird, muss horizontale Drags übernehmen und vertikale der Scroll View überlassen. Den Touch beim Start zu beanspruchen, blockiert das Scrollen vom ersten Pixel an, und die Entscheidung anhand der Bewegungen in Ihrem Code kommt zu spät: Bis sie vorliegt, hat die Scroll View den Touch möglicherweise schon übernommen. Die Pan-Aktivierung (TouchClaim::on_pan(..), panActivation) lässt die Plattform auf dem Main Thread entscheiden, während sich der Finger bewegt:

view()
    .claim_touches(TouchClaim::on_pan(PanActivation::horizontal(px(10.0), px(10.0))))
    .on_responder_grant(|e: &ResponderEvent| scrub(Some(e.location.x)))
    .on_responder_move(|e: &ResponderEvent| scrub(Some(e.location.x)))
    .on_responder_release(|_: &ResponderEvent| scrub(None))
    .child(chart())
Rust (PanActivation) TSX Beschreibung
active_offset_x, active_offset_y activeOffsetX, activeOffsetY Der Pan wird aktiviert, sobald sich der Finger entlang dieser Achse weiter als dieser Wert von der Stelle entfernt hat, an der er aufgesetzt wurde (in beide Richtungen, in Punkten).
fail_offset_x, fail_offset_y failOffsetX, failOffsetY Der Pan schlägt fehl, sobald sich der Finger zuvor weiter als dieser Wert bewegt hat: Die Scroll Views behalten die Touches.

PanActivation::horizontal(active, fail) und PanActivation::vertical(active, fail) setzen die üblichen Paare. Ohne aktive Offsets wird der Pan nach 10 Punkten in beliebiger Richtung aktiviert. Bis zur Aktivierung warten die umgebenden Scroll Views darauf (iOS), und die übrigen Ansprüche der View werden nicht abgefragt. Mit der Aktivierung können die Scroll Views diese Touches nicht mehr übernehmen, und die View wird zum Responder (erst der Grant, dann die Bewegungen); schlägt er fehl, scrollt die Scroll View wie gewohnt. In TSX funktionieren auch die panHandlers von PanResponder damit: Verteilen Sie sie per Spread neben der Prop, und onPanResponderGrant läuft bei der Aktivierung.

#Warum manche Antworten Props sind

Der Main Thread wartet nie auf Ihren Code. Wenn das System sofort eine Antwort benötigt (darf diese Pan-Geste beginnen, trifft dieser Punkt die View, soll dieser Tap die Tastatur schließen), antwortet NSH nativ anhand dessen, was zuvor gemountet wurde: die gebundenen Pan-Werte (ihre Achsen), der Pinch-Wert, der Hit Slop, die Pointer Events, die Keyboard-Taps-Einstellung von Scroll Views, die Pan-Aktivierung. Ihre Handler erfahren erst im Nachhinein, was passiert ist. Deshalb sind dies deklarative Einstellungen statt Callbacks, und deshalb entscheidet die Responder-Aushandlung, die Ihren Code tatsächlich ausführt, nur zwischen Respondern und blockiert nie eine native Geste.

#Siehe auch

  • View: jede Touch-Prop des Basiselements.
  • Animation: animierte Werte, Springs und Decay für gestengesteuerte Bewegungen.
  • Scrolling: die Keyboard-Taps-Einstellung, Scroll Views und verschachtelte Gesten.
  • PanResponder: die Referenz zu PanResponder.
  • Accessibility: Pressable Views sind Accessibility-Elemente; geben Sie ihnen eine Rolle und ein Label.