本頁由機器翻譯,可能有錯誤。 閱讀英文原文

文件

捲動

捲動檢視(Rust 中為 scroll(),TSX 中為 <Scroll>)就是平台原生的捲動檢視(iOS 上是 UIScrollView,Android 上是 ScrollView):甩動(fling)、邊緣回彈、捲動指示器和下拉重新整理都是原生的,吸附(snapping)也是。你的程式碼透過事件得知捲動狀態,也可以把偏移量繫結到動畫值,做出逐幀跟隨手指的效果。

guide-scrolling.tsx
guide-scrolling.tsxLive
import { mount, Scroll, type ScrollRef, signal, Text, View } from "nsh";
import { createAnimatedValue } from "nsh";
import { button, buttonText, desktop, size, tint, ui, wide } from "./palette";

// Tints of the info, violet, danger, warn and up colours under the text colour.
const hues = ["info", "violet", "down", "warn", "up", "faint"] as const;
const hue = (i: number) => tint(ui()[hues[i % hues.length]], 0.28);

/** Pages as wide as the scroll view, with a page indicator. */
function Pager() {
  const width = signal(340);
  const page = signal(0);
  return (
    <View style={{ gap: 8 }}>
      <Scroll
        horizontal
        pagingEnabled
        scrollbar={false}
        style={{ height: 110, borderRadius: 12 }}
        onLayout={({ bounds }) => width.set(bounds.size.width)}
        onMomentumEnd={({ offset }) => page.set(Math.round(offset.x / width()))}
        onScrollEnd={({ offset }) => page.set(Math.round(offset.x / width()))}
      >
        <View style={{ flexDirection: "row" }}>
          <Each of={[0, 1, 2, 3]}>
            {(i) => (
              <View style={{ width: width(), height: 110, backgroundColor: hue(i()), alignItems: "center", justifyContent: "center" }}>
                <Text style={{ color: ui().text, fontSize: 24, fontWeight: "700" }}>Page {i() + 1}</Text>
              </View>
            )}
          </Each>
        </View>
      </Scroll>
      <View style={{ flexDirection: "row", justifyContent: "center", gap: 6 }}>
        <Each of={[0, 1, 2, 3]}>
          {(i) => <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: page() === i() ? ui().accent : ui().border }} />}
        </Each>
      </View>
    </View>
  );
}

/** Cards that snap: one stop every card plus gap. */
function Cards() {
  return (
    <Scroll horizontal scrollbar={false} snapToInterval={152} decelerationRate="fast" style={{ height: 90 }}>
      <View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 4 }}>
        <Each of={Array.from({ length: 12 }, (_, i) => i)}>
          {(i) => (
            <View
              style={{
                width: 140,
                height: 90,
                borderRadius: 14,
                backgroundColor: hue(i()),
                boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
                alignItems: "center",
                justifyContent: "center",
              }}
            >
              <Text style={{ color: ui().text, fontWeight: "700" }}>Card {i() + 1}</Text>
            </View>
          )}
        </Each>
      </View>
    </Scroll>
  );
}

function App() {
  // The vertical offset, written natively in the frame it changes.
  const offset = createAnimatedValue(0);
  const y = signal(0);
  const refreshing = signal(false);
  const refreshed = signal(0);
  let scroll!: ScrollRef;
  const refresh = () => {
    refreshing.set(true);
    setTimeout(() => {
      refreshed.update((n) => n + 1);
      refreshing.set(false);
    }, 1200);
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, paddingTop: 24 }}>
      {/* A bar that fades in and shrinks its title as the page scrolls: no JS per frame. */}
      <View
        style={{
          height: 48,
          paddingHorizontal: 16,
          justifyContent: "center",
          backgroundColor: offset.interpolate({ inputRange: [0, 80], outputRange: [ui().page, ui().card.backgroundColor] }),
          borderBottomWidth: 1,
          borderBottomColor: ui().line,
        }}
      >
        <Text
          style={{
            fontSize: 22,
            fontWeight: "700",
            color: ui().text,
            transformOrigin: "left center",
            scale: offset.interpolate({ inputRange: [0, 80], outputRange: [1, 0.8] }),
          }}
        >
          Scrolling
        </Text>
      </View>
      <Scroll
        ref={scroll}
        style={{ flex: 1 }}
        scrollValue={offset}
        scrollEventThrottle={100}
        onScroll={(e) => y.set(Math.round(e.offset.y))}
        refreshing={refreshing()}
        onRefresh={refresh}
        keyboardDismissMode="on-drag"
      >
        <View style={{ padding: 16, gap: 18, maxWidth: wide() ? 720 : undefined }}>
          <Text style={{ color: ui().muted }}>
            {desktop ? `Scroll with the wheel or the trackpad. onScroll: y = ${y()}` : `Pull down to refresh (${refreshed()}×). onScroll: y = ${y()}`}
          </Text>
          <Text style={{ fontWeight: "600", color: ui().text }}>pagingEnabled</Text>
          <Pager />
          <Text style={{ fontWeight: "600", color: ui().text }}>snapToInterval</Text>
          <Cards />
          <View style={{ gap: desktop ? 8 : 18 }}>
            <Each of={Array.from({ length: 24 }, (_, i) => i + 1)}>
              {(n) => (
                <View
                  style={
                    desktop
                      ? { ...ui().row, height: size.row, justifyContent: "center", paddingHorizontal: 12, borderRadius: 8 }
                      : { ...ui().row, padding: 14, borderRadius: 12 }
                  }
                >
                  <Text style={{ color: ui().text, fontSize: desktop ? size.label : undefined }}>Row {n()}</Text>
                </View>
              )}
            </Each>
          </View>
          <View
            onPress={() => scroll.scrollTo({ y: 0 })}
            {...button("primary")}
          >
            <Text style={buttonText("primary")}>Back to the top (ref.scrollTo)</Text>
          </View>
        </View>
      </Scroll>
    </View>
  );
}

mount(App);

捲動檢視適用於大小已知且適中的內容:設定頁面、表單、文章、一排標籤。對於很長或沒有上限的資料,請使用清單,它會回收列,並接受相同的捲動屬性。

#捲動檢視

捲動檢視是可視區域(viewport),它的子元素是內容。可視區域需要由父層給定大小(通常是 flex: 1、.flex_1()),內容則以可視區域的寬度(垂直捲動)或高度(水平捲動)進行配置,長度則依內容需要而定。

#[component]
pub fn App() -> impl IntoElement {
    scroll().flex_1().bg(rgb(0xf6f8fa)).child(
        view().p_4().gap_3().children((1..=50).map(|n| {
            view().p_4().rounded(px(10.0)).bg(white()).child(text(format!("Row {n}")))
        })),
    )
}

上面的子檢視是捲動檢視的內容容器;在 TSX 中,contentStyle(或 contentClass)會替你建立一個,將子元素包在套用該樣式的檢視中,並一起捲動。flexGrow: 1(.flex_grow(1.0))會讓內容至少與可視區域一樣高,因此較短的內容可以置中,較長的內容仍然可以捲動:

scroll().flex_1().child(
    view().flex_grow(1.0).justify_center().p_4().child(
        when(move || messages.with(|m| !m.is_empty()), move || {
            each(messages, |m| m.id, |m, _| text(move || m.with(|m| m.text.clone())))
        })
        .otherwise(|| text("No messages yet")),
    ),
)

水平捲動檢視使用 horizontal(.horizontal()),並以一個橫列(row)作為子元素:

scroll().horizontal().scrollbar(false).child(
    view().flex_row().gap_2().px_4().children(["Today", "This week", "This month", "This year"].map(|label| {
        text(label).px(px(14.0)).py_2().rounded(px(16.0)).overflow_hidden().bg(white())
    })),
)

在從右到左的排版下(請參閱裝置與環境),水平捲動檢視會從右端開始,並保持與右端的距離。

手指可以拖曳捲動檢視。在桌面平台(macOS、Windows、Linux)和瀏覽器中,滑鼠的捲動方式與桌面應用程式相同:使用滾輪(Shift + 滾輪可水平捲動)、觸控板、鍵盤和捲動指示器;滑鼠移到檢視上時會顯示指示器,並可作為捲軸拖曳。滑鼠拖曳則交給應用程式的平移(pan)、responder 和文字選取。桌面裝置上的觸控螢幕與手指一樣可以拖曳捲動檢視。水平檢視即使設定了 scrollbar={false},在這些平台上仍會保留指示器,因為沒有水平滾輪的滑鼠需要靠它來捲動。

#行為屬性

屬性Rust型別預設值說明
horizontal.horizontal()booleanfalse橫向捲動。
scrollable.scrollable(..)booleantrue使用者可以捲動。從程式碼捲動仍然有效。
bounces.bounces(..)booleantrue可超出邊緣過度捲動。
scrollbar.scrollbar(..)booleantrue顯示捲動指示器。在桌面平台和桌面瀏覽器上,水平檢視會保留它:它是滑鼠的捲軸。
contentInset.content_inset(EdgeInsets { .. })Partial<Edges>內容周圍的空間,可捲動進入。
contentOffset.content_offset(point(..)){ x?, y? }起始位置。
decelerationRate.deceleration(rate)"normal" | "fast" | number平台預設值甩動減速的快慢:"normal"、"fast",或每毫秒保留的速度(0.998 為一般,0.99 為快速;0 為平台預設值)。

#分頁與吸附

吸附由原生端在手指離開時決定,依據放開時的偏移量、預估的滑行距離和速度;捲動檢視接著會滑行到選定的停駐點,完全不需要你的程式碼介入。

屬性Rust型別說明
pagingEnabled.paging(true)boolean停在可視區域大小的整數倍處。
snapToInterval.snap_interval(px(..))number停在此長度的整數倍處(0:關閉)。
snapToOffsets.snap_offsets(vec![..])number[]停在這些偏移量處。
snapToAlignment.snap_alignment(SnapAlignment::Center)"start" | "center" | "end"搭配 snapToInterval:項目的哪個位置與可視區域對齊。
disableIntervalMomentum.disable_interval_momentum(true)boolean一次甩動最多移動一個停駐點。

分頁器範例:頁面寬度與捲動檢視相同,並在滑行結束時更新指示器。

const COLORS: [u32; 4] = [0x0969da, 0x8250df, 0xbf3989, 0x1a7f37];

pub fn pager() -> impl IntoElement {
    let width = signal(0.0_f32);
    let page = signal(0_usize);
    let settle = move |e: &Point<Points>| {
        let width = width.get_untracked();
        if width > 0.0 {
            page.set((e.x.0 / width).round() as usize);
        }
    };
    let pages = COLORS.iter().enumerate().map(|(i, &color)| {
        view()
            .w(move || px(width.get()))
            .h(px(160.0))
            .bg(rgb(color))
            .items_center()
            .justify_center()
            .child(text(format!("Page {}", i + 1)).text_color(white()).text_2xl().font_weight(FontWeight::BOLD))
    });
    let dots = (0..COLORS.len()).map(|i| {
        view()
            .size_2()
            .rounded(px(4.0))
            .bg(move || if page.get() == i { rgb(0x0969da) } else { rgb(0xd0d7de) })
    });
    view()
        .gap_2()
        .p_4()
        .child(
            scroll()
                .horizontal()
                .paging(true)
                .scrollbar(false)
                .h(px(160.0))
                .rounded(px(12.0))
                .on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))
                .on_scroll_end(settle)
                .child(view().flex_row().children(pages)),
        )
        .child(view().flex_row().justify_center().gap(px(6.0)).children(dots))
}

吸附到中央的卡片輪播,每次甩動移動一張卡片:

scroll()
    .horizontal()
    .snap_interval(px(232.0))          // card width (220) + gap (12)
    .snap_alignment(SnapAlignment::Center)
    .deceleration(0.99)
    .disable_interval_momentum(true)
    .scrollbar(false)
    .child(view().flex_row().gap_3().px_4().children(cards))

清單也接受相同的吸附屬性,因此較長的輪播更適合使用清單。

#捲動事件

屬性Rust事件資料觸發時機
onScroll.on_scroll(..)ScrollEvent偏移量改變。每幀最多一次;scrollEventThrottle 可拉長間隔。
scrollEventThrottle.event_throttle(ms)numberonScroll 事件之間的最小間隔毫秒數。
onScrollDragStart.on_scroll_drag_start(..){ offset }使用者開始拖曳。
onScrollDragEnd.on_scroll_drag_end(..){ offset }使用者放開手指。
onMomentumStart.on_momentum_start(..){ offset }甩動後的滑行開始。
onMomentumEnd.on_momentum_end(..){ offset }滑行結束(新的拖曳攔住滑行時也會觸發)。
onScrollEnd.on_scroll_end(..){ offset }使用者的捲動停止:在滑行之後,或在沒有滑行的拖曳結束時。

ScrollEvent 包含偏移量(offset,一個 Point;Rust 中相同)、內容大小、縮放後的大小(contentSize,一個 Size;content_size)、可視區域(size)以及 zoomScale(zoom_scale)。其他事件則包含偏移量:TSX 中為 { offset } 搭配 Point,Rust 中為 Point<Points>。

let at_bottom = signal(false);

scroll()
    .flex_1()
    .event_throttle(100.0)
    .on_scroll(move |e: &ScrollEvent| {
        at_bottom.set(e.offset.y + e.size.height >= e.content_size.height - px(20.0))
    })
    .child(content)

#與捲動連動的效果:scrollValue

scrollValue(.scroll_value(&value))接受一個動畫值,並在內容移動的同一幀,於原生端把(主軸方向的)捲動偏移量寫入其中。你可以把這個值,或由它透過 interpolate、diffClamp(diff_clamp)或算術運算衍生的節點,繫結到樣式上。

#[component]
pub fn App() -> impl IntoElement {
    let offset = animated_value(0.0);
    view()
        .flex_1()
        .child(
            view()
                .h(px(56.0))
                .justify_center()
                .px_4()
                // Transparent at the top, white after 80 points.
                .bind_background(&offset.interpolate_colors(&[0.0, 80.0], &[rgba(0xffffff00), rgba(0xffffffff)]))
                .child(
                    text("Inbox")
                        .text_size(px(22.0))
                        .font_weight(FontWeight::BOLD)
                        .origin_left()
                        .bind_scale(&offset.interpolate(&[0.0, 80.0], &[1.0, 0.8])),
                ),
        )
        .child(scroll().flex_1().scroll_value(&offset).child(view().p_4().gap_2()))
}

向上捲動時滑出、向下捲動時立刻回來的標頭,是偏移量的 diffClamp:

let offset = animated_value(0.0);
let header = offset.diff_clamp(0.0, 56.0);

view()
    .flex_1()
    .child(scroll().flex_1().scroll_value(&offset).child(view().pt(px(56.0))))
    .child(view().absolute().top_0().left_0().right_0().h(px(56.0)).bind_translate_y(&header.mul(-1.0)))

內插預設會在輸入範圍之外進行夾限(clamp),因此拉過頂端(負偏移量)時,效果會維持在終點;若要做出下拉時會變大的彈性標頭,請傳入 extrapolate: "extend"(Rust 中為 interpolate_with(.., Interpolation::extrapolate(Extrapolate::Extend)))。

#下拉重新整理

refreshing 和 onRefresh(.refreshing(..) 和 .on_refresh(..))會加入平台的下拉重新整理控制項。當 refreshing 為 true 時顯示該控制項;請在 onRefresh 中設定它,並在工作完成時清除。

let refreshing = signal(false);
let reload = move || {
    refreshing.set(true);
    spawn(async move {
        if let Ok(response) = fetch("https://api.example.com/items").await {
            items.set(parse_items(&response.text()));
        }
        refreshing.set(false);
    });
};

scroll().flex_1().refreshing(refreshing).on_refresh(reload).child(content)

在 iOS 上,該控制項是 UIRefreshControl;在 Mac Catalyst 的 Mac 慣用模式中,NSH 會顯示自己的旋轉指示器,行為相同。清單也接受相同的屬性。在桌面平台和瀏覽器中,下拉是觸控螢幕(或 macOS 觸控板,可拉過頂端)的手勢,因為滑鼠捲動時不會拖曳;請在這些平台上提供其他重新整理的方式,例如按鈕或鍵盤快速鍵。

#自訂指示器

下拉的外觀由應用程式自行決定。refreshIndicator={{ height }} 表示應用程式自行繪製指示器,height 指定其高度(以點為單位):平台的旋轉指示器會被隱藏,下拉達到 height 時觸發重新整理,重新整理期間會在內容上方保留 height 的空間給指示器。pullValue 會以內容被拉過頂端的距離來更新一個動畫值:靜止時為 0,隨下拉增大,重新整理期間為 height。把指示器繪製在捲動檢視的頂端,並依該值內插其樣式:它們會在 UI 執行緒上跟隨手指,每幀都不需要 JS。

let pull = animated_value(0.0);

view()
    .flex_1()
    .child(
        scroll()
            .flex_1()
            .refreshing(refreshing)
            .on_refresh(reload)
            .pull_value(&pull)
            .refresh_indicator(Some(px(72.0)))
            .child(content),
    )
    .child(
        view()
            .pointer_events(PointerEvents::None)
            .absolute()
            .top_0()
            .left_0()
            .right_0()
            .h(px(72.0))
            .items_center()
            .justify_center()
            .bind_opacity(&pull.interpolate(&[0.0, 36.0], &[0.0, 1.0]))
            .bind_rotate(&pull.interpolate(&[36.0, 72.0], &[0.0, 180.0]))
            .child(arrow()),
    )

請讓 height 保持在可視區域高度的一半以內:拉過頂端後,內容越往下拉,對手指的阻力越大,過高的高度永遠無法達到。在 iOS 上,應用程式自己的指示器會以 NSH 自行偵測的下拉取代 UIRefreshControl,與 Mac 慣用模式相同。

#縮放

當最大縮放比例大於最小縮放比例時,捲動檢視(清單的縮放比例固定為 1)可以縮放:雙指捏合會縮放內容,放大時可以雙向捲動。

屬性Rust型別預設值說明
minimumZoomScale.zoom_range(min, max)number1最小縮放。
maximumZoomScale.zoom_range(min, max)number1最大縮放;大於最小值時,可用捏合縮放。
zoomScale.zoom(scale)number屬性改變時縮放到此比例;否則保留使用者的縮放。
bouncesZoom.bounces_zoom(..)booleantrue捏合可超出限制,並彈回。
zoomable.zoomable(..)booleantrue使用者可以捏合縮放。
centerContent.center_content(..)booleanfalse小於可視區域的內容(縮小時)會置中。

捲動事件會回報縮放比例和縮放後的內容大小。從程式碼呼叫 zoom_to(scale, center, animated) / zoomTo({ scale, x, y, animated }) 時,會縮放並讓該內容座標點(以未縮放的點為單位)落在可視區域的中央;雙擊縮放就是呼叫它的按壓處理函式:

pub fn photo() -> impl IntoElement {
    let photo = element_ref();
    let scale = signal(1.0_f32);
    scroll()
        .flex_1()
        .bg(black())
        .zoom_range(1.0, 4.0)
        .center_content(true)
        .on_scroll(move |e: &ScrollEvent| scale.set(e.zoom_scale))
        .element_ref(&photo)
        .child(
            image("https://picsum.photos/id/1015/1200/800")
                .w(px(390.0))
                .h(px(260.0))
                .on_press(move || photo.zoom_to(if scale.get_untracked() > 1.5 { 1.0 } else { 3.0 }, None, true)),
        )
}

縮放後的捲動檢視中的文字和畫布,會在縮放結束時以新的解析度重新繪製,因此在 iOS 上能保持清晰。在 Android 上,NSH 使用自己的捲動檢視來縮放,它會以捏合的焦點為中心縮放、支援任意數量手指的平移、甩動,並在邊緣和縮放限制處產生橡皮筋效果。Android 在手指間距約 27 mm 時才開始捏合,與系統的手勢偵測器相同。

#從程式碼捲動

為捲動檢視指定一個 ref(Rust 中為 element_ref() 和 .element_ref(&scroller),TSX 中為 ref),然後呼叫它的方法。這些命令會在同一次更新中的其他變更之後執行,因此捲動到剛加入的內容也能正常運作。

RustTSX說明
scroll_to(point, animated)scrollTo({ x?, y?, animated? })捲動到指定偏移量。TSX 中預設帶有動畫。
zoom_to(scale, center, animated)zoomTo({ scale, x?, y?, animated? })可縮放的捲動檢視:縮放到 scale(受限於縮放限制),以內容座標點或中央為中心。
let scroller = element_ref();

scroll().flex_1().element_ref(&scroller).child(content)
view().on_press(move || scroller.scroll_to(point(px(0.0), px(0.0)), true))

清單另外提供 scrollToIndex、scrollToOffset 和 scrollToEnd;請參閱清單。

#捲動檢視與鍵盤

NSH 會自動讓獲得焦點的輸入框保持可見:鍵盤顯示時,輸入框外層的捲動檢視會取得與鍵盤同高的底部內縮(inset),並把輸入框捲動到可見位置。這是在原生端完成的,應用程式中不需要任何程式碼。對於捲動檢視以外的內容,例如固定在底部的訊息列,請把 env(keyboard-inset-height) 當作底部內距使用(捲動檢視以外的內容)。

有兩個屬性決定捲動和點擊如何與鍵盤互動:

屬性Rust型別預設值說明
keyboardDismissMode.keyboard_dismiss_mode(KeyboardDismissMode::OnDrag)"none" | "on-drag" | "interactive""none"拖曳內容是否會隱藏鍵盤。"interactive" 會讓鍵盤在 iOS 上跟著手指向下收起;在 Android 上,其行為與 "on-drag" 相同。
keyboardShouldPersistTaps.keyboard_taps(KeyboardTaps::Handled)"never" | "always" | "handled""never"鍵盤顯示時,點擊會有什麼作用。

keyboardShouldPersistTaps:

  • "never":在獲得焦點的輸入框之外點擊,只會隱藏鍵盤,按鈕要等到下一次點擊才會作用。使用者要先點一次來關閉鍵盤,再點一次來按下按鈕。
  • "handled":點擊會處理該事件的元素(例如按鈕)時,事件會穿透並讓鍵盤保持顯示;點擊空白處則會隱藏鍵盤。通常是表單的最佳選擇。
  • "always":無論點擊什麼,鍵盤都保持顯示。

這兩者都在點擊或拖曳發生時由原生端決定,不需要等待你的程式碼。若要從程式碼隱藏鍵盤,在 Rust 中呼叫 dismiss_keyboard(),在 TSX 中呼叫 nsh 的 Keyboard.dismiss()。

#巢狀捲動檢視

垂直捲動檢視內的水平捲動檢視會如預期運作:各自處理沿著自己軸向的拖曳。兩個同軸向的捲動檢視也可以巢狀(內層先捲動)。需要手指的 responder,例如捲動檢視內的繪圖畫布,會讓外層的捲動檢視無法取得手指:Rust 中使用 .keep_touches(true),TSX 中使用從 onResponderGrant 回傳的 true;請參閱觸控與手勢。

#相關內容

  • Scroll:元素參考文件,包含所有屬性和事件。
  • 清單:具有相同捲動屬性的虛擬化清單。
  • 動畫:動畫值,以及用於捲動連動效果的 interpolate 和 diffClamp。
  • 表單與鍵盤:輸入框、焦點和鍵盤。