元素

Scroll

Scroll 是原生滚动视图(iOS 和 Mac Catalyst 上是 UIScrollView,Android 上是 ScrollView)。它把所有子元素作为内容进行布局,并在原生侧滚动:滚动、惯性滑动、回弹和吸附都由平台以屏幕刷新率完成。Rust 用 scroll() 创建,TSX 用从 nsh 导入的 <Scroll>。

el-scroll.tsx
el-scroll.tsx运行中
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),
    )),
)

滚动视图会创建所有子元素,适合中等规模的页面。对于很长或数量不定的数据,请使用列表:它创建视口附近的行,并复用它们。

滚动视图在滚动方向上需要确定的尺寸:在有尺寸的容器中给它 flex: 1(.flex_1())或高度。其内容按滚动视图的宽度(横向时为高度)布局,高度(宽度)按需增长。

#属性

除通用属性外(style 的类型为 ViewStyle)。在 Rust 中,滚动相关的属性是 Scrollable trait 的方法,列表也共用它们。

#滚动

属性Rust类型默认值说明
horizontal.horizontal()booleanfalse横向滚动。
scrollable.scrollable(..)booleantrue用户可以滚动。
scrollbar.scrollbar(..)boolean | ScrollbarThemetrue显示滚动条:平台的滚动指示器,或应用设置主题后的内置滚动条;对象是该视图自己的主题。在桌面和桌面浏览器中,横向视图始终保留滚动条:它是鼠标的滚动条。
bounces.bounces(..)booleantrue允许越过边缘回弹。
pagingEnabled.paging(..)booleanfalse停在视口尺寸的整数倍处。
snapToInterval.snap_interval(px(..))number0停在该长度的整数倍处(0 表示关闭)。
snapToOffsets.snap_offsets(..)number[]停在这些偏移量处。
snapToAlignment.snap_alignment(SnapAlignment::Center)"start" | "center" | "end""start"使用 snapToInterval 时,条目的哪个位置与视口对齐。
disableIntervalMomentum.disable_interval_momentum(..)booleanfalse一次惯性滑动最多移动一个吸附点。
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同上,写成类名,例如 "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 为真。
refreshIndicator.refresh_indicator(Some(height)){ height: number }应用自己绘制指示器,高 height 点:平台的转圈隐藏,下拉到 height 即刷新,刷新中在内容上方留出 height。height 不大于 0 时视为未设。
pullValue.pull_value(&value)AnimatedValue一个动画值,写入视图被拉过顶部的距离(静止为 0,随下拉增大;刷新中为内容上方留出的空间,设了 refreshIndicator 时等于 height),用来绘制应用的指示器。
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())

#缩放

把 maximumZoomScale 设得大于 minimumZoomScale(Rust 中为 .zoom_range(min, max)),内容就可以双指捏合缩放;放大后可以双向滚动。列表保持原有比例。

属性Rust类型默认值说明
minimumZoomScale.zoom_range(min, max)number1最小缩放比例。
maximumZoomScale.zoom_range(min, max)number1最大缩放比例。
zoomScale.zoom(..)number值变化时缩放到该比例;否则保留用户的缩放。
bouncesZoom.bounces_zoom(..)booleantrue捏合可以超出限制并弹回。
zoomable.zoomable(..)booleantrue用户可以捏合缩放。
centerContent.center_content(..)booleanfalse内容(缩小后)小于视口时居中显示。
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)),
    )

#事件

事件Rust载荷触发时机
onScroll.on_scrollScrollEvent偏移量变化(每帧最多一次,且不比 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);

#键盘

滚动视图中的输入框会在原生侧自动避开键盘:滚动视图获得底部内边距,并自行滚动到获得焦点的输入框。对于滚动视图之外的内容,请在底部内边距或外边距中使用 env(keyboard-inset-height)。参见表单与键盘。

#从右到左

在从右到左的布局中,横向滚动视图从右端开始,并在内容变化时保持与右端的距离;其子元素会被镜像。

#平台说明

  • Android:下拉刷新由 NSH 自行绘制(越界回弹效果仍是原生的),contentInset 以内边距的方式实现,decelerationRate 影响吸附时的滑动。
  • Android 用 NSH 自己的滚动视图实现缩放,围绕内容左上角缩放并支持双向滚动。双指间距超过 Android 的最小距离(约 27 mm)后才开始捏合。
  • 在 Mac Catalyst(Mac 界面风格)上,NSH 自行检测下拉并显示加载指示器。
  • iOS 在缩放结束后会按缩放后的分辨率重绘文本和画布。