تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

التوثيق

اللمس والإيماءات

يتعامل NSH مع اللمس على ثلاثة مستويات. الضغطات (.on_press في Rust وonPress في TSX وما يشبههما) تغطي الأزرار والصفوف والروابط، مع رسم تأثير الضغط بشكل أصلي. الإيماءات الأصلية (السحب والقرص والتدوير) تحرّك القيم المتحركة على خيط واجهة المستخدم (UI thread)، فتتبع البطاقة المسحوبة الإصبع حتى لو كانت شيفرتك مشغولة. اللمسات الخام ونظام المستجيب (.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);

اختر أعلى مستوى يؤدي المطلوب: كلما ارتفع المستوى، قلّ العمل الذي ينتقل من خيط واجهة المستخدم إلى شيفرتك.

#الضغطات

يقبل كل عنصر معالجات الضغط؛ فلا يوجد مكوّن “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رُفع الإصبع داخل العنصر (بعد ضغطة عادية، وليس ضغطة مطوّلة).
.on_long_press(..)onLongPressبقي الإصبع مضغوطًا طوال مهلة الضغطة المطوّلة. لا يُستدعى معالج الضغط بعدها.
.on_press_down(..)onPressDownبدأت الضغطة.
.on_press_up(..)onPressUpانتهت الضغطة: بالرفع، أو بالانزلاق خارج العنصر، أو باستيلاء scroll view عليها.
.long_press_delay(ms)delayLongPress500عدد المللي ثانية قبل أن تصبح الضغطة ضغطة مطوّلة.
.disabled(..)disabledfalseلا يستقبل ضغطات ولا يُظهر أي تأثير؛ يطبّق نمط التعطيل؛ وتعلن قارئات الشاشة أنه معطّل.

سلوك الضغطات:

  • يستجيب العنصر القابل للضغط الأعمق فقط. الصف القابل للضغط الذي يحتوي على زر قابل للضغط لا يتلقى ضغطة عند الضغط على الزر.
  • داخل scroll view، تنتظر الضغطة نحو 130 ms قبل أن تبدأ (ويظهر تأثيرها)، فيؤدي السحب الذي يبدأ على زر إلى التمرير بدل الضغط عليه.
  • الانزلاق خارج العنصر يلغي الضغطة. تحريك الإصبع خارج العنصر ينهي الضغطة بمعالج رفع الضغط ودون ضغطة مكتملة.
  • التعطيل يزيل مستمعي الضغط من العناصر المركّبة، فلا يُظهر العنصر أي تأثير إطلاقًا، ويُطبَّق نمط التعطيل (.disabled_style(..)، class="disabled:opacity-40").
  • المقاطع النصية المتداخلة التي لها معالج ضغط تعمل كروابط. الفقرة view أصلي واحد، وتذهب الضغطة إلى المقطع الذي تحت الإصبع. راجع Text.

#تأثير الضغط

تأثير الضغط من مسؤولية التطبيق: هو الأنماط التي يتخذها العنصر أثناء الضغط عليه أو تمرير المؤشر فوقه أو حصوله على التركيز أو تعطيله. تُطبَّق أصليًا لحظة تغيّر الحالة، دون رحلة ذهاب وإياب إلى شيفرتك. لكل حالة دالة في Rust، وخاصية نمط في TSX، ومتغيّر Tailwind:

Rustخاصية TSXالصنفالوصف
.active(|s| ..)activeStyleactive:أثناء الضغط.
.hover(|s| ..)hoverStylehover:أثناء وجود مؤشر فوقه (أجهزة سطح المكتب، ومؤشرات iPad، والمتصفحات).
.focus(|s| ..)focusStylefocus:أثناء حصوله على التركيز.
.focus_within(|s| ..)focusWithinStylefocus-within:أثناء حصوله هو أو عنصر بداخله على التركيز: إطار الحقل حول الإدخال الذي يُحرَّر.
.disabled_style(|s| ..)disabledStyledisabled:أثناء التعطيل.
.ripple(Ripple { .. })android={{ ripple }}تأثير material ripple في Android (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 غير المحدود بحدود خارج العنصر، وهو مناسب لأزرار الأيقونات: Ripple { borderless: true, radius: Some(px(24.0)), .. }، android={{ ripple: { borderless: true, radius: 24 } }}. راجع Platform-specific code.

مع الانتقال (.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 يُطبَّقان على الجوانب الأربعة.

أبقِ المنطقة الموسّعة داخل حدود العنصر الأب (أعطِ العنصر الصغير هامشًا خارجيًا أو داخليًا حوله): فاختبار الإصابة يصل إلى الابن عبر أبيه، لذلك لا تصل اللمسة الواقعة خارج الأب إلى hit slop الخاص بالابن.

#أبناء خارج حدود أبيهم

الابن الموضوع خارج حدود أبيه (بتموضع مطلق باستخدام overflow وبقي ظاهرًا، مثل قائمة منسدلة تحت حقلها) يستقبل اللمسات حيث يُرسم، مثل أي عنصر آخر: يجري NSH اختبار الإصابة أصليًا للعناصر الفرعية التي تمتد خارج عنصر ما. الأب الذي يقصّ ما يفيض عنه يقصّها، وعندها لا تظهر ولا تستقبل لمسات هناك. تُحسب المنطقة من التخطيط، لذا فإن الابن الذي نُقل خارج أبيه بواسطة transform لا يستقبل اللمسات إلا داخل حدود أبيه.

انتبه إلى ترتيب الرسم: تُرسم القائمة المنسدلة فوق العناصر الشقيقة لحقلها فقط إذا جاء الحقل بعدها أو كان له z-index أعلى، وتذهب اللمسات إلى ما يُرسم في الأعلى.

#pointerEvents

مفتاح النمط pointerEvents (.pointer_events(..) في Rust) يحدد أي عناصر الشجرة الفرعية يمكن أن تكون هدفًا للمسة. يُقيَّم أصليًا أثناء اختبار الإصابة، فلا يكلّف شيئًا لكل لمسة.

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" يُورَّث مثل pointer-events في CSS: العنصر الذي بداخله ويضبط "auto" يستقبل اللمسات من جديد. "box-none" و"box-only" يُطبَّقان على العنصر الذي يضبطهما. صنفا Tailwind ‏pointer-events-none وpointer-events-auto يضبطان "none" و"auto"، وبوصفه مفتاح نمط يقبل pointerEvents أيضًا أنماط الحالات (activeStyle، …) والمتغيّرات.

#الإيماءات الأصلية: السحب والقرص والتدوير

لعمليات السحب والقرص والتدوير التي تحرّك شيئًا على الشاشة، اربط القيم المتحركة بالإيماءة. يكتب مُعرِّف إيماءات المنصة القيم على خيط واجهة المستخدم، فتتحرك العناصر المرتبطة بها في الإطار نفسه؛ ويُبلَّغ كودك عند بدء الإيماءة وانتهائها.

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 عمودي يتمرر عموديًا. يُتخذ هذا القرار أصليًا حين يسأل النظام عمّا إذا كان يجوز للإيماءة أن تبدأ.
  • السحب والقرص والتدوير تعمل معًا على العنصر نفسه: يمكن سحب بطاقة واحدة وقرصها وتدويرها في آن واحد.
  • سرعة الإفلات للفيزياء. سرعة نهاية السحب تغذّي سرعة النابض أو التباطؤ (decay) مباشرةً؛ وتتيح لك القيم اختيار نقطة التثبيت.
// 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 views تعمل).

في Rust، يستمع .on_touch(..) إلى كل مرحلة بوصفها TouchEvent: phase الخاصة بها (TouchPhase::Start، Move، End، Cancel)، وtimestamp وpoints، أي كل إصبع نازل، ولكل منها id وlocation في العنصر الذي نزل عليه، وموضعه page في السطح، وchanged للأصابع التي يخصها الحدث. في TSX لكل مرحلة معالجها الخاص:

TSXالوصف
onTouchStartنزل إصبع على العنصر أو داخله.
onTouchMoveتحرك إصبع. تُدمج الحركات في كل دورة للمحرك.
onTouchEndرُفع إصبع.
onTouchCancelاستولى النظام على اللمسة (بدأ scroll view بالتمرير، أو ورد اتصال).
onTouchStartCapture، onTouchMoveCapture، onTouchEndCapture، onTouchCancelCaptureالمعالجات نفسها، لكن تُستدعى من الخارج إلى الداخل، قبل معالجات الفقاعة (bubbling).

يتلقى كل معالج في TSX GestureResponderEvent:

الحقلالوصف
identifierيعرّف الإصبع طالما هو نازل.
locationX، locationYالموضع في العنصر الذي نزل عليه الإصبع (target).
pageX، pageYالموضع في السطح.
targetمعرّف العنصر الذي نزل عليه الإصبع.
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() على العنصر وانتظر 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()))

تكتمل العملية بعد أن يُجرى تخطيط التغييرات التي حدثت حتى الآن: في Rust تُنتج Measurement (frame، وهو الإطار داخل الأب كما يبلّغ به معالج التخطيط، وpage، وهي الزاوية العلوية اليسرى)، وفي TSX تُنتج { bounds, pagePosition }. الزاوية محسوبة في السطح، أو في الحوار الذي يوجد فيه العنصر، وهي نفس إحداثيات مواضع اللمسات في الصفحة. تؤخذ في الحسبان إزاحات scroll views والقوائم المحيطة به؛ أما التحويلات (transforms)، بما فيها الحركات المتحركة، فلا تؤخذ. وتنتج None / null للعنصر غير الظاهر على الشاشة.

#نظام المستجيب

عندما يحتاج الكود الخاص بك إلى امتلاك تسلسل لمسات كامل (الرسم بالإصبع، أو شريط تمرير مخصص، أو إيماءة لا توفرها أدوات التعرف الأصلية)، استخدم نظام مستجيب الإيماءات (gesture responder system). في كل لحظة يكون عرض واحد فقط هو المستجيب ويتلقى الحركات، وتتفاوض العروض فيما بينها لتحديده.

أن يصبح العرض هو المستجيب:

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نسخة الحركة.

يُحسم الطلب من العرض وحده، دون تشغيل أي كود من عندك، ما لم يكن الطلب يستدعي ذلك: 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)}. يأتي هذا القرار متأخرًا بحركة واحدة، لذلك قد يبدأ عرض التمرير المحيط بالتمرير أولًا؛ وفي السحبات داخل عروض التمرير استخدم pan activation.

أثناء الاستجابة:

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 مضغوطًا من لحظة منحه حتى انتهاء لمساته، أينما ذهب الإصبع أو المؤشر، تمامًا كما يبقى :active في CSS مضغوطًا حتى يُحرَّر الزر: تحتفظ أنماط .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);
            }
        })
    })
}

كيف يعمل: أي عرض له معالج لمس أو معالج مستجيب يستمع إلى اللمسات أصليًا؛ تُبلغ المنصة عن اللمسات عليه (وداخله) مع مسار العروض المستمعة، وتجري المفاوضة في نواة Rust الخاصة بـ NSH، فتسأل الطلبات وتستدعي معالجاتك بالترتيب: معالجات الالتقاط (capture) من العرض الأبعد نحو الداخل، ثم بقية المعالجات من الهدف نحو الخارج. ينتج عن ذلك أمران:

  • عروض التمرير الأصلية والمستجيبون يتنافسون. ما دام المستجيب يحجبها، تتوقف عروض التمرير المحيطة به عن أخذ اللمسات حتى تنتهي. وإذا بدأ عرض تمرير أصلي السحب أولًا فإنه يفوز: يتلقى المستجيب معالج الإنهاء (terminate)، وتتلقى معالجات اللمس إلغاءً (cancel).
  • كل حركة هي حدث يصل إلى الكود الخاص بك. تكون السحبات التي يقودها المستجيب سلسة بقدر سرعة الكود الخاص بك؛ أما السحبات التي تحرك العروض فقط فالأفضل لها القيم المرتبطة.

#PanResponder

في Rust تحمل أحداث المستجيب الإيماءة بالفعل: ResponderEvent::gesture هو PanGesture يتضمن المسافة من نقطة البداية والسرعة وعدد الأصابع. وفي TSX يغلّف PanResponder من nsh نظام المستجيب ويضيف حالة الإيماءة نفسها؛ مرّر panHandlers الخاص به إلى العرض عبر 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موضع مركز الثقل عند منح المستجيب.
deltadx، dyالمسافة المقطوعة منذ المنح.
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 الذي مُنح الاستجابة يمنع عروض التمرير الأصلية من أخذ لمساته.

#السحبات داخل عروض التمرير: 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 تعمل panHandlers الخاصة بـ PanResponder معه أيضًا: مرّرها بجانب الخاصية (prop)، ويُنفَّذ onPanResponderGrant عند التفعيل.

#لماذا تكون بعض الإجابات props

لا ينتظر الخيط الرئيسي الكود الخاص بك أبدًا. وعندما يحتاج النظام إلى إجابة فورية (هل يجوز أن تبدأ إيماءة السحب هذه، وهل تصيب هذه النقطة العرض، وهل ينبغي لهذه النقرة أن تُغلق لوحة المفاتيح)، يجيب NSH أصليًا بناءً على ما رُكِّب مسبقًا: قيم السحب المرتبطة (محاورها)، وقيمة التقريب بإصبعين (pinch)، وهامش الإصابة (hit slop)، وأحداث المؤشر، وإعداد نقرات لوحة المفاتيح في عروض التمرير، وتفعيل السحب. أما معالجاتك فتعلم بما حدث بعد وقوعه. ولهذا السبب كانت هذه إعدادات تصريحية وليست دوال استدعاء، ولهذا أيضًا فإن مفاوضة المستجيب، التي تشغّل الكود الخاص بك، تفصل بين المستجيبين فقط ولا تعطّل أبدًا إيماءة أصلية.

#ذات صلة

  • View: كل خصائص اللمس في العنصر الأساسي.
  • Animation: القيم المتحركة والنوابض والتباطؤ للحركة التي تقودها الإيماءات.
  • Scrolling: إعداد نقرات لوحة المفاتيح، وعروض التمرير، والإيماءات المتداخلة.
  • PanResponder: مرجع PanResponder.
  • Accessibility: العروض القابلة للضغط هي عناصر إتاحة (accessibility elements)؛ امنحها دورًا (role) وتسمية (label).