Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Документация

Касания и жесты

В NSH есть три уровня обработки касаний. Нажатия (.on_press в Rust, onPress в TSX и родственные обработчики) покрывают кнопки, строки и ссылки, а отклик на нажатие рисуется нативно. Нативные жесты (панорамирование, щипок и поворот) двигают анимированные значения в UI-потоке, поэтому перетаскиваемая карточка следует за пальцем, даже когда ваш код занят. Необработанные касания и система респондеров (.on_touch и .claim_touches с обработчиками .on_responder_* в Rust; onTouchStart, onResponderMove и PanResponder в TSX) передают вашему коду позицию каждого пальца для всего, что не могут выразить два других уровня, например для рисования.

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

Выбирайте самый высокий уровень, который решает задачу: чем выше уровень, тем меньше работы переходит из UI-потока в ваш код.

#Нажатия

Каждый элемент принимает обработчики нажатий; отдельного «touchable»-компонента нет. Добавьте .on_press / onPress к view, тексту, изображению или чему угодно ещё.

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),
            ),
    )
}
RustTSXПо умолчаниюОписание
.on_press(..)onPressПалец поднялся внутри view (после нажатия, не долгого).
.on_long_press(..)onLongPressПалец оставался прижатым в течение задержки долгого нажатия. Обработчик нажатия после этого не вызывается.
.on_press_down(..)onPressDownНажатие началось.
.on_press_up(..)onPressUpНажатие закончилось: палец поднят, соскользнул или касание перехватил scroll view.
.long_press_delay(ms)delayLongPress500Миллисекунды до того, как нажатие становится долгим.
.disabled(..)disabledfalseНе принимает нажатия и не показывает отклик; применяет стиль отключённого состояния; скринридеры сообщают, что элемент отключён.

Как ведут себя нажатия:

  • Реагирует только самое внутреннее нажимаемое view. Нажимаемая строка с нажимаемой кнопкой внутри не получает нажатие, когда нажата кнопка.
  • Внутри scroll view нажатие ждёт около 130 мс перед началом (и показом отклика), поэтому перетаскивание, начатое на кнопке, прокручивает содержимое, а не нажимает кнопку.
  • Соскальзывание отменяет нажатие. Если палец выходит за пределы view, нажатие завершается обработчиком отпускания без события нажатия.
  • Отключение убирает слушатели нажатий у смонтированного элемента, поэтому view вообще не показывает отклик и применяется стиль отключённого состояния (.disabled_style(..), class="disabled:opacity-40").
  • Вложенные текстовые фрагменты с обработчиком нажатия — это ссылки. Абзац — одно нативное view; нажатие уходит фрагменту под пальцем. См. Text.

#Отклик на нажатие

Отклик на нажатие задаётся приложением: это стили, которые view принимает, пока оно нажато, находится под курсором, в фокусе или отключено. Они применяются нативно в момент смены состояния, без обращения к вашему коду. У каждого состояния есть метод в Rust, style-проп в TSX и вариант Tailwind:

RustПроп TSXКлассОписание
.active(|s| ..)activeStyleactive:Пока view нажато.
.hover(|s| ..)hoverStylehover:Пока над ним находится указатель (десктопы, указатели iPad, браузеры).
.focus(|s| ..)focusStylefocus:Пока оно в фокусе.
.focus_within(|s| ..)focusWithinStylefocus-within:Пока в фокусе оно или view внутри него: рамка поля вокруг редактируемого input.
.disabled_style(|s| ..)disabledStyledisabled:Пока оно отключено.
.ripple(Ripple { .. })android={{ ripple }}Material ripple на Android (color, borderless, radius, foreground) в дополнение к стилям.

Стили состояний накладываются поверх собственных стилей view. Более поздние состояния имеют приоритет (hover, focus, active), стиль отключённого состояния имеет приоритет над всеми, а остальные не применяются, пока view отключено.

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

Ripple без границ расходится за пределы view, что подходит для кнопок-иконок: Ripple { borderless: true, radius: Some(px(24.0)), .. }, android={{ ripple: { borderless: true, radius: 24 } }}. См. Platform-specific code.

С переходом (.transition(), класс transition) смена состояния анимируется. .group() (класс group) превращает view в группу, а .group_active(..) (group-active:) стилизует её дочерние элементы, пока группа нажата:

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

#Области нажатия: hitSlop

Hit slop (.hit_slop(..), hitSlop) расширяет область, принимающую касания, за границы view, не меняя его layout. Используйте его для мелких целей, таких как кнопки закрытия и переключатели.

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)
RustTSXОписание
.hit_slop(EdgeInsets)hitSlop: number | { top?, right?, bottom?, left? }Пункты, добавляемые вокруг границ. EdgeInsets::all(..) и число в TSX применяются ко всем четырём сторонам.

Держите расширенную область внутри границ родителя (оставьте вокруг маленького view отступ или padding): проверка попадания достигает дочернего элемента через его родителя, поэтому касание за пределами родителя никогда не дойдёт до slop дочернего элемента.

#Дочерние элементы за пределами родителя

Дочерний элемент, расположенный за границами родителя (абсолютно позиционированный с видимым overflow, например выпадающий список под полем), принимает касания там, где нарисован, как любое другое view: NSH нативно проверяет попадание в потомков, выходящих за пределы view. Родитель, обрезающий переполнение, обрезает и их, и тогда они там ни не отображаются, ни не принимают касания. Область вычисляется из layout, поэтому дочерний элемент, вынесенный из родителя transform-ом, принимает касания только внутри границ родителя.

Учитывайте порядок отрисовки: выпадающий список рисуется поверх соседних элементов своего поля, только если поле идёт после них или имеет больший z-index, а касания достаются тому, что нарисовано сверху.

#pointerEvents

Ключ стиля pointerEvents (.pointer_events(..) в Rust) определяет, какие view поддерева могут быть целью касания. Он вычисляется нативно во время проверки попадания, поэтому не стоит ничего на каждое касание.

RustTSXViewЕго дочерние элементы
PointerEvents::Auto (по умолчанию)"auto"Принимает касанияПринимают касания
PointerEvents::None"none"Никогда не цельНикогда не цель, если только один из них не задаёт "auto": касания проходят к тому, что находится позади
PointerEvents::BoxNone"box-none"Никогда не цельПринимают касания
PointerEvents::BoxOnly"box-only"Принимает касанияНикогда не цель

Типичные применения: оверлей, который не должен блокировать то, что под ним (none); полноэкранный контейнер, пустое место которого должно пропускать касания к содержимому позади, пока его кнопки по-прежнему работают (box-none); и строка, которая должна принимать нажатие, даже когда палец попал на дочерний элемент (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" наследуется как pointer-events в CSS: view внутри него, задающее "auto", снова принимает касания. "box-none" и "box-only" действуют на view, которое их задаёт. Классы Tailwind pointer-events-none и pointer-events-auto задают "none" и "auto", а как ключ стиля pointerEvents также принимает стили состояний (activeStyle, …) и варианты.

#Нативные жесты: панорамирование, щипок и поворот

Для перетаскиваний, щипков и поворотов, которые что-то двигают на экране, привяжите к жесту анимированные значения. Распознаватель жестов платформы записывает значения в UI-потоке, а привязанные к ним view двигаются в том же кадре; ваш код получает уведомления о начале и конце жеста.

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),
    )
}
RustTSXОписание
.pan_values(Some(&x), Some(&y))pan={{ x?, y? }}Перетаскивание одним пальцем двигает эти значения, в пунктах.
.pinch_value(&scale)pinch={{ scale? }}Щипок двумя пальцами управляет scale (коэффициент).
.rotation_value(&angle)rotation={{ angle? }}Поворот двумя пальцами управляет angle, в градусах.
.on_pan_start / .on_pan_endonPanStart / onPanEndПеретаскивание началось / закончилось, с PanEvent.
.on_pinch_start / .on_pinch_endonPinchStart / onPinchEndЩипок начался / закончился: его масштаб и скорость (GestureEvent).
.on_rotation_start / .on_rotation_endonRotationStart / onRotationEndПоворот начался / закончился: его угол (Rust: GestureEvent<Radians>) и скорость.

Событие панорамирования содержит смещение (translation, Point: пункты с начала жеста), скорость (velocity_x / velocity_y; TSX: velocity, Point: пункты в секунду) и привязанные значения в этот момент (value_x / value_y; TSX: value, { x?, y? } с осями, которые привязывает pan).

Что нативная сторона делает за вас:

  • Перетаскивание продолжается оттуда, где находится значение. В начале значения считываются, и к ним прибавляется смещение; если схватить значение, которое всё ещё анимируется (пружина, возвращающаяся на место), анимация останавливается там, где оно есть.
  • Одна ось позволяет прокручивать другую. Если привязано только горизонтальное значение, перетаскивание начинается, только когда палец движется больше по горизонтали, чем по вертикали, так что горизонтальная строка со свайпом внутри вертикального scroll view по-прежнему прокручивается вертикально. Это решение принимается нативно, когда система спрашивает, может ли жест начаться.
  • Панорамирование, щипок и поворот работают вместе на одном view: одну карточку можно одновременно перетаскивать, масштабировать и вращать.
  • Скорость отпускания для физики. Скорость в конце панорамирования напрямую подаётся в скорость пружины или затухания; значения позволяют выбрать точку привязки.
// 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)

#Необработанные касания

Обработчики касаний сообщают о каждом пальце, когда он опускается, двигается и поднимается. Они полезны для логирования, собственной проверки попадания и реакции на мультитач; они не захватывают касание (нажатия и scroll view продолжают работать).

В Rust .on_touch(..) получает каждую фазу как TouchEvent: его phase (TouchPhase::Start, Move, End, Cancel), его timestamp и его points — каждый опущенный палец, у каждого свой id, его location во view, на которое он опустился, его page позиция на поверхности и changed для пальцев, о которых это событие. В TSX у каждой фазы свой обработчик:

TSXОписание
onTouchStartПалец опустился на view или внутри него.
onTouchMoveПалец сдвинулся. Движения объединяются за один оборот движка.
onTouchEndПалец поднялся.
onTouchCancelСистема забрала касание (scroll view начал прокрутку, пришёл звонок).
onTouchStartCapture, onTouchMoveCapture, onTouchEndCapture, onTouchCancelCaptureТо же самое, но вызываются снаружи внутрь, до обработчиков всплытия.

Каждый обработчик в TSX получает GestureResponderEvent:

ПолеОписание
identifierИдентифицирует палец, пока он опущен.
locationX, locationYПозиция во view, на которое опустился палец (target).
pageX, pageYПозиция на поверхности.
targetИдентификатор view, на которое опустился палец.
timestampМиллисекунды.
touchesВсе опущенные пальцы.
changedTouchesПальцы, о которых это событие.
nativeEventСамо событие, так что e.nativeEvent.locationX тоже работает.
stopPropagation()Более внешние view не видят это событие касания.
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"),
    }
})

#Где находится view: measure

Положение касания отсчитывается от view, на которое опустился палец, а оно может быть дочерним элементом нужного вам view (ползунок слайдера). Измерение view сообщает, где оно находится, так что вы можете использовать позицию на странице. В Rust добавьте element_ref() к view и дождитесь его measure(); в TSX вызовите measure() у его ref:

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

Результат приходит, когда сделанные к этому моменту изменения размещены в layout: в Rust это Measurement (frame, рамка в родителе, как её сообщает обработчик layout, и page, левый верхний угол), в TSX — { bounds, pagePosition }. Угол задан на поверхности или в диалоге, в котором находится view, в тех же координатах, что и позиции касаний на странице. Смещения окружающих scroll view и списков учитываются; transform-ы (включая анимации) не учитываются. Для view, которого нет на экране, результат — None / null.

#Система responder

Когда ваш код должен владеть последовательностью касаний (рисование пальцем, собственный слайдер, жест, которого нет среди нативных распознавателей), используйте систему gesture responder. В каждый момент времени только один view является responder и получает движения; views договариваются между собой, кто им станет.

Как стать responder:

RustTSXОписание
.claim_touches(TouchClaim::on_start())onStartShouldSetResponder, возвращающий trueЗапрашивается, когда палец касается экрана, от целевого view наружу.
.claim_touches(TouchClaim::on_move())onMoveShouldSetResponder, возвращающий trueЗапрашивается при каждом движении, пока responder нет (или чтобы перехватить его).
TouchClaim::on_start().capture()onStartShouldSetResponderCaptureТо же, что выше, но запрашивается снаружи внутрь, раньше всплывающих (bubbling) версий. Родитель использует его, чтобы забрать касание у дочерних view.
TouchClaim::on_move().capture()onMoveShouldSetResponderCaptureВерсия для движения.

Заявка (claim) решается только по самому view, без запуска вашего кода, если только она сама не спрашивает: TouchClaim::on_move().ask(|r: &ClaimRequest| ..) заявляет права, когда слушатель вызывает r.claim(), принимая решение по жесту, выполненному к этому моменту (r.event.gesture). Обработчики TSX получают жест вторым аргументом, gestureState, с теми же полями, что и у PanResponder (dx, dy, vx, vy, moveX, moveY, x0, y0, numberActiveTouches). Свайп, который заявляет права только на горизонтальные движения: onMoveShouldSetResponder={(e, g) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy)}. Такое решение всё равно приходит с опозданием на одно движение, поэтому окружающий scroll view может начать прокрутку раньше; для перетаскиваний внутри scroll views используйте pan activation.

Пока view является responder:

RustTSXОписание
.on_responder_grant(..)onResponderGrantЭтот view теперь responder. В Rust по умолчанию нативные scroll views не получают касания (TouchClaim::block_native(false) разрешает им это); в TSX для этого верните true.
.on_responder_reject(..)onResponderRejectДругой view является responder и не захотел уступать.
.on_responder_start(..)onResponderStartКоснулся ещё один палец.
.on_responder_move(..)onResponderMoveПалец сдвинулся.
.on_responder_end(..)onResponderEndПалец поднялся.
.on_responder_release(..)onResponderReleaseПоднялся последний палец: жест завершён.
.keep_touches(true)onResponderTerminationRequest, возвращающий falseДругой view хочет получить касания: сохраните их (по умолчанию они передаются).
.on_responder_terminate(..)onResponderTerminateДругой view или нативный жест забрал касания.

Responder компонента на Rust остаётся нажатым от момента получения (grant) до окончания его касаний, куда бы ни ушёл палец или указатель, так же как :active в CSS удерживается, пока кнопка не будет отпущена: его стили .active(..) и классы group-active: внутри него сохраняют свой вид на протяжении всего перетаскивания (например, ручка изменения размера, которую утащили за пределы её полосы).

Обработчики Rust получают ResponderEvent: location пальца в том view, на котором он коснулся экрана, его позицию page, а также timestamp и gesture с момента 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);
            }
        })
    })
}

Как это работает: view с любым обработчиком касаний или responder слушает касания нативно; платформа сообщает о касаниях на нём (и внутри него) вместе с путём слушающих views, а согласование выполняется в Rust-ядре NSH, которое опрашивает заявки и вызывает ваши обработчики по порядку: сначала capture-обработчики от внешнего view внутрь, затем остальные от целевого view наружу. Отсюда два следствия:

  • Нативные scroll views и responders конкурируют. Пока responder блокирует их, окружающие scroll views не получают касания до его завершения. Если нативный scroll view начнёт перетаскивание первым, он побеждает: responder получает обработчик terminate, а обработчики касаний получают отмену.
  • Каждое движение является событием для вашего кода. Перетаскивания под управлением responder работают плавно настолько, насколько быстр ваш код; для перетаскиваний, которые только перемещают views, предпочитайте привязанные значения.

#PanResponder

В Rust события responder уже несут информацию о жесте: ResponderEvent::gesture — это PanGesture с расстоянием от начала, скоростью и числом пальцев. В TSX PanResponder из nsh оборачивает систему responder и добавляет то же состояние жеста; передайте его panHandlers во view через spread.

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

Состояние жеста:

Rust (PanGesture)TSX (gestureState)Описание
stateIDИдентифицирует жест.
positionmoveX, moveYПоследняя позиция центроида касаний, в точках поверхности.
originx0, y0Где находился центроид, когда был выдан responder.
deltadx, dyРасстояние, пройденное с момента grant.
vx, vyvx, vyСкорость, в точках в секунду.
active_touchesnumberActiveTouchesКоличество пальцев на экране.

PanGesture::begin и PanGesture::update отслеживают жест по необработанным касаниям (.on_touch) в Rust. Колбэки TSX получают (event, gestureState): onStartShouldSetPanResponder(Capture), onMoveShouldSetPanResponder(Capture), onPanResponderGrant, onPanResponderReject, onPanResponderStart, onPanResponderMove, onPanResponderEnd, onPanResponderRelease, onPanResponderTerminate, onPanResponderTerminationRequest и onShouldBlockNativeResponder. Последний по умолчанию равен true: PanResponder, получивший grant, не позволяет нативным scroll views забирать его касания.

#Pan внутри scroll views: panActivation

График, который прокручивают пальцем вбок внутри вертикального scroll view, должен принимать горизонтальные перетаскивания и оставлять вертикальные scroll view. Если заявлять права на касание сразу при его начале, прокрутка блокируется с первого пикселя, а если решать по движениям в вашем коде, будет слишком поздно: к моменту ответа scroll view может уже забрать касание. Pan activation (TouchClaim::on_pan(..), panActivation) позволяет платформе принимать решение в главном потоке по мере движения пальца:

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Описание
active_offset_x, active_offset_yactiveOffsetX, activeOffsetYPan активируется, как только палец сместился дальше этого значения от точки касания вдоль данной оси (в любую сторону, в точках).
fail_offset_x, fail_offset_yfailOffsetX, failOffsetYPan отменяется, как только палец сместился дальше этого значения раньше: scroll views сохраняют касания.

PanActivation::horizontal(active, fail) и PanActivation::vertical(active, fail) задают обычные пары значений. Без active offsets pan активируется после 10 точек в любом направлении. Пока он не активирован, окружающие scroll views ждут его (iOS), а другие заявки этого view не запрашиваются. При активации scroll views больше не могут забрать эти касания, а view становится responder (сначала grant, затем движения); при отмене scroll view прокручивается как обычно. В TSX panHandlers у PanResponder тоже работают вместе с ним: передайте их рядом с prop, а onPanResponderGrant вызывается при активации.

#Почему некоторые ответы задаются через props

Главный поток никогда не ждёт ваш код. Когда системе нужен ответ немедленно (может ли начаться этот pan-жест, попадает ли точка во view, должно ли это касание скрыть клавиатуру), NSH отвечает нативно, опираясь на то, что было смонтировано заранее: привязанные pan-значения (их оси), значение pinch, hit slop, pointer events, настройку keyboard taps у scroll views, pan activation. Ваши обработчики узнают о случившемся уже потом. Поэтому это декларативные настройки, а не колбэки, и поэтому согласование responder, которое всё же выполняет ваш код, лишь выбирает между responders и никогда не блокирует нативный жест.

#См. также

  • View: все touch-props базового элемента.
  • Animation: анимированные значения, spring и decay для движения, управляемого жестами.
  • Scrolling: настройка keyboard taps, scroll views и вложенные жесты.
  • PanResponder: справочник по PanResponder.
  • Accessibility: pressable views являются элементами accessibility; задайте им роль и подпись.