元素
Scroll
Scroll 是原生捲動檢視(在 iOS 和 Mac Catalyst 上為 UIScrollView,在 Android 上為 ScrollView)。它會將所有子元件排版為其內容,並以原生方式捲動:捲動、拋甩、回彈和吸附都由平台以顯示器的更新率執行。Rust 以 scroll() 建立,TSX 則使用 nsh 中的 <Scroll>。
import { mount, Scroll, type ScrollRef, signal, Text, View } from "nsh";
import { button, buttonText, desktop, Inspector, Panel, size, ui, wide } from "./palette";
// The page colors, read when they render: they follow the theme.
const colors = (): string[] => [ui().accent, ui().info, ui().violet, ui().down, ui().warn, ui().up];
function App() {
const page = signal(0);
// A page is as wide as the scroll view: 390 on a phone, wider on a desktop.
const width = signal(390);
const y = signal(0);
let scroller!: ScrollRef;
const rows = Array.from({ length: 40 }, (_, i) => i + 1);
/** Pages as wide as the scroll view; `inset`: each page's margin. */
const pager = (inset: number, style: object) => (
<Scroll
horizontal
pagingEnabled
scrollbar={false}
style={style}
onLayout={({ bounds }) => width.set(bounds.size.width)}
onMomentumEnd={({ offset }) => page.set(Math.round(offset.x / width()))}
>
<Each of={colors()}>
{(color, index) => (
<View style={{ width: width(), padding: inset }}>
<View style={{ flex: 1, borderRadius: 16, backgroundColor: color(), alignItems: "center", justifyContent: "center" }}>
<Text style={{ color: ui().page, fontSize: 32, fontWeight: "700" }}>{index() + 1}</Text>
</View>
</View>
)}
</Each>
</Scroll>
);
const toTop = (compact: boolean) => (
<View
onPress={() => scroller.scrollTo({ y: 0, animated: true })}
{...button("secondary", { size: compact ? "md" : "sm" })}
>
<Text style={buttonText()}>scrollTo top</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, paddingTop: 24, gap: 12 }}>
{() =>
wide() ? (
<View style={{ flex: 1, paddingHorizontal: 20, paddingBottom: 20, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>scroll</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Shift+wheel or swipe the trackpad across the pages; the wheel scrolls the rows" : "Swipe the pages and the rows"}
</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<View style={{ flex: 1, minWidth: 0, gap: 12 }}>
{pager(0, { height: 150, flexGrow: 0, borderRadius: 16 })}
{/* The scroll view fills its region, so its scrollbar sits at the region's edge. */}
<View style={{ flex: 1, borderRadius: 16, borderWidth: 1, borderColor: ui().line, overflow: "hidden" }}>
<Scroll ref={scroller} style={{ flex: 1 }} scrollEventThrottle={16} onScroll={(e) => y.set(e.offset.y)}>
<View style={{ padding: 12, gap: 6 }}>
<Each of={rows}>
{(n) => (
<View style={{ ...ui().row, borderRadius: 8, height: size.row, paddingHorizontal: 12, justifyContent: "center" }}>
<Text style={{ color: ui().text, fontSize: size.label }}>Row {n()}</Text>
</View>
)}
</Each>
</View>
</Scroll>
</View>
</View>
<Inspector>
<Panel title="Horizontal, pagingEnabled · onMomentumEnd">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
page {page() + 1} of {colors().length}
</Text>
</Panel>
<Panel title="Vertical · onScroll">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>y = {Math.round(y())}</Text>
</Panel>
{toTop(true)}
</Inspector>
</View>
</View>
) : (
<>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text, paddingHorizontal: 20 }}>scroll</Text>
<Text style={{ color: ui().muted, paddingHorizontal: 20 }}>
Horizontal, pagingEnabled · page {page() + 1} · {desktop ? "Shift+wheel or swipe the trackpad" : "swipe"}
</Text>
{pager(12, { height: 140, flexGrow: 0 })}
<View style={{ width: "100%", maxWidth: 640, alignSelf: "center", flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 20 }}>
<Text style={{ color: ui().muted }}>Vertical · y = {Math.round(y())}</Text>
{toTop(false)}
</View>
<Scroll ref={scroller} style={{ flex: 1 }} scrollEventThrottle={16} onScroll={(e) => y.set(e.offset.y)}>
<View style={{ width: "100%", maxWidth: 640, alignSelf: "center", paddingHorizontal: 20, paddingBottom: 20, gap: 8 }}>
<Each of={rows}>
{(n) => (
<View style={{ ...ui().row, borderRadius: 12, padding: 14 }}>
<Text style={{ color: ui().text }}>Row {n()}</Text>
</View>
)}
</Each>
</View>
</Scroll>
</>
)
}
</View>
);
}
mount(App);use nsh_core::prelude::*;
scroll().flex_1().child(
view().p_4().gap_2().child(each(
rows,
|row: &Row| row.id,
|row, _index| text(move || row.get().title),
)),
)<Scroll style={{ flex: 1 }}>
<View style={{ padding: 16, gap: 8 }}>
<Each of={rows}>{(row) => <Text>{row().title}</Text>}</Each>
</View>
</Scroll>捲動檢視會建立所有子元件,適合中等大小的頁面。如果是很長或沒有上限的資料,請使用列表:它只會為視口附近的列建立元件,並回收重用。
捲動檢視在其捲動軸向上需要有限的尺寸:請為它設定 flex: 1(.flex_1()),或將它放在有高度的容器內。其內容會按捲動檢視的寬度排版(橫向時按高度),而在捲動方向上的長度則按內容所需。
#屬性
除了通用屬性(style 的類型為 ViewStyle)之外,在 Rust 中,捲動屬性是 Scrollable trait 的方法,列表也共用這個 trait。
#捲動
| 屬性 | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|
horizontal |
.horizontal() |
boolean |
false |
橫向捲動。 |
scrollable |
.scrollable(..) |
boolean |
true |
使用者可以捲動。 |
scrollbar |
.scrollbar(..) |
boolean | ScrollbarTheme |
true |
是否顯示捲動指示條:使用平台的指示條,或在應用程式設定了主題後使用應用程式的內建捲動條;傳入物件則為此檢視自己的主題。在桌面和桌面瀏覽器上,橫向檢視會保留指示條,因為它是滑鼠的捲動條。 |
bounces |
.bounces(..) |
boolean |
true |
可越過邊緣過度捲動。 |
pagingEnabled |
.paging(..) |
boolean |
false |
停在視口的整數倍位置。 |
snapToInterval |
.snap_interval(px(..)) |
number |
0 |
停在此長度的整數倍位置(0:關閉)。 |
snapToOffsets |
.snap_offsets(..) |
number[] |
停在這些偏移量。 | |
snapToAlignment |
.snap_alignment(SnapAlignment::Center) |
"start" | "center" | "end" |
"start" |
配合 snapToInterval 時,項目的哪一點與視口對齊。 |
disableIntervalMomentum |
.disable_interval_momentum(..) |
boolean |
false |
一次拋甩最多移動一個吸附點。 |
decelerationRate |
.deceleration(rate) |
"normal" | "fast" | number |
平台預設 | 拋甩減速的快慢:每毫秒保留的速度(normal 為 0.998,fast 為 0.99)。 |
contentInset |
.content_inset(EdgeInsets { .. }) |
Partial<Edges> |
內容四周的留白。 | |
contentOffset |
.content_offset(point(..)) |
{ x?, y? } |
起始位置。 | |
contentStyle |
內容檢視自身的樣式 | ViewStyle |
包住子元件並隨之捲動的檢視的樣式;flexGrow: 1 會讓它的高度至少等於視口(例如置中的空狀態)。 |
|
contentClass |
內容檢視自身的樣式 | string |
與 classes 相同,例如 "grow justify-center p-4"。 |
|
keyboardDismissMode |
.keyboard_dismiss_mode(KeyboardDismissMode::OnDrag) |
"none" | "on-drag" | "interactive" |
"none" |
拖曳時是否收起鍵盤。 |
keyboardShouldPersistTaps |
.keyboard_taps(KeyboardTaps::Handled) |
"never" | "always" | "handled" | boolean |
"never" |
點擊時是否收起鍵盤:never(點擊只用來收起鍵盤)、always(鍵盤保持顯示)、handled(有元件處理該點擊時,鍵盤保持顯示)。 |
scrollValue |
.scroll_value(&value) |
AnimatedValue |
以捲動偏移量(主軸)驅動的動畫值,用於與捲動連動的動畫。 | |
scrollEventThrottle |
.event_throttle(ms) |
number |
onScroll 事件之間的最短間隔毫秒數(無論如何每個影格最多一次)。 |
#下拉重新整理
| 屬性 | Rust | 類型 | 說明 |
|---|---|---|---|
refreshing |
.refreshing(..) |
boolean |
顯示重新整理轉圈圖示。 |
onRefresh |
.on_refresh(..) |
() => void |
使用者下拉要求重新整理:開始載入,並在載入結束前將 refreshing 設為 true。 |
refreshIndicator |
.refresh_indicator(Some(height)) |
{ height: number } |
應用程式自行繪製指示器,高度為 height 點:平台的轉圈圖示會隱藏,下拉到 height 即觸發重新整理,重新整理期間會在內容上方保留 height。高度為 0 或以下表示不使用。 |
pullValue |
.pull_value(&value) |
AnimatedValue |
以檢視被下拉超過頂部的距離驅動的動畫值(靜止時為 0,隨下拉增大;重新整理期間為內容上方保留的空間:height 配合 refreshIndicator),供應用程式的指示器使用。 |
let refreshing = signal(false);
scroll()
.flex_1()
.refreshing(refreshing)
.on_refresh(move || {
refreshing.set(true);
spawn(async move {
load().await;
refreshing.set(false);
});
})
.child(content())const refreshing = signal(false);
async function reload() {
refreshing.set(true);
await load();
refreshing.set(false);
}
<Scroll style={{ flex: 1 }} refreshing={refreshing()} onRefresh={reload}>
…
</Scroll>;#縮放
將 maximumZoomScale 設為大於 minimumZoomScale(Rust 中為 .zoom_range(min, max)),內容即可透過雙指捏合縮放;放大後可向兩個方向捲動。列表不會縮放。
| 屬性 | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|
minimumZoomScale |
.zoom_range(min, max) |
number |
1 |
最小縮放比例。 |
maximumZoomScale |
.zoom_range(min, max) |
number |
1 |
最大縮放比例。 |
zoomScale |
.zoom(..) |
number |
此值改變時縮放至該比例;否則保留使用者的縮放。 | |
bouncesZoom |
.bounces_zoom(..) |
boolean |
true |
捏合時可超出限制,然後彈回。 |
zoomable |
.zoomable(..) |
boolean |
true |
使用者可以捏合縮放。 |
centerContent |
.center_content(..) |
boolean |
false |
小於視口的內容(縮小時)會置中。 |
let photo = element_ref();
scroll()
.element_ref(&photo)
.zoom_range(1.0, 4.0)
.center_content(true)
.flex_1()
.bg(black())
.child(
image(PICTURE)
.w(px(390.0))
.h(px(520.0))
.on_press(move || photo.zoom_to(1.0, None, true)),
)let photo!: ScrollRef;
<Scroll ref={photo} maximumZoomScale={4} centerContent style={{ flex: 1, backgroundColor: "#000" }}>
<Image src={picture} style={{ width: 390, height: 520 }} onPress={() => photo.zoomTo({ scale: 1, animated: true })} />
</Scroll>;#事件
| 事件 | Rust | 負載 | 觸發時機 |
|---|---|---|---|
onScroll |
.on_scroll |
ScrollEvent |
偏移量改變時(每個影格最多一次,且不會比 scrollEventThrottle 更頻密)。 |
onScrollDragStart / onScrollDragEnd |
.on_scroll_drag_start / .on_scroll_drag_end |
{ offset }(Point<Points>) |
使用者開始/停止拖曳。 |
onMomentumStart / onMomentumEnd |
.on_momentum_start / .on_momentum_end |
{ offset } |
拖曳後的滑行開始/停止。被新的拖曳截停的滑行,仍然會以 onMomentumEnd 結束。 |
onScrollEnd |
.on_scroll_end |
{ offset } |
捲動靜止。 |
onRefresh |
.on_refresh |
無 | 下拉重新整理。 |
ScrollEvent 為 { offset: Point; contentSize: Size; size: Size; zoomScale }(Rust 中為 offset、content_size、size、zoom_scale):包含偏移量、(縮放後的)內容尺寸、視口尺寸,以及縮放比例(檢視不縮放時為 1)。
#Ref 方法
| 方法 | Rust | 說明 |
|---|---|---|
scrollTo({ x?, y?, animated? }) |
scroll_to(point(x, y), animated) |
捲動到指定偏移量。 |
zoomTo({ scale, x?, y?, animated? }) |
zoom_to(scale, Some(point(x, y)), animated) |
縮放到 scale(限制在上下限之內),並將內容點(x、y,未縮放的點數)置於視口中央,或圍繞中央縮放。例如雙擊時放大到被點擊的位置。 |
let scroller = element_ref();
scroll().element_ref(&scroller).child(content());
scroller.scroll_to(point(px(0.0), px(0.0)), true);let scroller!: ScrollRef;
<Scroll ref={scroller}>…</Scroll>;
scroller.scrollTo({ y: 0, animated: true });#鍵盤
捲動檢視內的輸入元件會以原生方式避開鍵盤:捲動檢視會加上底部內距,並自動捲動到取得焦點的輸入元件。對於捲動檢視以外的內容,請在底部 padding 或 margin 中使用 env(keyboard-inset-height)。請參閱表單與鍵盤。
#從右到左
在從右到左的版面配置中,橫向捲動檢視會從右端開始,並在內容改變時保持與右端的距離;其子元件會鏡像顯示。
#平台備註
- Android:下拉重新整理由 NSH 繪製(過度捲動效果仍為原生),
contentInset會以 padding 套用,而decelerationRate會影響吸附滑行。 - Android 使用 NSH 自己的捲動檢視進行縮放,它會以左上角為基準縮放內容,並可向兩個方向捲動。捏合要在兩指間距超過 Android 的最小間距(約 27 mm)後才會開始。
- 在 Mac Catalyst(Mac idiom)上,NSH 會偵測下拉動作並顯示自己的轉圈圖示。
- 縮放結束時,iOS 會以縮放後的解析度重繪文字和 canvas。