العناصر
List
يعرض List بيانات طويلة أو غير محدودة. الصفوف القريبة من منطقة العرض فقط هي الموجودة: عندما يمرّر المستخدم، تُعاد تدوير الصفوف التي تغادر لتحل محلها الصفوف الداخلة. تُحسب أيضًا الصفوف الموجودة ومواضعها وأعمدتها والرؤوس الثابتة وقابلية المشاهدة كلها بشكل أصلي، لذا فإن تمرير قائمة من آلاف العناصر لا يكلّف أكثر من تمرير قائمة من عشرين عنصرًا. في Rust تُنشأ القائمة باستخدام virtual_list(count, |at| row)، وفي TSX باستخدام <List> من nsh.
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()
}import { List, mount, Text, View } from "nsh";
const contacts = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Contact ${i + 1}` }));
function App() {
return (
<List each={contacts} estimatedItemSize={56} style={{ flex: 1 }}>
{(contact, index) => (
<View style={{ height: 56, paddingHorizontal: 16, justifyContent: "center" }}>
<Text style={{ fontSize: 16 }}>
{index() + 1}. {contact().name}
</Text>
</View>
)}
</List>
);
}
mount(App);في 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()))
})<List each={messages()}>
{(message) => <Text>{message().body}</Text>}
</List>لذلك احتفظ بحالة كل عنصر في بياناتك (مفهرسة بالمعرّف id)، لا في إشارات signals تُنشأ داخل دالة الصف: فمثل هذه الإشارة تبقى مع الخلية وتظهر على عنصر آخر.
تُخفى الخلايا التي تنتظر عنصرها التالي ما دام صفّها قد اختفى أو ظهر عبر خلية أخرى، بحيث تصادف قارئات الشاشة واللمسات واختبارات واجهة المستخدم كل صف مرة واحدة فقط.
#الخصائص
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
each | virtual_list(count, ..) | readonly T[] | العناصر. | |
children | virtual_list(.., |at| row) | (item: () => T, index: () => number) => JSX.Element | يعرض صفًا واحدًا. | |
estimatedItemSize | .estimated_item_size(px(..)) | number | 50 | حجم العناصر التي لم تُقَس بعد، بالنقاط. التقديرات القريبة تُبقي شريط التمرير ثابتًا. |
horizontal | .horizontal() | boolean | false | يمرّر أفقيًا. |
viewport | .viewport(ListViewport::..) | "own" | "parent" | "own" | "own": تتمرّر القائمة داخل scroll view خاص بها. "parent": تمتد القائمة بطولها الكامل ضمن تخطيط عنصرها الأب، وتعرض الصفوف القريبة من منطقة عرض scroll view المحيط بها، والذي يمرّرها مع بقية الصفحة (راجع القوائم داخل صفحة). يُحدَّد عند إنشاء القائمة. |
numColumns | .columns(n) | number | 1 | عدد العناصر في كل صف (القوائم العمودية). |
inverted | .inverted() | boolean | false | تُرتَّب من النهاية، والعنصر 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. | |
contentClass | string | الحشو والفجوات للمحتوى على شكل فئات 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) => string | item.key أو item.id أو الفهرس | مفتاح العنصر في رموز قابلية المشاهدة. في Rust، هوية الصفوف، بحيث تحتفظ الصفوف بخلاياها وأحجامها عندما تأتي صفوف قبلها أو تذهب. |
onLoadMore | .on_load_more(..) | () => void | النهاية قريبة: حمّل المزيد. | |
loadMoreThreshold | .load_more_threshold(viewports) | number | 1 | مدى القرب من النهاية (بوحدات منطقة العرض) الذي يُطلق عنده onLoadMore. |
onLoadMoreAtStart | .on_load_more_at_start(..) | () => void | البداية قريبة، بعد أن حرّك المستخدم القائمة أو أمر تمرير: حمّل ما يسبق العنصر الأول. | |
loadMoreAtStartThreshold | .load_more_at_start_threshold(viewports) | number | 1 | مدى القرب من البداية (بوحدات منطقة العرض) الذي يُطلق عنده 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 | ScrollbarTheme | true | يظهر مؤشر التمرير: مؤشر المنصة، أو الشريط المدمج للتطبيق متى ضبط سمة theme؛ والكائن هو سمة خاصة بالعرض نفسه. على سطح المكتب وفي متصفحات سطح المكتب يحتفظ العرض الأفقي به: فهو شريط التمرير الخاص بالفأرة. |
scrollValue | .scroll_value(&value) | AnimatedValue | قيمة متحركة تُغذّى بإزاحة التمرير، للرسوم المتحركة المرتبطة بالتمرير. | |
keyboardDismissMode | .keyboard_dismiss_mode(..) | "none" | "on-drag" | "interactive" | "none" | هل يؤدي السحب إلى إخفاء لوحة المفاتيح. |
style | دوال Styled | StyleProp<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(..)):
| الحقل | النوع | الوصف |
|---|---|---|
itemVisibleThreshold | number | مقدار ما يجب أن يظهر من العنصر، من 0 إلى 1. |
viewAreaCoverageThreshold | number | مقدار ما يجب أن يغطيه العنصر من منطقة العرض، من 0 إلى 1. وبدون أي من العتبتين، يُحتسب أي بكسل مرئي. |
minimumViewTime | number | عدد المللي ثانية التي يجب أن تبقى فيها العناصر قابلة للمشاهدة قبل الإبلاغ عنها. |
ViewToken<T> هو { item, key, index, isViewable }. يتلقى مستمع Rust ViewableItems: indices() هو نطاق الصفوف القابلة للمشاهدة، وchanged() الصفوف التي أتت أو ذهبت.
#الأحداث
| الحدث | Rust | البيانات المرافقة | متى |
|---|---|---|---|
onScroll | .on_scroll | ScrollEvent | تغيّرت الإزاحة. |
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()const visible = signal("");
let list!: ListRef;
<List
ref={list}
each={photos}
numColumns={3}
estimatedItemSize={120}
contentStyle={{ padding: 12, gap: 8 }}
viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
onViewableItemsChange={({ viewableItems }) => visible.set(`${viewableItems.length} visible`)}
header={<Text onPress={() => list.scrollToIndex({ index: 60, viewPosition: 0.5 })}>Jump to 60</Text>}
style={{ flex: 1 }}
>
{(photo) => <Image src={photo().url} style={{ height: 120, borderRadius: 8 }} />}
</List>;محادثة:
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()<List each={messages()} inverted estimatedItemSize={64} keyboardDismissMode="interactive" style={{ flex: 1 }}>
{(m) => <Bubble message={m()} />}
</List>العنصر 0 هو أحدث رسالة، ويُرسم في الأسفل؛ والرسائل الجديدة المضافة في بداية العناصر تظهر دون أن تحرّك ما يقرأه المستخدم.
#ملاحظات المنصات
- إدراج عناصر فوق الصفوف المرئية يُبقي الصفوف المرئية في مكانها على المنصتين.
- القوائم الأفقية في التخطيطات من اليمين إلى اليسار تُعكس بشكل أصلي.
- الرؤوس الثابتة تُثبَّت بشكل أصلي في كل إطار تمرير.