Carousel

Carousel 在可吸附的 viewport 中展示一个或多个相关 item,支持横向和纵向布局、键盘导航、指针与触控板手势、循环以及受控选中项。

#引入

use gpui_kit::Axis;
use gpui_kit::component::carousel::{
    Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext,
    CarouselPagination, CarouselPaginationItem, CarouselPrevious, CarouselState,
};

#使用

为内容创建一个 CarouselState,并将它传给所有 Carousel 部件。

let state = cx.new(|_| CarouselState::new(3));

Carousel::new("projects-carousel", &state)
    .child(
        CarouselContent::new(&state)
            .child(CarouselItem::new("project-1", 0, &state).child("项目一"))
            .child(CarouselItem::new("project-2", 1, &state).child("项目二"))
            .child(CarouselItem::new("project-3", 2, &state).child("项目三")),
    )
    .child(CarouselPrevious::new(&state))
    .child(CarouselNext::new(&state))

CarouselContent 管理 viewport 与吸附布局,CarouselItem 标识一个逻辑 slide。到达对应边界时,上一项和下一项按钮会自动禁用。

state 的 item 数量应与直接 CarouselItem 子元素的数量一致。一个 state 及其 scroll handle 只服务一个 viewport。

#组合结构

Carousel 由一个内容 viewport、其中的 item 和可选控制按钮组成:

Carousel
├── CarouselContent
│   ├── CarouselItem
│   └── CarouselItem
├── CarouselPrevious
└── CarouselNext

可以在 Carousel 根节点上使用 .w_full().max_w_96() 约束整个 Carousel;需要单独设置 viewport 的宽度或高度时,可以直接设置 CarouselContent 的样式。track_style 仅用于间距等内部 track 调整。根节点会把常规子元素按列排布并留出 16px 间距,因此放在内容后面的 CarouselPagination 会自然与内容拉开;需要其他排布时直接在根节点上覆盖样式。

#每屏多个 item

CarouselItem 实现了 Styled。设置 flex basis 可以在 viewport 中同时显示多个 item;再通过 CarouselContent::track_style 设置负的起始 margin,并为每个 item 设置数值相同的起始 padding,即可调整它们之间的间距。这与 shadcn/ui 采用的成对间距模型一致。

use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _, relative};

let state = cx.new(|_| CarouselState::new(6));

CarouselContent::new(&state)
    .track_style(StyleRefinement::default().ml_neg_1())
    .children((0..6).map(|index| {
        CarouselItem::new(("project", index), index, &state)
            .flex_basis(relative(1. / 3.))
            .pl_1()
            .child(format!("项目 {}", index + 1))
    }))

flex basis 控制的是 item 几何尺寸,与按钮等控件使用的语义 Size 相互独立。

横向 Carousel 默认在 content track 上使用 .ml_neg_4(),在 item 上使用 .pl_4();纵向 Carousel 使用对应的 .mt_neg_4().pt_4()。覆盖间距时应同步修改两侧,并使用相同的 spacing scale,这样首个 item 会继续与 viewport 对齐,同时改变可见间距。

#方向

创建 state 时使用 with_axis

let state = cx.new(|_| {
    CarouselState::new(3).with_axis(Axis::Vertical)
});

横向 Carousel 使用 Left 和 Right,纵向 Carousel 使用 Up 和 Down。 纵向 CarouselContent 需要设置明确的高度,让每个全高 item 都有可供吸附的 viewport。

Carousel 根节点可通过 Tab 获得焦点,因此省略可选控制按钮时仍可使用键盘导航。Home 和 End 用于选择第一项和最后一项。点击 Carousel 内部或它的控制按钮同样会让它获得焦点以便键盘导航,但不会显示焦点环;焦点环只在通过键盘聚焦时出现。

#循环

启用循环后,从最后一项继续向后会回到第一项:

let state = cx.new(|_| CarouselState::new(5).with_looping(true));

#受控选中项

应用可以控制 CarouselState。使用 with_selected_index 设置初始选中项,使用 set_selected_index 进行程序化切换。

let state = cx.new(|_| CarouselState::new(4).with_selected_index(1));

state.update(cx, |state, cx| {
    state.set_selected_index(3, cx);
});

如果应用需要同步当前 slide,可以监听 CarouselEvent::Change

cx.subscribe(&state, |this, _, event: &CarouselEvent, cx| {
    let CarouselEvent::Change(index) = event;
    this.selected_index = *index;
    cx.notify();
});

#事件

事件说明
CarouselEvent::Change(index)用户导航选中新的内容时触发。

键盘导航和上一项/下一项按钮使用同一套 state 状态转换,并触发相同事件。指针和触控板手势结束时,会吸附到最近的 snap 点。鼠标滚轮每格移动一项;在边界处开始的手势会交给外层容器滚动。

#分页指示器

分页是可选部件,不会固定一种视觉样式。使用 CarouselPaginationItem 组合指示器,再按需要设置每一项的样式或内容:

CarouselPagination::new().children((0..3).map(|index| {
    CarouselPaginationItem::new(("project-page", index), index, &state)
        .child((index + 1).to_string())
}))

CarouselPaginationItem 与指针、键盘和上一项/下一项导航使用同一套 selection 状态转换。

#控件尺寸

CarouselPreviousCarouselNextCarouselPaginationItem 实现了 Sizable。需要让这些控件同步缩放时,为它们设置相同的语义尺寸:

use gpui_kit::component::{Sizable as _, Size};

CarouselPrevious::new(&state).with_size(Size::Large);
CarouselNext::new(&state).with_size(Size::Large);

上一项和下一项控件默认使用 Size::Medium,分页项默认使用 Size::XSmall

#自定义控制按钮

CarouselPreviousCarouselNext 实现了 ParentElementStyled。没有子元素时,它们会根据方向显示对应的箭头;添加子元素后,可以替换可见内容,同时保留自动导航和边界禁用状态。accessibility_label 也会同步替换控件的 tooltip。

use gpui_kit::ParentElement as _;

CarouselPrevious::new(&state)
    .accessibility_label("上一个项目")
    .child("返回");

CarouselNext::new(&state)
    .accessibility_label("下一个项目")
    .child("继续");

需要完全自定义控制按钮时,可以省略对应的 Carousel 部件,并使用公开 state API 组合任意控件:

use gpui_kit::ParentElement as _;
use gpui_kit::component::{Disableable as _, button::Button};

let previous_state = state.clone();
let previous_disabled = !state.read(cx).has_previous();

Button::new("projects-previous")
    .label("返回")
    .disabled(previous_disabled)
    .on_click(move |_, _, cx| {
        previous_state.update(cx, |state, cx| {
            state.select_previous(cx);
        });
    })

#无障碍

Carousel 会提供带 label 的区域,每个 item 会报告自己在内容集合中的位置。当默认的“轮播”无法准确描述内容时,使用 accessibility_label 设置更明确的名称。

Carousel 动画会遵循应用的减少动效设置。