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

التوثيق

القوائم

تعرض القائمة (virtual_list(..) في Rust، وList من nsh في TSX) البيانات الطويلة على scroll view أصلي، مع إنشاء الصفوف القريبة من الشاشة فقط. يتم حساب الصفوف الموجودة ومواضعها والأعمدة والرؤوس اللاصقة والعناصر المرئية كلها على الجانب الأصلي؛ أما شيفرتك فلا تملأ الخلايا بالمحتوى إلا عندما يطلب الجانب الأصلي خلية. تكلّف قائمة من مليون عنصر تقريبًا ما تكلّفه قائمة من ثلاثين عنصرًا.

guide-lists.tsx
guide-lists.tsxLive
import { effect, List, type ListRef, mount, signal, Text, View } from "nsh";
import { windowSize } from "nsh";
import { focusRing, hoverFill, size, tint, ui, wide } from "./palette";

/** In a wide window the lists sit in one centered region, 760 points across. */
const REGION = 760;

// Grid tiles, tinted with the status and info colours.
const tints = ["down", "up", "info", "warn", "faint", "violet"] as const;

type Post = { id: number; title: string };

/** Bumped when the selected tab is tapped again: its list scrolls to the top. */
const toTop = signal(0);

/** An endless feed: loads more near the end, pull to refresh, scroll to an item. */
function Feed() {
  let next = 0;
  const make = (n: number): Post[] => Array.from({ length: n }, () => ({ id: next, title: `Post ${++next}` }));
  const posts = signal(make(40));
  const refreshing = signal(false);
  const visible = signal("");
  let list!: ListRef;
  effect(() => {
    if (toTop() > 0) list.scrollToOffset({ offset: 0 });
  });
  return (
    <List
      ref={list}
      each={posts()}
      keyExtractor={(p) => String(p.id)}
      estimatedItemSize={wide() ? size.row : 56}
      style={wide() ? { ...ui().card, flex: 1, margin: 20, marginTop: 12, borderRadius: 12, overflow: "hidden" } : { flex: 1 }}
      contentStyle={wide() ? {} : { padding: 12, gap: 8 }}
      onLoadMore={() => posts.update((p) => [...p, ...make(20)])}
      loadMoreThreshold={0.5}
      refreshing={refreshing()}
      onRefresh={() => {
        refreshing.set(true);
        setTimeout(() => {
          next = 0;
          posts.set(make(40));
          refreshing.set(false);
        }, 800);
      }}
      viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
      onViewableItemsChange={({ viewableItems }) =>
        visible.set(viewableItems.length ? `${viewableItems[0].index}–${viewableItems[viewableItems.length - 1].index}` : "")
      }
      header={
        <View
          style={
            wide()
              ? { flexDirection: "row", alignItems: "center", gap: 12, height: size.rowSm, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: ui().line, backgroundColor: ui().inset.backgroundColor }
              : { flexDirection: "row", alignItems: "center", gap: 12, paddingBottom: 4 }
          }
        >
          <Text style={{ flex: 1, color: ui().muted, fontSize: wide() ? 13 : undefined }}>
            {posts().length} posts, visible {visible()}
          </Text>
          <Text onPress={() => list.scrollToIndex({ index: 30, viewPosition: 0.5 })} style={{ color: ui().accent, fontSize: wide() ? 13 : undefined }} hoverStyle={hoverFill()} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
            #30
          </Text>
          <Text onPress={() => list.scrollToOffset({ offset: 0 })} style={{ color: ui().accent, fontSize: wide() ? 13 : undefined }} hoverStyle={hoverFill()} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
            Top
          </Text>
        </View>
      }
    >
      {(post) => (
        <View
          style={
            wide()
              ? { height: size.row, paddingHorizontal: 12, justifyContent: "center", borderBottomWidth: 1, borderBottomColor: ui().line }
              : { ...ui().row, padding: 16, borderRadius: 12 }
          }
        >
          <Text style={{ fontSize: wide() ? size.label : 16, color: ui().text }}>{post().title}</Text>
        </View>
      )}
    </List>
  );
}

/** Three columns of different heights. */
function Grid() {
  const items = Array.from({ length: 300 }, (_, i) => ({ id: i, height: 60 + (i % 4) * 18 }));
  return (
    <List
      each={items}
      numColumns={wide() ? 6 : 3}
      estimatedItemSize={80}
      style={{ flex: 1 }}
      contentStyle={wide() ? { padding: 20, paddingTop: 12, gap: 8 } : { padding: 12, gap: 8 }}
    >
      {(item) => (
        <View style={{ height: item().height, borderRadius: 10, backgroundColor: tint(ui()[tints[item().id % tints.length]], 0.16), alignItems: "center", justifyContent: "center" }}>
          <Text style={{ fontWeight: "600", color: ui().text }}>{item().id}</Text>
        </View>
      )}
    </List>
  );
}

/** Sticky letter headers: one list of header and name rows. */
function Contacts() {
  const names = ["Ada", "Alan", "Anita", "Barbara", "Bjarne", "Brian", "Claude", "Dennis", "Donald", "Edsger", "Frances", "Grace", "Guido", "Hedy", "Ken", "Linus", "Margaret", "Niklaus", "Radia", "Tim"];
  type Row = { header: string } | { name: string };
  const rows: Row[] = [];
  const headers: number[] = [];
  for (const letter of [...new Set(names.map((n) => n[0]))]) {
    headers.push(rows.length);
    rows.push({ header: letter });
    for (const name of names.filter((n) => n[0] === letter)) rows.push({ name });
  }
  return (
    <List
      each={rows}
      stickyHeaderIndices={headers}
      estimatedItemSize={wide() ? size.rowSm : 44}
      style={
        wide()
          ? { ...ui().card, flex: 1, margin: 20, marginTop: 12, borderRadius: 12, overflow: "hidden" }
          : { flex: 1, backgroundColor: ui().card.backgroundColor }
      }
    >
      {(row) => (
        <If
          when={"header" in row()}
          else={
            <View
              style={
                wide()
                  ? { height: size.rowSm, paddingHorizontal: 12, justifyContent: "center", borderBottomWidth: 1, borderBottomColor: ui().line }
                  : { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: ui().line }
              }
            >
              <Text style={{ fontSize: wide() ? size.label : 16, color: ui().text }}>{(row() as { name: string }).name}</Text>
            </View>
          }
        >
          <View style={{ backgroundColor: ui().inset.backgroundColor, paddingHorizontal: wide() ? 12 : 16, paddingVertical: wide() ? 4 : 6 }}>
            <Text style={{ fontWeight: "700", fontSize: wide() ? 12 : undefined, color: ui().muted }}>{(row() as { header: string }).header}</Text>
          </View>
        </If>
      )}
    </List>
  );
}

type Tab = "feed" | "grid" | "contacts";

function App() {
  const tab = signal<Tab>("feed");
  const button = (id: Tab, label: string) => (
    <Text
      onPress={() => (tab() === id ? toTop.update((n) => n + 1) : tab.set(id))}
      style={{
        flex: 1,
        textAlign: "center",
        paddingVertical: wide() ? 4 : 6,
        fontSize: wide() ? 13 : undefined,
        borderRadius: 8,
        overflow: "hidden",
        fontWeight: "600",
        color: tab() === id ? ui().text : ui().muted,
        backgroundColor: tab() === id ? ui().raised.backgroundColor : "transparent",
        backgroundImage: tab() === id ? ui().raised.backgroundImage : "none",
        boxShadow: tab() === id ? ui().raised.boxShadow : "none",
      }}
      hoverStyle={{ opacity: 0.88 }}
      focusStyle={focusRing()}
      activeStyle={{ opacity: 0.7 }}
    >
      {label}
    </Text>
  );
  return (
    <View
      style={
        wide()
          ? { flex: 1, backgroundColor: ui().page, paddingTop: 24, paddingHorizontal: Math.max(0, (windowSize().width - REGION) / 2) }
          : { flex: 1, backgroundColor: ui().page, paddingTop: 24 }
      }
    >
      <View
        style={
          wide()
            ? { ...ui().inset, flexDirection: "row", alignSelf: "flex-start", width: 320, marginHorizontal: 20, padding: 3, borderRadius: 10 }
            : { ...ui().inset, flexDirection: "row", marginHorizontal: 12, padding: 3, borderRadius: 10 }
        }
      >
        {button("feed", "Feed")}
        {button("grid", "Grid")}
        {button("contacts", "Sticky")}
      </View>
      {() => {
        switch (tab()) {
          case "feed":
            return <Feed />;
          case "grid":
            return <Grid />;
          case "contacts":
            return <Contacts />;
        }
      }}
    </View>
  );
}

mount(App);
use std::rc::Rc;

use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let titles: Rc<Vec<String>> = Rc::new((1..=10_000).map(|i| format!("Item {i}")).collect());
    virtual_list(titles.len(), move |at| {
        let titles = titles.clone();
        view()
            .p_4()
            .border_b_1()
            .border_b_color(rgb(0xeaeef2))
            .child(text(move || titles[at.index()].clone()).text_size(px(16.0)))
    })
    .estimated_item_size(px(52.0))
    .flex_1()
    .bg(white())
}

في Rust، تأخذ virtual_list(count, row) عدد الصفوف (قيمة أو signal أو closure) ودالة تبني الصف انطلاقًا من موضعه (at.index())؛ وتقرأ الصفوف بياناتك بواسطة هذا الفهرس. في TSX، تأخذ each العناصر، وتتلقى دالة الصف العنصر نفسه. وكما هو الحال مع scroll view، فإن القائمة هي منفذ العرض (viewport) وتحتاج إلى حجم من عنصرها الأب (.flex_1() أو flex: 1 أو ارتفاع).

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

لا تُستدعى دالة الصف مرة واحدة لكل عنصر، بل تُستدعى مرة واحدة لكل خلية، وتُعاد استخدام الخلايا: عندما يبتعد صف كثيرًا عن الشاشة أثناء التمرير، تُعطى خليته لصف آخر، ويتغير موضعه (at.index() في Rust، وitem() وindex() في TSX). وكل ما في الصف يقرأ هذا الموضع يتحدث في مكانه، مثل أي signal آخر.

تنتج عن ذلك قاعدتان:

اقرأ الموضع في المكان الذي تستخدمه فيه. القيمة المنسوخة في أعلى دالة الصف تخص الصف الذي عرضته الخلية أولًا، أيًّا كان.

// Wrong: `title` stays the first row's title when the cell is reused.
virtual_list(count, move |at| {
    let title = titles[at.index()].clone();
    text(title)
})

// Right: the text follows the cell's current row.
virtual_list(count, move |at| {
    let titles = titles.clone();
    text(move || titles[at.index()].clone())
})

احتفظ بالحالة الخاصة بكل عنصر في بياناتك، لا في الصف. الـ signal الذي يُنشأ داخل الصف يخص الخلية وينتقل معها إلى العنصر التالي. خزّن حالة “هل هذا الصف موسّع / محدد / معجب به” في العناصر نفسها أو في مجموعة مفهرسة بالمعرّف:

let liked = signal(HashSet::<u32>::new());
let toggle = move |id: u32| {
    liked.update(|s| {
        if !s.remove(&id) {
            s.insert(id);
        }
    })
};

virtual_list(move || posts.with(Vec::len), move |at| {
    let post = computed(move || posts.with(|p| p[at.index()].clone()));
    view()
        .flex_row()
        .p_4()
        .child(text(move || post.with(|p| p.title.clone())).flex_1())
        .child(
            text(move || if liked.with(|s| s.contains(&post.with(|p| p.id))) { "♥" } else { "♡" })
                .on_press(move || toggle(post.with(|p| p.id))),
        )
})
.estimated_item_size(px(64.0))
.flex_1()

يمكن أن تختلف ارتفاعات الصفوف. وإلى أن يتم قياس صف ما، تفترض القائمة حجم العنصر التقديري (الافتراضي 50 نقطة)؛ مرّر قيمة قريبة من صفك المعتاد حتى يكون مؤشر التمرير والقفزات إلى العناصر البعيدة دقيقة منذ البداية.

#تغيير البيانات

في Rust، مرّر العدد على شكل signal أو closure (move || posts.with(Vec::len)): تكبر القائمة وتصغر في مكانها. الصفوف المضافة أو المحذوفة في النهاية لا تحتاج إلى شيء آخر؛ أما عندما تظهر الصفوف وتختفي في أماكن أخرى، فحدّد هوياتها باستخدام .keys(move || posts.with(|p| p.iter().map(|p| p.id).collect::<Vec<_>>()))، وستحتفظ القائمة بالبداية والنهاية المشتركتين وتعامل ما بينهما على أنه محذوف ومُدرج.

في TSX، مرّر مصفوفة جديدة لتغيير العناصر: posts.set([...posts(), ...more]). تقارن القائمة المصفوفة القديمة بالجديدة بهوية العنصر (===)، وتحتفظ بالبداية والنهاية المشتركتين، وتعامل ما بينهما على أنه محذوف ومُدرج.

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

  • في TSX، حدّث البيانات بلا تعديل مباشر (immutably): مصفوفة جديدة مع إعادة استخدام كائنات العناصر غير المتغيرة. أما تعديل المصفوفة في مكانها (push) فلا يُلاحَظ.
  • يجب أن يكون العنصر المتغير كائنًا جديدًا ({ ...post, title })، حتى يعرض صفه التغيير.
  • إضافة عناصر في البداية (رسائل جديدة، أو تغذية محدّثة) تحافظ على موضع المستخدم.

keyExtractor يسمّي العناصر في رموز القابلية للمشاهدة (viewability tokens) أدناه؛ المفتاح الافتراضي هو item.key، ثم item.id، ثم الفهرس. ولا يؤثر ذلك في كيفية مطابقة التغييرات في TSX، فهي تتم دائمًا بالهوية.

#الخصائص

تقبل القائمة كل خصائص التمرير في scroll view التي لها معنى في القائمة (pagingEnabled، snapToInterval، snapToOffsets، snapToAlignment، decelerationRate، disableIntervalMomentum، bounces، scrollable، contentInset، contentOffset، keyboardShouldPersistTaps، keyboardDismissMode، scrollEventThrottle، onScroll، onScrollDragStart، onScrollDragEnd، onMomentumStart، onMomentumEnd، onScrollEnd، scrollValue، pullValue؛ وفي Rust هي الدوال نفسها الخاصة بـ scroll view)، وهي موصوفة في التمرير. تحتفظ القوائم بمقياس 1. أما خصائصها الخاصة فهي:

الخاصيةRustالنوعالافتراضيالوصف
eachvirtual_list(count, ..)readonly T[]العناصر؛ وفي Rust، عدد الصفوف.
childrenvirtual_list(.., 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(..)){ autoscrollToBottomThreshold?: number }القائمة غير المعكوسة تبقى عند النهاية ما دامت ضمن هذا العدد من النقاط منها، أثناء نمو المحتوى (انظر أدناه).
stickyHeaderIndices.sticky_header_indices(..)number[]العناصر التي تلتصق بالأعلى عند تجاوزها بالتمرير (عمود واحد، غير معكوسة).
stickyHeaderOffset.sticky_header_offset(px(..))، .bind_sticky_header_offset(&node)number | AnimatedNode0المسافة تحت الأعلى التي تلتصق عندها العناصر اللاصقة، مثل top في CSS position: sticky؛ ويستمر المحتوى في التمرير تحت المساحة التي فوقها. القيمة المتحركة تحرّكها في كل إطار.
contentStyle.content_padding(..)، .content_padding_x(..)، …، .row_gap(..)، .column_gap(..){ padding*, gap, rowGap, columnGap }الحشو (padding) والفجوات (gaps) للمحتوى، أرقام فقط.
contentClassstringمثل classes (p-*، gap-*)؛ وcontentStyle له الأولوية عليها.
header.header(..)مكوّن أو عنصرقبل العناصر.
footer.footer(..)مكوّن أو عنصربعد العناصر.
empty.empty(..)مكوّن أو عنصريُعرض بدلًا من العناصر عندما لا يوجد أي منها.
separator.separator(|| ..)مكوّن أو عنصربين العناصر (عمود واحد).
initialScrollIndex.initial_scroll_index(i)numberيمرّر إلى هذا العنصر قبل الإطار الأول.
onLoadMore.on_load_more(..)() => voidالنهاية قريبة (انظر أدناه).
loadMoreThreshold.load_more_threshold(..)number1مدى القرب من النهاية، بوحدات منفذ العرض، الذي يُطلق عنده onLoadMore.
onLoadMoreAtStart.on_load_more_at_start(..)() => voidالبداية قريبة، بعد تمرير (انظر أدناه).
loadMoreAtStartThreshold.load_more_at_start_threshold(..)number1مدى القرب من البداية، بوحدات منفذ العرض، الذي يُطلق عنده onLoadMoreAtStart.
onViewableItemsChange.on_viewable_items_change(..)(info) => voidأصبحت عناصر قابلة للمشاهدة أو توقفت عن ذلك.
viewabilityConfig.viewability(..)، .minimum_view_time(..)ViewabilityConfigمتى تُعدّ العناصر قابلة للمشاهدة.
keyExtractor.keys(..)(item, index) => stringالمفتاح في رموز القابلية للمشاهدة؛ وفي Rust، هويات الصفوف.
refreshing / onRefresh.refreshing(..) / .on_refresh(..)boolean / () => voidالسحب للتحديث.
refreshIndicator.refresh_indicator(..){ height: number }مؤشر السحب للتحديث الخاص بالتطبيق، بدلًا من مؤشر الدوران الخاص بالمنصة (راجع التمرير).
scrollbar.scrollbar(..)boolean | ScrollbarThemetrueظهور مؤشر التمرير: مؤشر المنصة، أو الشريط المدمج في التطبيق متى ضبط سمة؛ والكائن هو سمة خاصة بالعرض نفسه. على سطح المكتب وفي متصفحات سطح المكتب يحتفظ العرض الأفقي بالمؤشر: فهو شريط تمرير الفأرة.
ref.element_ref(&list)ListRefscrollToIndex، scrollToOffset، scrollToEnd، scrollTo.

تقبل خانات الرأس والتذييل والحالة الفارغة عنصرًا (وفي TSX مكوّنًا أيضًا: header={Header}). وهي خلايا قائمة بذاتها، لا يُعاد تدويرها أبدًا، وهي تفاعلية مثل أي عنصر آخر.

#الأعمدة والشبكات

numColumns (.columns(n)) يرتّب العناصر في صفوف بهذا العدد من الأعمدة؛ ويحيط حشو المحتوى بالمحتوى، مع فجوات بين الصفوف والأعمدة. يمكن أن تختلف ارتفاعات صفوف الشبكة: يكون كل صف بارتفاع أطول عنصر فيه.

let count = photos.len();
virtual_list(count, move |at| {
    let photos = photos.clone();
    image(move || photos[at.index()].thumb.clone()).aspect_square().rounded(px(8.0))
})
.columns(3)
.estimated_item_size(px(120.0))
.content_padding(px(12.0))
.row_gap(px(8.0))
.column_gap(px(8.0))
.flex_1()

#الرؤوس اللاصقة

stickyHeaderIndices يسرد العناصر التي تلتصق بأعلى منفذ العرض بينما يمرّ المحتوى تحتها، إلى أن يدفعها العنصر اللاصق التالي إلى الخارج. الأقسام هي قائمة واحدة من عناصر الرؤوس وعناصر الصفوف؛ وتلتصق فهارس الرؤوس. يتم التثبيت على الجانب الأصلي في كل إطار تمرير.

#[derive(Clone, PartialEq)]
enum Row {
    Header(String),
    Name(String),
}

let mut rows = Vec::new();
let mut headers = Vec::new();
for (letter, names) in groups {
    headers.push(rows.len());
    rows.push(Row::Header(letter));
    rows.extend(names.into_iter().map(Row::Name));
}
let count = rows.len();
let rows = Rc::new(rows);

virtual_list(count, move |at| {
    let rows = rows.clone();
    let row = computed(move || rows[at.index()].clone());
    let is_header = computed(move || matches!(row.get(), Row::Header(_)));
    let label = move || match row.get() {
        Row::Header(s) | Row::Name(s) => s,
    };
    when(is_header, move || {
        text(label).p(px(6.0)).bg(rgb(0xeaeef2)).font_weight(FontWeight::BOLD)
    })
    .otherwise(move || text(label).p_3())
})
.sticky_header_indices(headers)
.estimated_item_size(px(44.0))
.flex_1()

ولأن الخلية قد تعرض رأسًا الآن واسمًا لاحقًا، فإن الصف يقرر ما سيعرضه بواسطة when / <If>، الذي يتبدل عندما يتغير الصف. تحتاج الرؤوس اللاصقة إلى عمود واحد وقائمة غير معكوسة.

stickyHeaderOffset (.sticky_header_offset(..) في Rust) يجعل الرؤوس تلتصق على بعد هذا العدد من النقاط تحت الأعلى بدلًا من ذلك، كما يفعل top في CSS position: sticky: تحت شريط شبه شفاف ارتفاعه 56 نقطة، يُبقي stickyHeaderOffset={56} (.sticky_header_offset(px(56.0))) كل رأس تحت الشريط بينما تستمر الصفوف في التمرير تحته، ويدفعه الرأس التالي إلى الخارج من هناك. الإزاحة السالبة تترك جزءًا من الرأس خارج مجال الرؤية.

يمكن أن تكون الإزاحة قيمة متحركة (.bind_sticky_header_offset(&node) في Rust): تتبعها الرؤوس في كل إطار على خيط واجهة المستخدم، فتبقى تحت شريط متحرك. ومع شريط يختفي عندما يتمرر المحتوى للأسفل ويعود عندما يتمرر للأعلى (diffClamp)، تلتصق الرؤوس بحافته السفلية أينما كان:

let offset = animated_value(0.0);
let hidden = offset.diff_clamp(0.0, 56.0);
let shown = hidden.mul(-1.0).add(56.0);

view().flex_1().overflow_hidden().child(
    virtual_list(rows.len(), row)
        .scroll_value(&offset)
        .sticky_header_indices(headers)
        .bind_sticky_header_offset(&shown),
).child(view().absolute().top_0().left_0().right_0().h(px(56.0)).bind_translate_y(&hidden.mul(-1.0)))

#الدردشة: القوائم المعكوسة

inverted يرتّب القائمة انطلاقًا من النهاية: العنصر 0 في الأسفل، وتبدأ القائمة ممرَّرة إلى النهاية، وتحافظ على بعدها عن النهاية أثناء تغيّر العناصر. اجعل أحدث رسالة أولًا؛ أضف في البداية لإضافة رسالة، وأضف في النهاية لتحميل الرسائل الأقدم باستخدام onLoadMore (“نهاية” القائمة المعكوسة هي أعلاها). وعندما تُفتح الدردشة عند رسالة قديمة، تحمّل الرسائل الأحدث باستخدام onLoadMoreAtStart؛ ومعه تحافظ القائمة على بعدها عن الأسفل ما دامت هناك، وتُبقي الرسائل الظاهرة على الشاشة في مكانها عند التمرير للأعلى.

#[derive(Clone, PartialEq)]
struct Message {
    id: u32,
    text: String,
    mine: bool,
}

pub fn chat() -> impl IntoElement {
    let next = stored(0_u32);
    let message = move |text: String, mine: bool| Message {
        id: next.with(|n| {
            *n += 1;
            *n
        }),
        text,
        mine,
    };
    // Newest first.
    let messages = signal(
        (0..30)
            .map(|i| message(format!("Message {}", 30 - i), i % 3 == 0))
            .collect::<Vec<_>>(),
    );
    let draft = signal(String::new());
    let send = move || {
        let text = draft.get_untracked();
        if text.trim().is_empty() {
            return;
        }
        let sent = message(text, true);
        messages.update(|m| m.insert(0, sent));
        draft.set(String::new());
    };
    let load_older = move || {
        let older: Vec<_> = (0..20).map(|i| message(format!("Older {i}"), false)).collect();
        messages.update(|m| m.extend(older));
    };
    let list = virtual_list(
        move || messages.with(Vec::len),
        move |at| {
            let mine = computed(move || messages.with(|m| m[at.index()].mine));
            view()
                .style_when(mine, |s| s.self_end().bg(rgb(0x0969da)))
                .style_when(move || !mine.get(), |s| s.self_start().bg(rgb(0xeaeef2)))
                .max_w(relative(0.75))
                .px_3()
                .py_2()
                .rounded(px(14.0))
                .child(
                    text(move || messages.with(|m| m[at.index()].text.clone()))
                        .text_color(move || if mine.get() { rgb(0xffffff) } else { rgb(0x1f2328) }),
                )
        },
    )
    .keys(move || messages.with(|m| m.iter().map(|m| m.id).collect::<Vec<_>>()))
    .inverted()
    .estimated_item_size(px(44.0))
    .content_padding(px(12.0))
    .row_gap(px(6.0))
    .keyboard_dismiss_mode(KeyboardDismissMode::Interactive)
    .on_load_more(load_older)
    .load_more_threshold(0.5)
    .flex_1();
    view()
        .flex_1()
        .pb_keyboard()
        .child(list)
        .child(
            view().flex_row().gap_2().p_2().child(
                input()
                    .value(move || draft.get())
                    .on_change(move |text: &str| draft.set(text.to_owned()))
                    .on_submit(move |_: &str| send())
                    .multiline(true)
                    .submit_behavior(SubmitBehavior::Submit)
                    .return_key_type(ReturnKeyType::Send)
                    .placeholder("Message")
                    .flex_1()
                    .max_h(px(120.0))
                    .border_1()
                    .border_color(rgb(0xd0d7de))
                    .rounded(px(18.0))
                    .px_3()
                    .py_2(),
            ),
        )
}

scroll_to_end(..) / scrollToEnd() على قائمة معكوسة يمرّر إلى بدايتها: أحدث رسالة.

#متابعة نهاية قائمة غير معكوسة

يمكن للقائمة بترتيب القراءة (الأقدم أولًا) أن تتابع نهايتها أيضًا: مع .follow_end(px(40.0)) في Rust أو maintainVisibleContentPosition={{ autoscrollToBottomThreshold: 40 }} في TSX، ما دام المستخدم عند النهاية (أو ضمن 40 نقطة منها)، تبقى القائمة عند النهاية أثناء نمو المحتوى: رد يتدفق إلى العنصر الأخير، أو رسالة جديدة تُضاف، أو لوحة مفاتيح تصغّر القائمة. وإذا تمرّر المستخدم أبعد إلى الأعلى، يبقى المحتوى المرئي في مكانه ويكبر المحتوى الجديد تحته. العناصر المُدرجة فوق العناصر المرئية لا تحرّكها أبدًا، مع هذا الخيار أو بدونه.

virtual_list(move || messages.with(Vec::len), move |at| bubble(messages, at))
    .keys(move || messages.with(|m| m.iter().map(|m| m.id).collect::<Vec<_>>()))
    .follow_end(px(40.0))
    .flex_1()

#القوائم الأفقية

horizontal يقلب القائمة إلى الوضع الأفقي؛ وتُرتَّب الخلايا بارتفاع القائمة. وعند دمجه مع الالتقاط (snapping) يصنع دوّارة (carousel) بأي طول.

let count = cards.len();
virtual_list(count, move |at| {
    let cards = cards.clone();
    view()
        .w(px(220.0))
        .h(px(140.0))
        .rounded(px(14.0))
        .bg(move || cards[at.index()].color)
})
.horizontal()
.estimated_item_size(px(232.0))
.snap_interval(px(232.0))
.deceleration(0.99)
.scrollbar(false)
.content_padding_x(px(16.0))
.column_gap(px(12.0))
.h(px(140.0))

في اتجاه اليمين إلى اليسار، تبدأ القوائم الأفقية من اليمين وتمتد نحو اليسار.

#القوائم داخل صفحة: viewport=“parent”

غالبًا ما تتمرر الصفحة كوحدة واحدة: رأس، ثم بعض البطاقات، ثم قائمة طويلة، وربما قائمة أخرى تحتها. امنح القائمة viewport="parent" (.viewport(ListViewport::Parent) في Rust) وضعها في Scroll مع بقية الصفحة: تمتد القائمة في الصفحة بكامل طولها، ويمرّرها scroll view الخاص بالصفحة مع كل شيء آخر. وهي لا تزال تنشئ الصفوف القريبة من منفذ عرض الصفحة فقط وتعيد تدوير خلاياها، لذا يمكن لصفحة واحدة أن تحتوي على عدة قوائم طويلة.

scroll()
    .flex_1()
    .child(header())
    .child(
        virtual_list(count, row)
            .viewport(ListViewport::Parent)
            .estimated_item_size(px(56.0))
            .row_gap(px(6.0)),
    )
    .child(footer())
  • تكون القائمة بطول صفوفها: يأتي ارتفاعها (وعرضها في الوضع الأفقي) من محتواها، كما هو الحال مع أي عرض، وتعمل حواشيها وحدودها وهوامشها كما تعمل على العرض.
  • تتبع صفوفها أقرب scroll view يحيط بها على محورها، أو الشاشة التي هي عليها.
  • تحافظ الصفحة على ما تقرؤه في مكانه، كما يفعل المتصفح: الصفوف التي يتغير حجمها بالكامل فوق أعلى منفذ العرض تحرّك الصفحة بالمقدار نفسه؛ وما دامت بداية القائمة ظاهرة، فإن الصفوف التي تكبر تدفع ما تحتها إلى الأسفل.
  • scrollToIndex وscrollToEnd وinitialScrollIndex تمرّر الصفحة إلى صفوف القائمة، ضمن محتوى الصفحة؛ وonLoadMore يُطلق عندما تقترب نهاية القائمة من منفذ العرض، وonViewableItemsChange يبلّغ عن الصفوف الموجودة في منفذ عرض الصفحة.
  • stickyHeaderIndices وstickyHeaderOffset تُلصق الصفوف بأعلى الصفحة: يبقى رأس القسم في الأعلى بينما تتمرر صفوفه تحته، إلى أن يدفعه التالي إلى الخارج، ويغادر الأخير مع نهاية قائمته.
  • كيفية تمرير الصفحة تعود إلى scroll view الخاص بها: اضبط onScroll وبقية أحداث التمرير، وscrollValue، وrefreshing / onRefresh، والتصفح بالصفحات والالتقاط، وcontentInset وscrollbar وscrollable وkeyboardDismissMode على Scroll. القوائم المعكوسة وmaintainVisibleContentPosition هي ميزات للقائمة ذات منفذ العرض الخاص بها (viewport="own"، وهو الافتراضي).
  • يُختار منفذ العرض عند إنشاء القائمة: للتبديل، اعرض قائمة جديدة.

#تحميل المزيد: onLoadMore

onLoadMore (.on_load_more(..)) يُطلق عندما يصبح منفذ العرض ضمن loadMoreThreshold من منافذ العرض من النهاية (1 افتراضيًا: شاشة واحدة قبل النهاية). أضف الصفحة التالية؛ وتُطلق القائمة الحدث مجددًا عندما تقترب النهاية الجديدة.

let posts = signal(Vec::<Post>::new());
let page = stored(0_u32);
let loading = stored(false);

let load_more = move || {
    if loading.replace(true) {
        return;
    }
    let next = page.with(|p| {
        *p += 1;
        *p
    });
    spawn(async move {
        if let Ok(response) = fetch(format!("https://api.example.com/posts?page={next}")).await {
            let more = parse_posts(&response.text());
            posts.update(|p| p.extend(more));
        }
        loading.replace(false);
    });
};
load_more();

virtual_list(move || posts.with(Vec::len), move |at| post_row(posts, at))
    .estimated_item_size(px(72.0))
    .on_load_more(load_more)
    .load_more_threshold(0.5)
    .footer(text("Loading…").p_4().text_center().text_color(rgb(0x8c959f)))
    .flex_1()

احرص على منع التحميلات المتداخلة كما في المثال أعلاه: فقد يُبلَّغ عن النهاية مرة أخرى بينما صفحة في الطريق.

#التحميل عند البداية: onLoadMoreAtStart

يمكن أيضًا أن تُفتح القائمة في منتصف بياناتها: فمحادثة تُفتح عند رسالة قديمة (نتيجة بحث، أو اقتباس رد) تعرض صفحة حولها، مع بقاء رسائل أحدث لم تُحمَّل بعد. يُستدعى onLoadMoreAtStart (.on_load_more_at_start(..)) عندما تقترب نافذة العرض من البداية بمقدار loadMoreAtStartThreshold من نوافذ العرض (1 افتراضيًا)، والبداية هي جهة العنصر 0: أعلى القائمة، أو أسفل القائمة المعكوسة. أضف الصفحة في بداية البيانات: تحتفظ الصفوف الظاهرة على الشاشة بمكانها أثناء وصول الصفحة قبلها (وكذلك القائمة المعكوسة، متى ابتعد التمرير عن أسفلها)، وتُطلق القائمة الاستدعاء مرة أخرى عند الاقتراب التالي، أو فورًا إن بقيت القائمة قريبة من البداية بعد وصول الصفحة.

يُستدعى عندما يمرر المستخدم إلى هناك، أو عندما يوصل أمر تمرير القائمة إلى هناك (بما في ذلك initialScrollIndex)، لذا فالقائمة التي تُفتح عند أحدث عنصر لا تطلب شيئًا، أما التي تُفتح بـ initialScrollIndex قرب بدايتها فتطلب الصفحة التي قبله على الفور.

let messages = signal(page_around(target)); // newest first, ending at an old message
let loading = stored(false);

let load_newer = move |_: &()| {
    if loading.replace(true) {
        return;
    }
    let newest = messages.with(|m| m[0].id);
    spawn(async move {
        if let Ok(response) = fetch(format!("https://api.example.com/messages?after={newest}")).await {
            let newer = parse_messages(&response.text());
            messages.update(|m| {
                m.splice(0..0, newer);
            });
        }
        loading.replace(false);
    });
};

virtual_list(move || messages.with(Vec::len), move |at| message_row(messages, at))
    .inverted(true)
    .on_load_more(load_older)
    .on_load_more_at_start(load_newer)
    .load_more_at_start_threshold(0.5)
    .flex_1()

بمجرد وصول أحدث رسالة، أوقف التحميل: فتصل الرسائل الجديدة عندئذٍ عند البداية كما في أي محادثة.

#Viewability

يُبلغ onViewableItemsChange (.on_viewable_items_change(..)) عن العناصر الظاهرة على الشاشة، لأغراض التحليلات، أو التشغيل التلقائي للفيديو المرئي، أو وسم الرسائل كمقروءة. في TSX يستلم { viewableItems, changed }، وهي مصفوفات من العناصر { item, key, index, isViewable }؛ وفي Rust يستلم ViewableItems مع أول صف مرئي وآخر صف مرئي (viewable) والصفوف التي تغيرت حالتها (changed()). تُحسب قابلية الظهور في الجانب الأصلي (native)؛ ولا يصل إلى شيفرتك سوى التغييرات.

viewabilityConfigRustالوصف
itemVisibleThresholdViewability { item_visible_threshold, .. }مقدار الجزء الذي يجب أن يكون مرئيًا من العنصر، من 0 إلى 1.
viewAreaCoverageThresholdViewability { view_area_coverage_threshold, .. }مقدار الجزء من نافذة العرض الذي يجب أن يغطيه العنصر، من 0 إلى 1.
minimumViewTime.minimum_view_time(Duration)عدد الميلي ثانية التي يجب أن يبقى فيها العنصر قابلًا للظهور قبل الإبلاغ عنه.

من دون عتبة، يُحتسب أي بكسل مرئي.

let playing = signal(None::<usize>);

virtual_list(count, move |at| video_card(videos.clone(), at))
    .estimated_item_size(px(300.0))
    .viewability(Viewability {
        item_visible_threshold: Some(Percentage(0.6)),
        ..Viewability::default()
    })
    .minimum_view_time(Duration::from_millis(250))
    .on_viewable_items_change(move |rows: &ViewableItems| {
        playing.set(rows.viewable.map(|(first, _)| first))
    })
    .flex_1()

#التمرير من الشيفرة

أعطِ القائمة مرجعًا (ref) (element_ref() و.element_ref(&list) في Rust، وref في TSX):

RustTSXالوصف
scroll_to_index(index, view_position, view_offset, animated)scrollToIndex({ index, animated?, viewPosition?, viewOffset? })يمرر العنصر index إلى المجال المرئي. يحدد موضع العرض مكانه: 0 عند بداية نافذة العرض (الافتراضي)، و0.5 في المنتصف، و1 عند النهاية؛ ويزيحه إزاحة العرض أكثر.
scroll_to_offset(offset, animated)scrollToOffset({ offset, animated? })يمرر إلى إزاحة في المحتوى على امتداد محور القائمة.
scroll_to_end(animated)scrollToEnd({ animated? })يمرر إلى نهاية المحتوى (بداية القائمة المعكوسة).
scroll_to(point, animated)scrollTo({ x?, y?, animated? })يمرر إلى إزاحة، كما في scroll view.
let list = element_ref();

virtual_list(count, row).estimated_item_size(px(48.0)).flex_1().element_ref(&list)

list.scroll_to_index(120, percentage(0.5), px(0.0), true);

يُحَل التمرير إلى فهرس (وإلى النهاية) في الجانب الأصلي (native) بالأحجام المعروفة في تلك اللحظة: الصفوف المقيسة حيث قيست، والحجم المقدَّر في غيرها. ثم تتبع القائمة هدفها بينما تُقاس الصفوف المحيطة به، فتستقر عليه بدقة، إلى أن يمرر المستخدم أو تتغير العناصر. لفتح قائمة عند عنصر معيّن، استخدم initialScrollIndex (.initial_scroll_index(..)) بدلًا من التمرير بعد الإطار الأول.

#قائمة بنمط الإعدادات

مظهر القائمة من شأن التطبيق: تضع القائمة الصفوف وتعيد تدويرها، أما الصفوف والفواصل والأسطح فهي عروضك (views). القائمة المجمّعة بنمط الإعدادات هي قائمة بحشوة وفاصل وصفين أول وأخير بحواف مستديرة. اجعلها مكونًا خاصًا بك واستخدمها في كل موضع يعرض فيه التطبيق إعدادات:

use std::rc::Rc;

use nsh_core::{element::palette::NEUTRAL, prelude::*};

pub struct Setting {
    pub id: &'static str,
    pub label: &'static str,
}

pub fn settings_list(settings: Rc<Vec<Setting>>, on_select: impl Fn(&Setting) + 'static) -> impl IntoElement {
    let surface = AdaptiveColor::new(white(), NEUTRAL.shade(900));
    let on_select = Rc::new(on_select);
    let count = settings.len();
    view().flex_1().bg(AdaptiveColor::new(NEUTRAL.shade(100), NEUTRAL.shade(950))).child(
        virtual_list(count, move |at| {
            let (pressed, shown, on_select) = (settings.clone(), settings.clone(), on_select.clone());
            view()
                .on_press(move || on_select(&pressed[at.index()]))
                .role(AccessibilityRole::Button)
                .h_12()
                .justify_center()
                .bg(surface)
                .px_4()
                .active(|s| s.bg(NEUTRAL.shade(200)))
                .rounded_t(move || px(if at.is_first() { 12.0 } else { 0.0 }))
                .rounded_b(move || px(if at.is_last() { 12.0 } else { 0.0 }))
                .child(text(move || shown[at.index()].label))
        })
        .estimated_item_size(px(48.0))
        .content_padding(px(16.0))
        .separator(move || {
            view()
                .bg(surface)
                .child(view().ml_4().h(px(1.0)).bg(AdaptiveColor::new(NEUTRAL.shade(200), NEUTRAL.shade(800))))
        })
        .flex_1(),
    )
}

#ملاحظات الأداء

  • تُوزَّع كل خلية بمفردها، لذا لا يعيد التمرير توزيع سوى الخلايا التي تغيرت. أبقِ الصفوف ضحلة وتجنب قراءة signals في الصفوف التي تتغير في كل إطار.
  • يطلب الجانب الأصلي (native) الخلايا على دفعات، بحد أقصى عدة جولات في الإطار الواحد، تُدمج في تحديث واحد؛ فتربط الحركة السريعة (fling) خلايا كثيرة في إطار واحد دون استدعاء لشيفرتك لكل خلية. أما صفوف Rust فتُربط في النواة نفسها، ضمن commit الذي يحتاج إليها.
  • استخدم nsh test --trace مع خطوة scroll لقياس كلفة صفوفك في كل إطار.

#ذات صلة