本頁由機器翻譯,可能有錯誤。 閱讀英文原文

文件

觸控與手勢

NSH 有三個層級的觸控處理。按壓(Rust 中的 .on_press、TSX 中的 onPress,以及其同類處理器)涵蓋按鈕、列與連結,並以原生方式繪製按壓回饋。原生手勢(平移、縮放與旋轉)在 UI 執行緒上驅動動畫值,因此即使你的程式碼正忙碌,被拖曳的卡片仍會跟著手指移動。原始觸控與 responder 系統(Rust 中的 .on_touch 與 .claim_touches 搭配 .on_responder_* 處理器;TSX 中的 onTouchStart、onResponderMove 與 PanResponder)會把每根手指的位置交給你的程式碼,用於另外兩個層級無法表達的情境,例如繪圖。

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 放在視圖、文字、圖片或任何其他元素上。

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手指在視圖內抬起(發生在按壓之後,而非長按之後)。
.on_long_press(..)onLongPress手指持續按住達到長按延遲時間。之後不會再觸發按壓處理器。
.on_press_down(..)onPressDown按壓開始。
.on_press_up(..)onPressUp按壓結束:手指抬起、滑出視圖,或被捲動視圖接管。
.long_press_delay(ms)delayLongPress500按壓轉為長按之前的毫秒數。
.disabled(..)disabledfalse不接收按壓且不顯示回饋;套用停用樣式;螢幕閱讀器會說明其為停用狀態。

按壓的行為如下:

  • 只有最內層的可按壓視圖會回應。 包含可按壓按鈕的可按壓列,在按下按鈕時不會收到按壓。
  • 在捲動視圖內,按壓會等待約 130 毫秒才開始(並顯示回饋),因此從按鈕上開始的拖曳會捲動,而不是按下按鈕。
  • 滑出會取消按壓。 手指移出視圖時,會以按壓結束處理器結束按壓,且不會觸發按壓。
  • 停用會從已掛載的內容中移除按壓監聽器,因此視圖完全不顯示回饋,並套用停用樣式(.disabled_style(..)、class="disabled:opacity-40")。
  • 帶有按壓處理器的巢狀文字片段就是連結。 段落是單一原生視圖;按壓會交給手指下方的片段。請參閱文字。

#按壓回饋

按壓回饋由應用程式定義:視圖在被按壓、懸停、取得焦點或停用時所採用的樣式。這些樣式會在狀態改變的瞬間於原生端套用,不需要往返你的程式碼。每種狀態都有對應的 Rust 方法、TSX 樣式屬性與 Tailwind 變體:

RustTSX 屬性類別說明
.active(|s| ..)activeStyleactive:被按壓時。
.hover(|s| ..)hoverStylehover:指標位於其上方時(桌面、iPad 指標、瀏覽器)。
.focus(|s| ..)focusStylefocus:取得焦點時。
.focus_within(|s| ..)focusWithinStylefocus-within:它或其內部的視圖取得焦點時:例如包住實際編輯輸入框的欄位外框。
.disabled_style(|s| ..)disabledStyledisabled:停用時。
.ripple(Ripple { .. })android={{ ripple }}Android 的 material 漣漪效果(color、borderless、radius、foreground),與樣式並用。

狀態樣式會疊加在視圖自身的樣式之上。較後面的狀態優先(hover、focus、active),停用樣式優先於全部,而視圖停用時其他狀態樣式不會套用。

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 { borderless: true, radius: Some(px(24.0)), .. }、android={{ ripple: { borderless: true, radius: 24 } }}。請參閱平台專屬程式碼。

搭配轉場(.transition(),即 transition 類別)時,狀態變化會以動畫呈現。.group()(group 類別)會讓視圖成為群組,而 .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().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 的數字會套用到四個邊。

請讓擴展後的區域保持在父視圖的邊界內(在小視圖周圍留一些 margin 或 padding):命中測試是透過父視圖到達子視圖的,因此落在父視圖之外的觸控永遠不會到達子視圖的 slop。

#位於父視圖之外的子視圖

位於父視圖邊界之外的子視圖(以 overflow 絕對定位且保持可見,例如欄位下方的下拉選單)會像其他視圖一樣,在它被繪製的位置接收觸控:NSH 會在原生端對超出視圖範圍的子孫視圖進行命中測試。若父視圖裁切其溢出內容,這些子視圖就會被裁切,並且在該處既不會顯示也不會接收觸控。此區域是依據版面配置計算的,因此被 transform 移出父視圖的子視圖,只會在父視圖的邊界內接收觸控。

請留意繪製順序:下拉選單只有在其欄位排在這些同層視圖之後,或具有較高的 z-index 時,才會繪製在它們之上,而觸控會交給最上層繪製的視圖。

#pointerEvents

樣式鍵 pointerEvents(Rust 中為 .pointer_events(..))決定子樹中的哪些視圖可以成為觸控的目標。它在命中測試期間於原生端求值,因此每次觸控都不會產生額外成本。

RustTSX該視圖其子視圖
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" 的繼承方式與 CSS 的 pointer-events 相同:其中設定 "auto" 的視圖會重新接收觸控。"box-none" 與 "box-only" 僅作用於設定它們的視圖。Tailwind 類別 pointer-events-none 與 pointer-events-auto 會設定 "none" 與 "auto",而作為樣式鍵時,pointerEvents 也接受狀態樣式(activeStyle、…)與變體。

#原生手勢:平移、縮放與旋轉

對於會在畫面上移動內容的拖曳、縮放與旋轉,請將動畫值綁定到手勢。平台的手勢辨識器會在 UI 執行緒上寫入這些值,綁定到它們的視圖會在同一個影格中移動;你的程式碼只會在手勢開始與結束時收到通知。

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 所綁定的軸)。

原生端會為你處理以下事項:

  • 拖曳會從值目前所在處繼續。 開始時會讀取這些值,並把位移加到其上;抓住仍在動畫中的值(例如正彈回原位的彈簧)會讓動畫停在當下的位置。
  • 只綁定一個軸時,另一個軸可以捲動。 若只綁定水平值,只有當手指的水平移動大於垂直移動時,拖曳才會開始,因此垂直捲動視圖內的水平滑動列仍然可以垂直捲動。這個判斷是在系統詢問手勢是否可以開始時,於原生端做出的。
  • 平移、縮放與旋轉會在同一個視圖上同時運作:同一張卡片可以同時被拖曳、縮放與旋轉。
  • 放開時的速度可用於物理效果。 平移結束時的速度可以直接餵給彈簧或衰減動畫的速度;這些值可讓你選擇吸附點。
// 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)

#原始觸控

觸控處理器會回報每根手指的按下、移動與抬起。它們適用於記錄、自訂命中測試以及回應多點觸控;它們不會宣告取得該觸控(按壓與捲動視圖仍會繼續運作)。

在 Rust 中,.on_touch(..) 會以 TouchEvent 接收每個階段:包含其 phase(TouchPhase::Start、Move、End、Cancel)、timestamp 與 points(目前按下的每根手指),每根手指都有其 id、在其按下的視圖中的 location、在畫面中的 page 位置,以及標示該事件所涉及手指的 changed。在 TSX 中,每個階段都有各自的處理器:

TSX說明
onTouchStart有手指按在視圖上或其內部。
onTouchMove手指移動。移動事件會在每個引擎週期內合併。
onTouchEnd手指抬起。
onTouchCancel系統接管了該觸控(捲動視圖開始捲動、來電等)。
onTouchStartCapture、onTouchMoveCapture、onTouchEndCapture、onTouchCancelCapture同上,但由外向內呼叫,且早於冒泡處理器。

每個 TSX 處理器都會收到一個 GestureResponderEvent:

欄位說明
identifier在手指按下期間用來識別該手指。
locationX、locationY在手指按下的視圖中的位置(target)。
pageX、pageY在畫面中的位置。
target手指按下的視圖的 id。
timestamp毫秒。
touches目前按下的每根手指。
changedTouches此事件所涉及的手指。
nativeEvent事件本身,因此 e.nativeEvent.locationX 也可以運作。
stopPropagation()更外層的視圖不會看到這個觸控事件。
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"),
    }
})

#視圖的位置:measure

觸控的位置是相對於手指按下的視圖,而該視圖可能是你所關心之視圖的子視圖(例如滑桿的旋鈕)。測量視圖可以告訴你它的位置,讓你改用頁面位置。在 Rust 中,請在視圖上放置 element_ref() 並 await 其 measure();在 TSX 中,請對其 ref 呼叫 measure():

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

它會在目前為止所做的變更都完成版面配置後才解析:在 Rust 中解析為 Measurement(frame,即版面配置處理器所回報的、在父視圖中的框架,以及 page,即左上角),在 TSX 中解析為 { bounds, pagePosition }。該角點位於畫面中,或視圖所在的對話框中,與觸控的頁面位置使用相同的座標。其周圍捲動視圖與清單的偏移量會被納入考量;transform(包括動畫)則不會。對於不在畫面上的視圖,它會解析為 None / null。

#回應者系統

當你的程式碼必須掌控一段觸控序列(手指繪圖、自訂滑桿、原生手勢辨識器未提供的手勢)時,請使用手勢回應者系統。同一時間只有一個檢視是回應者,負責接收移動事件;檢視之間會協商由誰擔任。

成為回應者:

RustTSX說明
.claim_touches(TouchClaim::on_start())onStartShouldSetResponder 回傳 true手指按下時詢問,從目標向外依序進行。
.claim_touches(TouchClaim::on_move())onMoveShouldSetResponder 回傳 true在沒有回應者時(或為了接手時),每次移動都會詢問。
TouchClaim::on_start().capture()onStartShouldSetResponderCapture同上,但由外向內詢問,且先於冒泡階段的詢問。父層可用它從子層手中接管觸控。
TouchClaim::on_move().capture()onMoveShouldSetResponderCapture移動版本。

宣告(claim)僅憑檢視本身決定,不會執行你的任何程式碼,除非它主動詢問: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)}。這項決定仍會晚一次移動才做出,因此外層的捲動檢視可能會先開始捲動;若是在捲動檢視內的拖曳,請使用平移啟動。

成為回應者之後:

RustTSX說明
.on_responder_grant(..)onResponderGrant此檢視現在是回應者。在 Rust 中,預設會阻止原生捲動檢視接手觸控(TouchClaim::block_native(false) 則允許它們接手);在 TSX 中,請回傳 true 來達成。
.on_responder_reject(..)onResponderReject另一個檢視是回應者,且不肯放手。
.on_responder_start(..)onResponderStart另一根手指按下。
.on_responder_move(..)onResponderMove手指移動。
.on_responder_end(..)onResponderEnd一根手指抬起。
.on_responder_release(..)onResponderRelease最後一根手指抬起:手勢結束。
.keep_touches(true)onResponderTerminationRequest 回傳 false另一個檢視想要接手觸控:保留它們(預設會交出去)。
.on_responder_terminate(..)onResponderTerminate另一個檢視或原生手勢接手了觸控。

Rust 元件的回應者從被授予起到其觸控結束為止,都會維持按下狀態,無論手指或指標移動到哪裡,就像 CSS 的 :active 會持續到按鈕放開一樣:它的 .active(..) 樣式與其內部的 group-active: 類別在整個拖曳過程中都會維持外觀(例如被拖出其區帶的調整大小把手)。

Rust 的處理函式會取得 ResponderEvent:手指在其按下的檢視中的 location、其 page 位置,以及自被授予以來的 timestamp 與 gesture。

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

運作方式:任何帶有觸控或回應者處理函式的檢視都會以原生方式監聽觸控;平台會回報該檢視上(及其內部)的觸控,並附上監聽檢視的路徑,而協商過程則在 NSH 的 Rust 核心中執行,依序詢問各項宣告並呼叫你的處理函式:先由最外層的檢視向內處理捕獲階段,再從目標向外處理其餘階段。這帶來兩個結果:

  • 原生捲動檢視與回應者會互相競爭。 當回應者阻止它們時,周圍的捲動檢視在回應者結束之前都不會接手觸控。如果原生捲動檢視先開始拖曳,則由它勝出:回應者會收到終止處理函式,而觸控處理函式會收到取消。
  • 每次移動都是傳給你程式碼的一個事件。 由回應者驅動的拖曳,流暢程度取決於你的程式碼有多快;若拖曳只是移動檢視,建議改用綁定值。

#PanResponder

在 Rust 中,回應者事件本身就帶有手勢資訊:ResponderEvent::gesture 是一個 PanGesture,包含與起點的距離、速度以及手指數量。在 TSX 中,來自 nsh 的 PanResponder 包裝了回應者系統,並加上相同的手勢狀態;請將其 panHandlers 展開到檢視上。

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觸控質心的最新位置,單位為表面點(surface points)。
originx0、y0回應者被授予時質心所在的位置。
deltadx、dy自被授予以來移動的距離。
vx、vyvx、vy速度,單位為每秒點數。
active_touchesnumberActiveTouches按下的手指數量。

在 Rust 中,PanGesture::begin 與 PanGesture::update 會根據原始觸控(.on_touch)追蹤手勢。TSX 的回呼會取得 (event, gestureState):onStartShouldSetPanResponder(Capture)、onMoveShouldSetPanResponder(Capture)、onPanResponderGrant、onPanResponderReject、onPanResponderStart、onPanResponderMove、onPanResponderEnd、onPanResponderRelease、onPanResponderTerminate、onPanResponderTerminationRequest 與 onShouldBlockNativeResponder。最後一項預設為 true:被授予的 PanResponder 會阻止原生捲動檢視接手它的觸控。

#捲動檢視內的平移:panActivation

在垂直捲動檢視內橫向拖曳瀏覽的圖表,必須接手水平拖曳,並把垂直拖曳留給捲動檢視。在開始時就宣告觸控,會從第一個像素起就阻止捲動;而在你的程式碼中依據移動來決定又太遲:等它給出答案時,捲動檢視可能已經接手了觸控。平移啟動(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、activeOffsetY當手指沿該軸(任一方向,單位為點)從按下處移動超過此距離時,平移即啟動。
fail_offset_x、fail_offset_yfailOffsetX、failOffsetY若手指先移動超過此距離,平移即失敗:觸控由捲動檢視保留。

PanActivation::horizontal(active, fail) 與 PanActivation::vertical(active, fail) 可設定常用的成對值。若未設定啟動偏移量,平移會在任意方向移動 10 點後啟動。在啟動之前,周圍的捲動檢視會等待它(iOS),且不會詢問該檢視的其他宣告。啟動時,捲動檢視就無法再接手這些觸控,而該檢視會成為回應者(先授予,再接收移動);失敗時,捲動檢視則照常捲動。在 TSX 中,PanResponder 的 panHandlers 也能搭配使用:將它們展開在該 prop 旁邊,而 onPanResponderGrant 會在啟動時執行。

#為什麼有些答案是 prop

主執行緒絕不會等待你的程式碼。當系統需要立即得到答案時(這個平移手勢可以開始嗎、這個點是否命中該檢視、這次點擊是否應該收起鍵盤),NSH 會根據事先掛載的內容以原生方式回答:綁定的平移值(其軸向)、縮放值、點擊延伸範圍(hit slop)、指標事件、捲動檢視的鍵盤點擊設定,以及平移啟動。你的處理函式則是在事情發生之後才得知。這就是為什麼這些是宣告式設定而不是回呼,也是為什麼會執行你程式碼的回應者協商,只在各回應者之間做決定,而絕不會阻擋原生手勢。

#相關內容

  • View:基本元素上的所有觸控 prop。
  • Animation:用於手勢驅動動態效果的動畫值、彈簧與衰減。
  • Scrolling:鍵盤點擊設定、捲動檢視與巢狀手勢。
  • PanResponder:PanResponder 參考文件。
  • Accessibility:可按壓的檢視屬於無障礙元素;請為它們指定角色與標籤。