跳转到内容

ScrollView 滚动容器

ScrollView 是可滚动容器。它不会改变子组件的声明方式:你仍然用普通 ViewTextBorder 组织内容,只是这些内容会先渲染到一个更大的内部缓冲区,再按当前 offset 显示到可见视口。

scrollview demo

这个录屏由 docs/tapes/scrollview.tape 生成。示例展示一个受控文档阅读器:左侧是 ScrollView,右侧实时显示 ScrollViewState 的 offset。按 j/k 逐行滚动,按 PageDown/PageUp 翻页,状态面板会跟着变化。

cargo run --example scrollview

j/k 或方向键逐行滚动,按 PageDown/PageUp 翻页,按 Home/End 跳到顶部或底部,按 q 退出。

最小形态不需要自己保存滚动状态:

ScrollView(
    flex_direction: Direction::Vertical,
    block: Block::bordered(),
) {
    for (index, row) in rows.into_iter().enumerate() {
        View(key: index, height: Constraint::Length(1)) {
            Text(text: row)
        }
    }
}

不传 state 时,ScrollView 内部会创建自己的 ScrollViewState,并在当前 input layer 注册滚动事件。它支持 j/k、方向键、PageUp/PageDownHome/End 和鼠标滚轮。

内置滚动只由 active(默认 true)门控,state 正交:传外部 state 不会关掉内置滚动——你可以既拿到句柄、又保留内置按键(与其它选择类组件一致)。内部 handler 现在对真正处理了的滚动键返回 EventResult::Consumed(其余返回 Ignored),所以同层无关快捷键(页面 q 退出、? 帮助)照常运行。

当页面需要读取 offset、重置滚动位置,或把滚动状态和其它 UI 联动时,传入外部 state:

let scroll_state = hooks.use_state(ScrollViewState::default);

hooks.use_event_handler_with_options(
    EventScope::Current,
    EventPriority::Normal,
    EventOptions { hit_test: true },
    move |event| {
        let Event::Key(key) = &event else {
            return EventResult::Ignored;
        };

        match key.code {
            KeyCode::Down | KeyCode::Char('j') | KeyCode::Up | KeyCode::Char('k') => {
                scroll_state.write().handle_event(&event);
                EventResult::Consumed
            }
            KeyCode::PageDown | KeyCode::PageUp | KeyCode::Home | KeyCode::End => {
                scroll_state.write().handle_event(&event);
                EventResult::Consumed
            }
            _ => EventResult::Ignored,
        }
    },
);

ScrollView(
    state: scroll_state,
    scrollbars: Scrollbars {
        vertical_scrollbar_visibility: ScrollbarVisibility::Always,
        horizontal_scrollbar_visibility: ScrollbarVisibility::Never,
        ..Default::default()
    },
) {
    // rows
}

示例用的就是外部状态:j/k 移动一个选中光标,视口通过 scroll_state.write().scroll_to_index(selected) 自动跟随;状态面板读取 scroll_state.read().offset()

ScrollViewState 还暴露 size() / page_size()is_at_bottom()(日志/聊天的“贴底”逻辑)、scroll_to_visible(y, height),以及选中项联动滚动的原语 scroll_to_index(i) / child_area(i)——把第 i 个直接子节点滚进视口。ScrollView 每帧记录各直接子节点在内容缓冲中的区域,故页面在一组子节点上移动选中后,只需 scroll_to_index(selected);子布局与”选中谁”无关,无需额外测量。handle_event 返回 bool(是否处理),业务 handler 可据此映射为 Consumed / Ignored

ScrollView 的内容尺寸来自子节点的布局约束。示例里每一行都显式设置高度:

View(key: index, height: Constraint::Length(1)) {
    Text(text: row.line())
}

如果子节点没有稳定高度,滚动距离会变得难预测。长文档、日志、快捷键表、只读详情页这类场景,通常把每一行固定为 Constraint::Length(1) 会更容易控制。

block 会在滚动视口外绘制边框和标题:

ScrollView(
    block: Block::bordered()
        .title(Line::from(" controlled document ").centered())
        .border_style(Style::new().cyan()),
)

滚动条通过 Scrollbars 控制。Automatic 只在内容超出时显示;Always 会预留稳定空间;Never 适合明确不需要某个方向滚动的场景。

有 block 边框时,Scrollbars::over_border(默认 true)把滚动条画在边框环上——轨道落在右/下边框线上,内容占满整个 inner。设为 false 则把滚动条退到边框内侧,视口相应少一行/一列:

ScrollView(
    block: Block::bordered(),
    scrollbars: Scrollbars { over_border: false, ..Default::default() },
)

scrollview 示例支持运行时切换(按 b),可对比两种观感。无论哪种模式,偏移量都按真实可见视口裁剪,所以最后一行/列始终可达。

ScrollView 的 handler 注册在 EventScope::Current。放进 ModalShortcutInfoModal 时,它会自然归属前景弹层的 current layer,所以弹窗内滚动不会穿透到背景页面。

如果你只需要展示快捷键分组,优先用 ShortcutInfoModal;如果要渲染非常长的、带选择态的列表,看 VirtualList