元素

Carousel

轮播一次在一个位置显示一张幻灯片:商品的照片、引导流程的步骤、自动切换的横幅、一排卡片。Carousel 负责行为:显示哪张幻灯片、在幻灯片之间滑动、上一张和下一张控件、分页、方向键、在两端循环,以及屏幕阅读器听到的内容。它的部件通过上下文找到它,因此应用可以在自己的布局里自由安排它们。外观由应用决定:它没有自己的颜色、尺寸、箭头、圆点或文字。

el-carousel.tsx

内容是一个原生滚动视图。手指、滚轮和触控板在平台的主线程上移动幻灯片,与指针在同一帧;松手时内容吸附到最近的幻灯片,每次快速滑动只移动一张。内容停下时,轮播用 onChange 报告这张幻灯片。控件、分页、按键和应用的 index 以原生动画把内容滚动到对应幻灯片。

use nsh_base::{
    carousel, carousel_content, carousel_item, carousel_next, carousel_pagination,
    carousel_pagination_item, carousel_previous, theme,
};
use nsh_core::prelude::*;

fn photos() -> impl IntoElement {
    let index = signal(0u32);
    let names = ["Harbor", "Dunes", "Glacier"];
    carousel()
        .index(move || Some(index.get()))
        .on_change(move |i: &u32| index.set(*i))
        .accessibility_label("Photos")
        .gap_3()
        .child(
            carousel_content().w_full().h(px(180.0)).gap_3().child(
                names
                    .iter()
                    .enumerate()
                    .map(|(i, name)| {
                        carousel_item()
                            .accessibility_label(format!("{} of 3: {name}", i + 1))
                            .w_full()
                            .rounded(move || theme().radius)
                            .bg(move || theme().muted)
                            .items_center()
                            .justify_center()
                            .child(text(*name))
                    })
                    .collect::<Vec<_>>(),
            ),
        )
        .child(
            view()
                .flex_row()
                .items_center()
                .gap_2()
                .child(carousel_previous().accessibility_label("Previous photo").child(text("‹")))
                .child(
                    carousel_pagination()
                        .flex_row()
                        .gap_1()
                        .child(
                            (0..3)
                                .map(|i| {
                                    carousel_pagination_item()
                                        .index(i)
                                        .accessibility_label(format!("Photo {}", i + 1))
                                        .size_2()
                                        .rounded_full()
                                        .bg(move || theme().border)
                                        .selected_style(|s| s.bg(move || theme().primary))
                                })
                                .collect::<Vec<_>>(),
                        ),
                )
                .child(carousel_next().accessibility_label("Next photo").child(text("›"))),
        )
}

Carousel 及其部件来自 nsh/base,在 Rust 中是 nsh-base crate。显示的幻灯片是应用的 index:滑动、控件或按键用 onChange 报告下一张,轮播滚动到应用传回的 index。在应用用新的 index 回应一次滑动之前,滑动停下的那张保持显示,所以滑动不会弹回。没有 index 时,轮播从 defaultIndex 开始自己保存。在 Rust 中,carousel()、carousel_content()、carousel_item()、carousel_previous()、carousel_next()、carousel_pagination() 和 carousel_pagination_item() 创建这些部件,每个 prop 有一个 setter(.index(..)),每个事件有一个监听方法(.on_change(..)),另有类型化的样式方法,内容用 .child(..)。

#Props

PropRust类型默认值说明
index.index(..)number无显示的幻灯片,从 0 开始,限制在已有幻灯片范围内。由应用持有:变化通过 onChange 报告,新的 index 以动画滚动过去。
defaultIndex.default_index(..)number0未设置 index 时最初显示的幻灯片。
axis.axis(..)"horizontal" | "vertical""horizontal"幻灯片排列的方向;在轮播创建时读取。纵向轮播的方向键是上和下。
looping.looping(..)booleanfalse幻灯片首尾相接:最后一张之后是第一张,第一张之前是最后一张,无论滑动、上一张、下一张、按键还是应用的 index 都无缝衔接(见循环)。在轮播创建时读取。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器对轮播(一个区域)的称呼:它的幻灯片是什么("精选商品")。
children.child(..)JSX.Element无CarouselContent、控件,以及应用在它们周围布置的其他内容。
class、style样式方法string、StyleProp<ViewStyle>无轮播的布局:内容和控件的位置。

#CarouselContent

PropRust类型默认值说明
children.child(..)JSX.Element无各个 CarouselItem,每个都是内容的子元素。
class、style样式方法string、StyleProp<ViewStyle>无视口的尺寸,以及幻灯片在其中排成的轨道:它们之间的 gap,露出相邻幻灯片的内边距。

#CarouselItem

PropRust类型默认值说明
accessibilityLabel.accessibility_label(..)string无屏幕阅读器对幻灯片(一个分组)的称呼:它在其中的位置和显示的内容("第 2 张,共 5 张:海景")。
children.child(..)JSX.Element无幻灯片的内容。
class、style样式方法string、StyleProp<ViewStyle>无它沿轴的尺寸(每屏一张用 w-full,三张用 basis-1/3)和外观。。循环的轮播里,它的位移由轮播决定:transform 请放在它里面的视图上。

#CarouselPrevious、CarouselNext

PropRust类型默认值说明
accessibilityLabel.accessibility_label(..)string无控件的名称,当子元素没有说明它的文字时(一个箭头图标)。
children.child(..)JSX.Element无应用的图标或文字。
class、style样式方法string、StyleProp<ViewStyle>无它的外观和位置:在内容旁边,或用 absolute 盖在内容边缘。
activeStyle、hoverStyle、focusStyle、disabledStyle.active(|s| ..)、.hover(..)、.focus(..)、.disabled_style(..)StyleProp<ViewStyle>无按下、悬停、聚焦或在两端禁用时的样式,同 <state>: 类。

#CarouselPagination

PropRust类型默认值说明
accessibilityLabel.accessibility_label(..)string无屏幕阅读器对这组分页项的称呼("选择幻灯片")。
indicatorClass.indicator_class(..)string无指示器的 Tailwind 类:一个位于当前幻灯片分页项上的视图(圆点后的胶囊、下划线),它的外观以及在行中横向的位置(inset-y-0、bottom-0 h-0.5)。沿着行它覆盖该分页项,并以原生弹簧动画移到下一个。
indicatorStyle.indicator_style(|s| ..)StyleProp<ViewStyle>无指示器的样式,覆盖在 indicatorClass 之上。两者都没有时,分页没有指示器。
children.child(..)JSX.Element无各个 CarouselPaginationItem,以及应用在它们之间布置的内容。
class、style样式方法string、StyleProp<ViewStyle>无这一行的布局(flex-row gap-2)。

#CarouselPaginationItem

PropRust类型默认值说明
index.index(..)number必填它对应的幻灯片,从 0 开始。超过最后一张的索引被禁用。
selectedStyle.selected_style(|s| ..)StyleProp<ViewStyle>无它的幻灯片显示时的样式,同 selected: 类。
accessibilityLabel.accessibility_label(..)string无它对应哪张幻灯片("第 3 张"),当子元素没有说明它的文字时(一个圆点)。
children.child(..)JSX.Element无应用的圆点、缩略图或数字。
class、style样式方法string、StyleProp<ViewStyle>无它的外观;在 group 下,其中的 group-selected: 类跟随它。

#事件

事件Rust说明
onChange(Carousel).on_change(|index: &u32| ..)用户切换到了另一张幻灯片:滑动、滚轮或触控板停在它上面,或者控件、分页项或按键请求了它。{ index } 就是这张幻灯片。应用设置新的 index 不会报告。
onDragStart(Carousel).on_drag_start(|| ..)用户开始拖动内容(手指、触控板)。
onDragEnd(Carousel).on_drag_end(|| ..)用户拖动的内容停下了。
onFocus(Carousel).on_focus(|| ..)键盘焦点来到轮播自身的视图、上一张或下一张控件,或某个分页项。在它们之间移动不会报告。幻灯片里可聚焦的视图用它自己的 onFocus 报告。
onBlur(Carousel).on_blur(|| ..)键盘焦点离开了轮播自身的视图、它的控件和分页项。

#部件

部件位于样式来自状态
轮播Carousel 的 class、stylefocus:
内容轮播CarouselContent 的 class、style
幻灯片内容CarouselItem 的 class、style
上一张、下一张轮播它们的 class、styledisabled:、active:、hover:、focus:
分页轮播CarouselPagination 的 class、style
指示器分页indicatorClass、indicatorStyle
分页项分页它的 class、styleselected:、disabled:、active:、hover:、focus:

#每屏多张幻灯片

幻灯片的宽度由它自己的类决定,因此一排卡片就是视口里的多张幻灯片。每张幻灯片的起点是一个吸附点:内容停下时,这张幻灯片位于第一张所在的位置,所以第一张前的内边距会保留在每一张前面。在滚动范围的末尾,无法到达该位置的幻灯片与最后一张停在一起,下一张也停在那里。index 指位于第一个位置的幻灯片;与最后一张停在一起的几张显示同一个位置,滑动到那里时报告其中的第一张:

<Carousel accessibilityLabel="合集">
  <CarouselContent class="w-full gap-4 px-4">
    <Each of={collections()}>
      {(c) => (
        <CarouselItem class="basis-1/3">
          <Card collection={c()} />
        </CarouselItem>
      )}
    </Each>
  </CarouselContent>
</Carousel>

在手机宽度下用 basis-1/2 或 basis-[85%] 可以露出下一张卡片的一部分。纵向轮播(axis="vertical")按高度确定幻灯片的尺寸。

#循环

开启 looping 后,幻灯片无缝地首尾相接:最后一张之后是第一张,无论是滑动、按下一张或按键,还是应用设置的 index,中间都不会倒回经过其余幻灯片。内容在幻灯片前后各留出一整圈幻灯片的空间,并把每张幻灯片画在离视图中央最近的位置;动画在平台自己的帧上随滚动计算。每张幻灯片只有一份:屏幕阅读器和分页都只看到一次。

当幻灯片的总长度不小于视口再加一张幻灯片时(三张满宽,或五张三分之一宽),循环是无缝的。少于这个长度时,上一张、下一张和按键仍会在两端循环,但会滚动经过其余幻灯片。

#自动切换的幻灯片

自动切换的横幅由应用实现:用一个计时器设置 index。轮播会报告用户何时在操作它,计时器在这些时候暂停,符合 WAI-ARIA 轮播模式 的要求:拖动前后的 onDragStart / onDragEnd,键盘焦点在轮播、它的控件或分页上时的 onFocus / onBlur,以及包在外面的视图上的指针进出。减少动态效果时请关闭它,并提供一个停止它的按钮(轮换控件):

import { cleanup, effect, reduceMotion, signal, Text, View } from "nsh";
import { Button, Carousel, CarouselContent } from "nsh/base";

const index = signal(0);
const playing = signal(true);
const held = signal(0);
const hold = (on: boolean) => held.set(held() + (on ? 1 : -1));

effect(() => {
  index();
  if (!playing() || held() > 0 || reduceMotion()) return;
  const timer = setInterval(() => index.set((index() + 1) % SLIDES.length), 4000);
  cleanup(() => clearInterval(timer));
});

<View onMouseEnter={() => hold(true)} onMouseLeave={() => hold(false)}>
  <Carousel
    looping
    index={index()}
    onChange={({ index: next }) => index.set(next)}
    onDragStart={() => hold(true)}
    onDragEnd={() => hold(false)}
    onFocus={() => hold(true)}
    onBlur={() => hold(false)}
    accessibilityLabel="精彩内容"
  >
    <CarouselContent class="w-full">{/* 幻灯片 */}</CarouselContent>
    <Button onPress={() => playing.set(!playing())} class="self-start px-3 py-1">
      <Text>{playing() ? "暂停播放" : "开始播放"}</Text>
    </Button>
  </Carousel>
</View>

在 effect 里读取 index(),每次变化(包括用户的切换)后都会重新开始一整个间隔。开启 looping 时,从最后一张到第一张会向前跨过接缝。

#无障碍

  • 轮播是由 accessibilityLabel 命名的区域;每张幻灯片是由它自己的标签命名的分组(它在其中的位置和显示的内容)。
  • 上一张和下一张是按钮,除非循环,否则在两端禁用;每个分页项是一个按钮,它的幻灯片显示时为选中状态。
  • 所有文字都来自应用,使用应用的语言。
  • 循环轮播中的每张幻灯片只有一份,无论画在哪里。

无障碍标签、角色和播报的一般说明见无障碍。

#键盘

在 macOS 和 Web 上,Tab 会到达接受按压的视图,轮播是键盘焦点的一站,它的控件和分页项也是。当其中之一持有焦点时:

按键作用
左、右(纵向时为上、下)移到上一张或下一张,循环时越过两端。
Home、End移到第一张或最后一张。
空格、回车按下有焦点的控件或分页项。

在从右到左的布局里,横向轮播的幻灯片从右边开始,左键移到下一张,右键移到上一张;纵向轮播仍用上、下键。持有焦点的控件变为禁用时(最后一张上的下一张),焦点交给轮播,按键在那里继续生效。

#动效

  • 滑动、滚轮和触控板让幻灯片在同一帧跟随指针移动:由平台滚动它们。
  • 松手时内容滑行到最近的幻灯片,每次快速滑动一张,与平台的吸附一致。
  • 上一张、下一张、分页项、按键和应用的 index 以动画把内容滚动到对应幻灯片;减少动态效果时立即到位。
  • 分页的指示器以弹簧动画移到当前幻灯片的分页项;减少动态效果时立即移动。
  • looping 从最后一张到第一张时向前走一步,跨过接缝;内容停下后,无动画地移回一整圈,画面没有任何变化。

#平台说明

  • iOS、Android 和 Mac Catalyst:手指滑动幻灯片;轻点按下控件。
  • 桌面(macOS、Windows、Linux、GPUI)和 Web:滚轮和触控板滚动幻灯片,点击按下控件。在内容上用鼠标拖动由应用处理,与那里的所有滚动视图一样。
  • 每个平台绘制应用设定的样式:幻灯片、控件、分页及其指示器。