Элементы
Scroll
Scroll — это нативный scroll view (UIScrollView на iOS и Mac Catalyst, ScrollView на Android). Он размещает все свои
дочерние элементы как содержимое и прокручивает их нативно: прокрутка, fling, bounce и snapping выполняются на стороне платформы
с частотой дисплея. В Rust его создают с помощью scroll(), в TSX — с помощью <Scroll> из nsh.
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>Scroll view создаёт всех своих потомков, что подходит для страниц умеренного размера. Для длинных или неограниченных данных используйте list: он создаёт строки рядом с видимой областью и переиспользует их.
Scroll view нужен ограниченный размер вдоль его оси: задайте ему flex: 1 (.flex_1()) или высоту внутри контейнера, у которого
она есть. Его содержимое размещается по ширине scroll view (по высоте, если он горизонтальный), а по другой оси имеет столько, сколько ему нужно.
#Props
Помимо общих props (где style имеет тип ViewStyle). В Rust props прокрутки
являются методами трейта Scrollable, общего для списков.
#Scrolling
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
horizontal | .horizontal() | boolean | false | Прокручивается по горизонтали. |
scrollable | .scrollable(..) | boolean | true | Пользователь может его прокручивать. |
scrollbar | .scrollbar(..) | boolean | ScrollbarTheme | true | Показывается индикатор прокрутки: платформенный или встроенная полоса приложения, если оно задало тему; объект — это тема самого представления. На десктопе и в десктопных браузерах горизонтальное представление сохраняет его: это полоса прокрутки мыши. |
bounces | .bounces(..) | boolean | true | Прокрутка за границы (overscroll). |
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 | Fling сдвигает не более чем на одну точку привязки. |
decelerationRate | .deceleration(rate) | "normal" | "fast" | number | платформенное | Как быстро затухает fling: доля скорости, сохраняемая за миллисекунду (normal 0.998, fast 0.99). |
contentInset | .content_inset(EdgeInsets { .. }) | Partial<Edges> | Отступ вокруг содержимого. | |
contentOffset | .content_offset(point(..)) | { x?, y? } | Откуда начинается прокрутка. | |
contentStyle | собственные стили content view | ViewStyle | Стиль представления вокруг дочерних элементов, которое прокручивается вместе с ними; flexGrow: 1 делает его не ниже видимой области (по центру — пустое состояние). | |
contentClass | собственные стили content view | 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 (в любом случае не чаще одного за кадр). |
#Pull to refresh
| Prop | Rust | Type | Description |
|---|---|---|---|
refreshing | .refreshing(..) | boolean | Показывается индикатор обновления. |
onRefresh | .on_refresh(..) | () => void | Пользователь потянул для обновления: начните загрузку и держите refreshing, пока она не закончится. |
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>;#Zooming
Если задать maximumZoomScale больше minimumZoomScale (.zoom_range(min, max) в Rust), содержимое можно масштабировать
щипком; при увеличении оно прокручивается в обе стороны. Списки сохраняют свой масштаб.
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
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>;#Events
| Event | Rust | Payload | When |
|---|---|---|---|
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 methods
| Method | Rust | Description |
|---|---|---|
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 });#Keyboard
Поля ввода внутри scroll view нативно остаются вне зоны клавиатуры: scroll view получает нижний отступ и сам прокручивается к
полю в фокусе. Для содержимого вне scroll view используйте env(keyboard-inset-height)
в нижнем padding или margin. См. forms and the keyboard.
#Right to left
Горизонтальные scroll view в раскладках справа налево начинаются с правого края и сохраняют расстояние до него при изменении содержимого; их дочерние элементы зеркально отражаются.
#Platform notes
- Android: pull to refresh рисуется NSH (overscroll остаётся нативным),
contentInsetприменяется как padding, аdecelerationRateзадаёт форму скольжений при snapping. - Android масштабирует с помощью собственного scroll view из NSH, который масштабирует содержимое относительно его левого верхнего угла и прокручивает в обе стороны. Щипок начинается после минимального расстояния между пальцами, принятого в Android (около 27 мм).
- На Mac Catalyst (Mac idiom) NSH сам определяет натяжение и показывает свой спиннер.
- iOS перерисовывает тексты и canvas в масштабированном разрешении, когда масштабирование заканчивается.