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

العناصر

List

يعرض List بيانات طويلة أو غير محدودة. الصفوف القريبة من منطقة العرض فقط هي الموجودة: عندما يمرّر المستخدم، تُعاد تدوير الصفوف التي تغادر لتحل محلها الصفوف الداخلة. تُحسب أيضًا الصفوف الموجودة ومواضعها وأعمدتها والرؤوس الثابتة وقابلية المشاهدة كلها بشكل أصلي، لذا فإن تمرير قائمة من آلاف العناصر لا يكلّف أكثر من تمرير قائمة من عشرين عنصرًا. في Rust تُنشأ القائمة باستخدام virtual_list(count, |at| row)، وفي TSX باستخدام <List> من nsh.

el-list.tsx
el-list.tsxLive
import { List, type ListRef, mount, signal, Text, View } from "nsh";
import { button, buttonText, desktop, Inspector, Panel, size, tint, ui, wide } from "./palette";

type Trade = { id: string; symbol: string; shares: number; change: number };

const symbols = ["AAPL", "NVDA", "TSLA", "MSFT", "AMZN", "META", "GOOG", "AMD", "NFLX", "INTC"];
const LIMIT = 200;

// A page of made-up trades; ids keep counting so every row has its own key.
let next = 0;
const page = (n: number): Trade[] =>
  Array.from({ length: n }, () => {
    const i = next++;
    return { id: `t${i}`, symbol: symbols[(i * 7) % symbols.length], shares: 10 + ((i * 37) % 290), change: (((i * 53) % 200) - 100) / 10 };
  });

function App() {
  const trades = signal(page(40));
  const loading = signal(false);
  const loads = signal(0);
  const refreshing = signal(false);
  const visible = signal("");
  let list!: ListRef;

  const loadMore = () => {
    if (loading() || trades().length >= LIMIT) return;
    loading.set(true);
    setTimeout(() => {
      trades.set([...trades(), ...page(20)]);
      loads.update((n) => n + 1);
      loading.set(false);
    }, 600);
  };

  const refresh = () => {
    refreshing.set(true);
    setTimeout(() => {
      next = 0;
      trades.set(page(40));
      loads.set(0);
      refreshing.set(false);
    }, 800);
  };
  const viewable = ({ viewableItems }: { viewableItems: { index: number }[] }) =>
    visible.set(viewableItems.length ? `${viewableItems[0].index}–${viewableItems[viewableItems.length - 1].index}` : "");
  const toIndex = () => list.scrollToIndex({ index: Math.min(100, trades().length - 1), viewPosition: 0.5 });
  const toEnd = () => list.scrollToEnd({ animated: true });
  const status = () => (loading() ? "Loading 20 more…" : trades().length >= LIMIT ? "The end" : "");
  const changeColor = (t: Trade) => (t.change >= 0 ? ui().up : ui().down);

  // A phone: cards in one column, pull to refresh.
  const cards = () => (
    <View style={{ flex: 1, backgroundColor: ui().page, paddingTop: 24 }}>
      <View style={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}>
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>list</Text>
        <Text style={{ color: ui().muted }}>
          {trades().length} rows · onLoadMore {loads()}× · visible {visible()}
        </Text>
      </View>
      <List
        ref={list}
        testID="trades"
        each={trades()}
        keyExtractor={(t) => t.id}
        numColumns={1}
        estimatedItemSize={64}
        style={{ flex: 1 }}
        contentStyle={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: 16, gap: 8 }}
        onLoadMore={loadMore}
        loadMoreThreshold={0.5}
        refreshing={refreshing()}
        onRefresh={refresh}
        viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
        onViewableItemsChange={viewable}
        header={
          <View style={{ ...ui().card, borderRadius: 14, padding: 14, gap: 10, marginBottom: 4 }}>
            <Text style={{ color: ui().muted }}>
              {desktop ? "Scroll with the wheel or trackpad" : "Swipe up; pull down to refresh"}: more rows load near the end, up to {LIMIT}.
            </Text>
            <View style={{ flexDirection: "row", gap: 8 }}>
              <Chip label="scrollToIndex 100" onPress={toIndex} />
              <Chip label="scrollToEnd" onPress={toEnd} />
            </View>
          </View>
        }
        footer={<Text style={{ color: ui().faint, textAlign: "center", paddingVertical: 16 }}>{status()}</Text>}
      >
        {(trade, index) => (
          <View style={{ ...ui().row, flex: 1, flexDirection: "row", alignItems: "center", gap: 12, padding: 14, borderRadius: 12 }}>
            <Text style={{ color: ui().faint, width: 32, fontVariant: ["tabular-nums"] }}>{index()}</Text>
            <View style={{ flex: 1 }}>
              <Text style={{ color: ui().text, fontWeight: "600" }}>{trade().symbol}</Text>
              <Text style={{ color: ui().muted, fontSize: 12 }}>
                {trade().shares} shares · key {trade().id}
              </Text>
            </View>
            <Text
              style={{
                color: changeColor(trade()),
                backgroundColor: tint(changeColor(trade()), 0.12),
                paddingHorizontal: 8,
                paddingVertical: 3,
                borderRadius: 6,
                overflow: "hidden",
                fontWeight: "600",
                fontVariant: ["tabular-nums"],
              }}
            >
              {trade().change >= 0 ? "+" : ""}
              {trade().change.toFixed(1)}%
            </Text>
          </View>
        )}
      </List>
    </View>
  );

  // A wide window: a table with fixed column lanes, its controls beside it.
  const cell = { fontSize: 13, fontVariant: ["tabular-nums" as const] };
  const lanes = { index: 48, shares: 88, key: 104, change: 88 };
  const table = () => (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>list</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Scroll with the wheel or trackpad" : "Swipe up"}: more rows load near the end, up to {LIMIT}.
      </Text>
      <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
        <View style={{ ...ui().card, flex: 1, minWidth: 0, borderRadius: 12, overflow: "hidden" }}>
          <View
            style={{
              flexDirection: "row",
              alignItems: "center",
              gap: 12,
              height: size.rowSm,
              paddingHorizontal: 12,
              borderBottomWidth: 1,
              borderBottomColor: ui().border,
              backgroundColor: ui().inset.backgroundColor,
            }}
          >
            <Text style={{ ...cell, width: lanes.index, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>#</Text>
            <Text style={{ ...cell, flex: 1, fontSize: 12, fontWeight: "600", color: ui().muted }}>Symbol</Text>
            <Text style={{ ...cell, width: lanes.shares, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>Shares</Text>
            <Text style={{ ...cell, width: lanes.key, paddingStart: 16, fontSize: 12, fontWeight: "600", color: ui().muted }}>Key</Text>
            <Text style={{ ...cell, width: lanes.change, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>Change</Text>
          </View>
          <List
            ref={list}
            testID="trades"
            each={trades()}
            keyExtractor={(t) => t.id}
            estimatedItemSize={size.row}
            style={{ flex: 1 }}
            onLoadMore={loadMore}
            loadMoreThreshold={0.5}
            refreshing={refreshing()}
            onRefresh={refresh}
            viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
            onViewableItemsChange={viewable}
            footer={<Text style={{ color: ui().faint, fontSize: 13, textAlign: "center", paddingVertical: 12 }}>{status()}</Text>}
          >
            {(trade, index) => (
              <View
                style={{
                  flexDirection: "row",
                  alignItems: "center",
                  gap: 12,
                  height: size.row - 4,
                  paddingHorizontal: 12,
                  borderBottomWidth: 1,
                  borderBottomColor: ui().line,
                }}
              >
                <Text style={{ ...cell, width: lanes.index, textAlign: "right", color: ui().faint }}>{index()}</Text>
                <Text style={{ ...cell, flex: 1, fontWeight: "600", color: ui().text }}>{trade().symbol}</Text>
                <Text style={{ ...cell, width: lanes.shares, textAlign: "right", color: ui().text }}>{trade().shares}</Text>
                <Text style={{ ...cell, width: lanes.key, paddingStart: 16, color: ui().muted }}>{trade().id}</Text>
                <Text style={{ ...cell, width: lanes.change, textAlign: "right", fontWeight: "600", color: changeColor(trade()) }}>
                  {trade().change >= 0 ? "+" : ""}
                  {trade().change.toFixed(1)}%
                </Text>
              </View>
            )}
          </List>
        </View>
        <Inspector>
          <Panel title="rows · onLoadMore · visible">
            <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
              {trades().length} rows · {loads()}× · visible {visible()}
            </Text>
          </Panel>
          <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
            <Chip label="scrollToIndex 100" onPress={toIndex} />
            <Chip label="scrollToEnd" onPress={toEnd} />
          </View>
        </Inspector>
      </View>
    </View>
  );

  return <>{() => (wide() ? table() : cards())}</>;
}

function Chip(props: { label: string; onPress: () => void }) {
  return (
    <View onPress={props.onPress} {...button()}>
      <Text style={buttonText()}>{props.label}</Text>
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

#[derive(Clone, PartialEq)]
struct Contact {
    id: usize,
    name: String,
}

fn app() -> impl IntoElement {
    let contacts = signal(
        (0..1000).map(|i| Contact { id: i, name: format!("Contact {}", i + 1) }).collect::<Vec<_>>(),
    );
    virtual_list(move || contacts.with(|c| c.len()), move |at| {
        view().h(px(56.0)).px_4().justify_center().child(
            text(move || contacts.with(|c| format!("{}. {}", at.index() + 1, c[at.index()].name)))
                .text_base(),
        )
    })
    .estimated_item_size(px(56.0))
    .flex_1()
}

في TSX، المكوّن List عام (generic): يتحقق List<T> من أن each ودالة الصف متفقان على نوع العنصر، والذي يستنتجه TypeScript من each (راجع واجهة List البرمجية). في Rust، تأخذ virtual_list عدد الصفوف (قيمة أو إشارة signal أو إغلاقًا closure: تنمو القائمة وتتقلص في مكانها) والدالة التي تبني الصف عند Position معيّن؛ وتقرأ الصفوف بيانات المكوّن عبر at.index().

للقوائم القصيرة التي يجب أن تكون صفوفها كلها موجودة (صفحة إعدادات، نموذج)، يكون استخدام Each داخل scroll view أبسط. أما للقائمة المنسّقة كجدول مجمّع، فراجع قائمة بنمط الإعدادات. ويتناول دليل القوائم الأنماط الشائعة: التحميل اللانهائي، والمحادثة، والأقسام.

#إعادة تدوير الصفوف

تُنفَّذ دالة الصف مرة واحدة لكل خلية، لا مرة لكل عنصر. وعند إعادة استخدام خلية لعنصر آخر يتغير موضعها (في TSX: الموصّلان item() وindex()؛ وفي Rust: at.index()) ويتحدّث كل ما يقرأه:

virtual_list(move || messages.with(|m| m.len()), move |at| {
    text(move || messages.with(|m| m[at.index()].body.clone()))
})

لذلك احتفظ بحالة كل عنصر في بياناتك (مفهرسة بالمعرّف id)، لا في إشارات signals تُنشأ داخل دالة الصف: فمثل هذه الإشارة تبقى مع الخلية وتظهر على عنصر آخر.

تُخفى الخلايا التي تنتظر عنصرها التالي ما دام صفّها قد اختفى أو ظهر عبر خلية أخرى، بحيث تصادف قارئات الشاشة واللمسات واختبارات واجهة المستخدم كل صف مرة واحدة فقط.

#الخصائص

الخاصيةRustالنوعالافتراضيالوصف
eachvirtual_list(count, ..)readonly T[]العناصر.
childrenvirtual_list(.., |at| row)(item: () => T, index: () => number) => JSX.Elementيعرض صفًا واحدًا.
estimatedItemSize.estimated_item_size(px(..))number50حجم العناصر التي لم تُقَس بعد، بالنقاط. التقديرات القريبة تُبقي شريط التمرير ثابتًا.
horizontal.horizontal()booleanfalseيمرّر أفقيًا.
viewport.viewport(ListViewport::..)"own" | "parent""own""own": تتمرّر القائمة داخل scroll view خاص بها. "parent": تمتد القائمة بطولها الكامل ضمن تخطيط عنصرها الأب، وتعرض الصفوف القريبة من منطقة عرض scroll view المحيط بها، والذي يمرّرها مع بقية الصفحة (راجع القوائم داخل صفحة). يُحدَّد عند إنشاء القائمة.
numColumns.columns(n)number1عدد العناصر في كل صف (القوائم العمودية).
inverted.inverted()booleanfalseتُرتَّب من النهاية، والعنصر 0 أخيرًا (نمط المحادثة): تبدأ ممرَّرة إلى النهاية وتحافظ على بعدها عن النهاية عند تغيّر المحتوى.
maintainVisibleContentPosition.follow_end(px(threshold)){ autoscrollToBottomThreshold?: number }للقوائم غير المعكوسة: عند نهاية المحتوى أو ضمن هذا العدد من النقاط منها، تبقى القائمة عند النهاية مع نمو المحتوى أو تقلّص منطقة العرض؛ وإذا مُرّرت إلى أعلى أكثر، يبقى المحتوى المرئي في مكانه.
stickyHeaderIndices.sticky_header_indices([..])number[]عناصر تلتصق بالبداية عند تجاوزها بالتمرير (عمود واحد، غير معكوسة).
contentStyle.content_padding(..)، .content_padding_x(..)، .row_gap(..)، .column_gap(..)، …ListContentStyleالحشو والفجوات للمحتوى، بالنقاط: padding، paddingHorizontal، paddingVertical، paddingTop، paddingRight، paddingBottom، paddingLeft، gap، rowGap، columnGap.
contentClassstringالحشو والفجوات للمحتوى على شكل فئات classes (p-*، gap-*)؛ وتتغلّب عليها contentStyle.
header.header(el)ListSlotقبل العناصر: مكوّن (Header) أو عنصر (<Header />).
footer.footer(el)ListSlotبعد العناصر.
empty.empty(el)ListSlotيُعرض بدلًا من العناصر عندما تكون each فارغة.
separator.separator(|| el)ListSlotبين العناصر (عمود واحد).
initialScrollIndex.initial_scroll_index(i)numberيمرّر إلى هذا العنصر قبل الإطار الأول.
keyExtractor.keys(|| ids)(item: T, index: number) => stringitem.key أو item.id أو الفهرسمفتاح العنصر في رموز قابلية المشاهدة. في Rust، هوية الصفوف، بحيث تحتفظ الصفوف بخلاياها وأحجامها عندما تأتي صفوف قبلها أو تذهب.
onLoadMore.on_load_more(..)() => voidالنهاية قريبة: حمّل المزيد.
loadMoreThreshold.load_more_threshold(viewports)number1مدى القرب من النهاية (بوحدات منطقة العرض) الذي يُطلق عنده onLoadMore.
onLoadMoreAtStart.on_load_more_at_start(..)() => voidالبداية قريبة، بعد أن حرّك المستخدم القائمة أو أمر تمرير: حمّل ما يسبق العنصر الأول.
loadMoreAtStartThreshold.load_more_at_start_threshold(viewports)number1مدى القرب من البداية (بوحدات منطقة العرض) الذي يُطلق عنده onLoadMoreAtStart.
onViewableItemsChange.on_viewable_items_change(..)(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => voidأصبحت عناصر قابلة للمشاهدة أو توقفت عن كونها كذلك.
viewabilityConfig.viewability(..)، .minimum_view_time(..)ViewabilityConfigمتى تُعدّ العناصر قابلة للمشاهدة (أدناه).
refreshing.refreshing(..)booleanيظهر مؤشر السحب للتحديث.
onRefresh.on_refresh(..)() => voidسحب المستخدم للتحديث.
refreshIndicator.refresh_indicator(Some(height)){ height: number }يرسم التطبيق مؤشره الخاص بارتفاع height نقطة: يُخفى مؤشر المنصة، ويحدث التحديث عندما يبلغ السحب height، ويُبقي التحديث height فوق المحتوى. الارتفاع 0 أو أقل يعني عدم وجود مؤشر.
pullValue.pull_value(&value)AnimatedValueقيمة متحركة تُغذّى بمقدار سحب القائمة إلى ما بعد أعلاها (0 في حالة السكون، وتزداد مع السحب؛ والمساحة المحجوزة فوق المحتوى أثناء التحديث: height مع refreshIndicator)، لمؤشر التطبيق.
scrollbar.scrollbar(..)boolean | ScrollbarThemetrueيظهر مؤشر التمرير: مؤشر المنصة، أو الشريط المدمج للتطبيق متى ضبط سمة theme؛ والكائن هو سمة خاصة بالعرض نفسه. على سطح المكتب وفي متصفحات سطح المكتب يحتفظ العرض الأفقي به: فهو شريط التمرير الخاص بالفأرة.
scrollValue.scroll_value(&value)AnimatedValueقيمة متحركة تُغذّى بإزاحة التمرير، للرسوم المتحركة المرتبطة بالتمرير.
keyboardDismissMode.keyboard_dismiss_mode(..)"none" | "on-drag" | "interactive""none"هل يؤدي السحب إلى إخفاء لوحة المفاتيح.
styleدوال StyledStyleProp<ViewStyle>النمط الخاص بالقائمة (تحتاج إلى حجم محدود: flex: 1 أو ارتفاع).
testID.test_id(id)stringالمعرّف الذي تجد به اختبارات واجهة المستخدم scroll view الخاص بالقائمة (nsh test الخاص بـ id:، وXCUITest، وUI Automator).
ref.element_ref(&handle)ListRef | (node: ListRef) => voidمرجع ref القائمة (راجع دوال ref).

تقبل القوائم أيضًا خصائص التمرير الخاصة بـ Scroll (دوال Scrollable في Rust): pagingEnabled، snapToInterval، snapToOffsets، snapToAlignment، disableIntervalMomentum، decelerationRate، bounces، contentInset، contentOffset، keyboardShouldPersistTaps، scrollEventThrottle وscrollable. تحافظ القوائم على مقياسها.

الخصائص التي يقبلها كل عنصر (الخصائص المشتركة) تنتقل إلى scroll view الخاص بالقائمة كما هي: testID وaccessibilityLabel وبقية خصائص إمكانية الوصول، والأنماط، وonLayout، وخصائص اللمس والمستجيب responder. تجد اختبارات واجهة المستخدم القائمة عبر testID كما تجد أي عرض آخر.

ViewabilityConfig (في Rust: Viewability { item_visible_threshold, view_area_coverage_threshold } و.minimum_view_time(..)):

الحقلالنوعالوصف
itemVisibleThresholdnumberمقدار ما يجب أن يظهر من العنصر، من 0 إلى 1.
viewAreaCoverageThresholdnumberمقدار ما يجب أن يغطيه العنصر من منطقة العرض، من 0 إلى 1. وبدون أي من العتبتين، يُحتسب أي بكسل مرئي.
minimumViewTimenumberعدد المللي ثانية التي يجب أن تبقى فيها العناصر قابلة للمشاهدة قبل الإبلاغ عنها.

ViewToken<T> هو { item, key, index, isViewable }. يتلقى مستمع Rust ViewableItems: indices() هو نطاق الصفوف القابلة للمشاهدة، وchanged() الصفوف التي أتت أو ذهبت.

#الأحداث

الحدثRustالبيانات المرافقةمتى
onScroll.on_scrollScrollEventتغيّرت الإزاحة.
onScrollDragStart / onScrollDragEnd.on_scroll_drag_start / .on_scroll_drag_end{ offset }بدأ السحب / توقف.
onMomentumStart / onMomentumEnd.on_momentum_start / .on_momentum_end{ offset }بدأ الانزلاق / توقف.
onScrollEnd.on_scroll_end{ offset }استقر التمرير.
onLoadMore.on_load_moreلا شيءضمن loadMoreThreshold من وحدات منطقة العرض من النهاية.
onLoadMoreAtStart.on_load_more_at_startلا شيءضمن loadMoreAtStartThreshold من وحدات منطقة العرض من البداية، مرة واحدة لكل اقتراب بعد تمرير.
onViewableItemsChange.on_viewable_items_change{ viewableItems, changed } (ViewableItems)تغيّرت قابلية المشاهدة.
onRefresh.on_refreshلا شيءسُحبت القائمة للتحديث.

#دوال ref

الدالةRustالوصف
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })scroll_to_index(index, percentage(0.5), px(0.0), animated)يمرّر العنصر index إلى المجال المرئي. viewPosition يحدّد موضعه: 0 عند بداية منطقة العرض (الافتراضي)، و0.5 في المنتصف، و1 عند النهاية؛ وviewOffset يزيحه أكثر. يُحسم بشكل أصلي باستخدام أحجام العناصر الحالية (المقيسة أو المقدَّرة).
scrollToOffset({ offset, animated? })scroll_to_offset(px(..), animated)يمرّر إلى إزاحة على طول محور القائمة.
scrollToEnd({ animated? })scroll_to_end(animated)يمرّر إلى نهاية المحتوى (بداية القائمة المعكوسة).
scrollTo({ x?, y?, animated? })scroll_to(point(..), animated)يمرّر إلى إزاحة.

#أمثلة

شبكة مع رأس وقابلية مشاهدة:

let visible = signal(SharedString::default());
let list = element_ref();

virtual_list(move || photos.with(|p| p.len()), move |at| {
    image(move || photos.with(|p| p[at.index()].url.clone())).h(px(120.0)).rounded(px(8.0))
})
.element_ref(&list)
.columns(3)
.estimated_item_size(px(120.0))
.content_padding(px(12.0))
.row_gap(px(8.0))
.column_gap(px(8.0))
.viewability(Viewability { item_visible_threshold: Some(Percentage(0.5)), ..Viewability::default() })
.on_viewable_items_change(move |items: &ViewableItems| {
    visible.set(format!("{} visible", items.indices().count()).into())
})
.header(
    text("Jump to 60")
        .on_press(move || list.scroll_to_index(60, percentage(0.5), px(0.0), true)),
)
.flex_1()

محادثة:

virtual_list(move || messages.with(|m| m.len()), move |at| bubble(messages, at))
    .keys(move || messages.with(|m| m.iter().map(|m| m.id).collect::<Vec<_>>()))
    .inverted()
    .estimated_item_size(px(64.0))
    .keyboard_dismiss_mode(KeyboardDismissMode::Interactive)
    .flex_1()

العنصر 0 هو أحدث رسالة، ويُرسم في الأسفل؛ والرسائل الجديدة المضافة في بداية العناصر تظهر دون أن تحرّك ما يقرأه المستخدم.

#ملاحظات المنصات

  • إدراج عناصر فوق الصفوف المرئية يُبقي الصفوف المرئية في مكانها على المنصتين.
  • القوائم الأفقية في التخطيطات من اليمين إلى اليسار تُعكس بشكل أصلي.
  • الرؤوس الثابتة تُثبَّت بشكل أصلي في كل إطار تمرير.