一个超灵活的 CSS 走马灯,增强了 JavaScript 导航

Avatar of Maks Akymenko
Maks Akymenko

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

我不确定你是否和我一样,但我经常想知道如何构建一个走马灯组件,以便你可以轻松地将一堆项目转储到组件中并获得一个不错的可工作的走马灯——一个允许你平滑滚动、使用动态按钮导航且具有响应性的走马灯。如果这是你想要构建的东西,请继续阅读,我们将一起努力!

这就是我们的目标

从现在开始,我们将使用大量的 JavaScript、React 和 DOM API。

首先,让我们启动一个新的项目

让我们从使用 styled-components 进行样式设置来引导一个简单的 React 应用程序开始

npx create-react-app react-easy-carousel

cd react-easy-carousel
yarn add styled-components
yarn install

yarn start

样式设置并不是我们正在做的重点,所以我为我们准备了一堆预定义的组件,以便我们可以立即使用它们

// App.styled.js
import styled from 'styled-components'

export const H1 = styled('h1')`
  text-align: center;
  margin: 0;
  padding-bottom: 10rem;
`
export const Relative = styled('div')`
  position: relative;
`
export const Flex = styled('div')`
  display: flex;
`
export const HorizontalCenter = styled(Flex)`
  justify-content: center;
  margin-left: auto;
  margin-right: auto;
  max-width: 25rem;
`
export const Container = styled('div')`
  height: 100vh;
  width: 100%;
  background: #ecf0f1;
`
export const Item = styled('div')`
  color: white;
  font-size: 2rem;
  text-transform: capitalize;
  width: ${({size}) => `${size}rem`};
  height: ${({size}) => `${size}rem`};
  display: flex;
  align-items: center;
  justify-content: center;
`

现在让我们转到我们的 App 文件,删除所有不必要的代码,并为我们的走马灯构建一个基本结构

// App.js
import {Carousel} from './Carousel'

function App() {
  return (
    <Container>
      <H1>Easy Carousel</H1>
      <HorizontalCenter>
        <Carousel>
        {/* Put your items here */}
        </Carousel>
      </HorizontalCenter>
    </Container>
  )
}
export default App

我相信这个结构非常简单。它是将走马灯直接居中在页面中间的基本布局。

让我们谈谈组件的结构。我们需要一个主要的 <div> 容器作为我们的基础。在里面,我们将利用原生滚动并放入另一个充当可滚动区域的块。

// Carousel.js 
<CarouserContainer>
  <CarouserContainerInner>
    {children}
  </CarouserContainerInner>
</CarouserContainer>

你可以在内部容器上指定宽度和高度,但我建议避免使用严格的尺寸,而是使用其上的一些大小组件来保持灵活性。

CSS 滚动方式

我们希望滚动流畅,以便清楚地显示幻灯片之间的过渡,因此我们将使用 CSS 滚动捕捉,沿 x 轴水平设置滚动,并在此时隐藏实际的滚动条。

export const CarouserContainerInner = styled(Flex)`
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
  -ms-overflow-style: none;
  scrollbar-width: none;

  &::-webkit-scrollbar {
    display: none;
  }

  & > * {
    scroll-snap-align: center;
  }
`

想知道 scroll-snap-typescroll-snap-align 是怎么回事吗?这是允许我们控制滚动行为的原生 CSS,以便元素在滚动过程中“捕捉”到位。因此,在本例中,我们已沿水平 (x) 方向设置了捕捉类型,并告诉浏览器它必须停止在元素中心的捕捉位置。

换句话说:**滚动到下一张幻灯片,并确保该幻灯片居中显示。**让我们稍微分解一下,看看它如何融入更大的画面。

我们的外部 <div> 是一个灵活的容器,它将子元素(走马灯幻灯片)放在水平行中。这些子元素将很容易超出容器的宽度,因此我们使其能够在容器内水平滚动。这就是 scroll-snap-type 发挥作用的地方。来自 Andy Adams 在 CSS-Tricks 年鉴 中的文章

滚动捕捉是指在窗口(或可滚动容器)滚动时将视口的位置“锁定”到页面上的特定元素。将其想象成在元素顶部放置一个磁铁,该磁铁粘贴到视口顶部并强制页面停止滚动。

我无法说得更好。在 Andy 在 CodePen 上的演示 中试用一下。

但是,我们仍然需要在容器的子元素(同样,走马灯幻灯片)上设置另一个 CSS 属性,以告诉浏览器滚动应该在哪里停止。Andy 将其比作磁铁,所以让我们将该磁铁直接放在幻灯片的中心。这样,滚动就会“锁定”在幻灯片的中心,使其能够在走马灯容器中完整显示。

那个属性?scroll-snap-align

& > * {
  scroll-snap-align: center;
}

我们已经可以通过创建一些随机的项目数组来测试它

const colors = [
  '#f1c40f',
  '#f39c12',
  '#e74c3c',
  '#16a085',
  '#2980b9',
  '#8e44ad',
  '#2c3e50',
  '#95a5a6',
]
const colorsArray = colors.map((color) => (
  <Item
    size={20}
    style={{background: color, borderRadius: '20px', opacity: 0.9}}
    key={color}
  >
    {color}
  </Item>
))

并将其直接转储到我们的走马灯中

// App.js
<Container>
  <H1>Easy Carousel</H1>
  <HorizontalCenter>
    <Carousel>{colorsArray}</Carousel>
  </HorizontalCenter>
</Container>

让我们也为我们的项目添加一些间距,这样它们看起来就不会太挤。你可能还会注意到,在第一个项目的左侧有一些不必要的间距。我们可以添加一个负边距来抵消它。

export const CarouserContainerInner = styled(Flex)`
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
  -ms-overflow-style: none;
  scrollbar-width: none;
  margin-left: -1rem;

  &::-webkit-scrollbar {
    display: none;
  }

  & > * {
    scroll-snap-align: center;
    margin-left: 1rem;
  }
`

仔细观察滚动时的光标位置。它始终位于中心。这就是 scroll-snap-align 属性的作用!

就是这样!我们创建了一个很棒的走马灯,我们可以在其中添加任意数量的项目,它可以正常工作。请注意,我们所有这些操作都是在纯 CSS 中完成的,即使它是作为 React 应用程序构建的。我们实际上并不需要 React 或 styled-components 来使它工作。

额外:导航

我们可以在此结束文章并继续,但我希望更进一步。我目前喜欢的是它很灵活并且可以完成滚动浏览一组项目的**基本工作**。

但你可能已经注意到本文开头演示中的一个关键增强功能:用于浏览幻灯片的按钮。我们将在这里放下 CSS,戴上 JavaScript 帽子来实现它。

首先,让我们在走马灯容器的左侧和右侧定义按钮,当单击这些按钮时,分别滚动到上一张或下一张幻灯片。我使用简单的 SVG 箭头作为组件

// ArrowLeft
export const ArrowLeft = ({size = 30, color = '#000000'}) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width={size}
    height={size}
    viewBox="0 0 24 24"
    fill="none"
    stroke={color}
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
  >
    <path d="M19 12H6M12 5l-7 7 7 7" />
  </svg>
)

// ArrowRight
export const ArrowRight = ({size = 30, color = '#000000'}) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width={size}
    height={size}
    viewBox="0 0 24 24"
    fill="none"
    stroke={color}
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
  >
    <path d="M5 12h13M12 5l7 7-7 7" />
  </svg>
)

现在让我们将它们放置在走马灯的两侧

// Carousel.js
<LeftCarouselButton>
  <ArrowLeft />
</LeftCarouselButton>

<RightCarouselButton>
  <ArrowRight />
</RightCarouselButton>

我们将添加一些样式,为箭头添加绝对定位,以便左侧箭头位于走马灯的左侧边缘,右侧箭头位于右侧边缘。还添加了一些其他内容来设置按钮本身的样式,使其看起来像按钮。此外,我们正在使用走马灯容器的 :hover 状态,以便仅在用户的游标悬停在容器上时显示按钮。

// Carousel.styled.js

// Position and style the buttons
export const CarouselButton = styled('button')`
  position: absolute;
  cursor: pointer;
  top: 50%;
  z-index: 1;
  transition: transform 0.1s ease-in-out;
  background: white;
  border-radius: 15px;
  border: none;
  padding: 0.5rem;
`

// Display buttons on hover
export const LeftCarouselButton = styled(CarouselButton)`
  left: 0;
  transform: translate(-100%, -50%);

  ${CarouserContainer}:hover & {
    transform: translate(0%, -50%);
  }
`
// Position the buttons to their respective sides
export const RightCarouselButton = styled(CarouselButton)`
  right: 0;
  transform: translate(100%, -50%);

  ${CarouserContainer}:hover & {
    transform: translate(0%, -50%);
  }
`

这很酷。现在我们有了按钮,但只有当用户与走马灯交互时才会出现。

但我们是否总是想要看到两个按钮?如果我们在第一张幻灯片上隐藏左侧箭头,并在最后一张幻灯片上隐藏右侧箭头会很棒。就像用户可以浏览这些幻灯片一样,那为什么要设置他们可以浏览的错觉呢?

我建议创建一个负责我们所需所有滚动功能的钩子,因为我们将有很多滚动功能。此外,将功能性关注点与我们的视觉组件分离也是一个好习惯。

首先,我们需要获取组件的引用,以便我们可以获取幻灯片的位置。让我们使用 ref 来做到这一点

// Carousel.js
const ref = useRef()
const position = usePosition(ref)

<CarouserContainer>
  <CarouserContainerInner ref={ref}>
    {children}
  </CarouserContainerInner>
  <LeftCarouselButton>
    <ArrowLeft />
  </LeftCarouselButton>
  <RightCarouselButton>
    <ArrowRight />
  </RightCarouselButton>
</CarouserContainer>

ref 属性位于 <CarouserContainerInner> 上,因为它包含我们所有的项目,并将允许我们进行正确的计算。

现在让我们实现钩子本身。我们有两个按钮。为了使它们工作,我们需要相应地跟踪下一个和上一个项目。最好的方法是为每个项目设置一个状态

// usePosition.js
export function usePosition(ref) {
  const [prevElement, setPrevElement] = useState(null)
  const [nextElement, setNextElement] = useState(null)
}

下一步是创建一个函数,检测元素的位置并更新按钮,使其根据该位置隐藏或显示。

让我们将其称为 update 函数。我们将将其放入 React 的 useEffect 钩子中,因为最初,我们希望在 DOM 首次挂载时运行此函数。我们需要访问我们的可滚动容器,该容器可在 ref.current property 下使用。我们将把它放在一个名为 element 的单独变量中,并从获取元素在 DOM 中的位置开始。

我们也将在这里使用 getBoundingClientRect()。这是一个非常有用的函数,因为它提供了元素在视口(即窗口)中的位置,并允许我们继续进行计算。

// usePosition.js
 useEffect(() => {
  // Our scrollable container
  const element = ref.current

  const update = () => {
    const rect = element.getBoundingClientRect()
}, [ref])

到目前为止,我们已经做了很多定位工作,getBoundingClientRect() 可以帮助我们了解元素的大小(在本例中为 rect)及其相对于视口的位置。

鸣谢:Mozilla 开发者网络

接下来的步骤有点棘手,因为它需要一些数学运算来计算哪些元素在容器内可见。

首先,我们需要通过获取每个项目在视口中的位置并根据容器边界对其进行检查来过滤每个项目。然后,我们检查子元素的左边界是否大于容器的左边界,以及右侧的相同内容。

如果满足其中一个条件,则表示我们的子元素在容器内可见。让我们一步一步将其转换为代码

  1. 我们需要循环并过滤所有容器子元素。我们可以使用每个节点上可用的 children 属性。因此,让我们将其转换为数组并进行过滤
const visibleElements = Array.from(element.children).filter((child) => {}
  1. 之后,我们需要再次使用方便的 getBoundingClientRect() 函数获取每个元素的位置
const childRect = child.getBoundingClientRect()
  1. 现在让我们将我们的绘图变为现实
rect.left <= childRect.left && rect.right >= childRect.right

将这些结合起来,这就是我们的脚本

// usePosition.js
const visibleElements = Array.from(element.children).filter((child) => {
  const childRect = child.getBoundingClientRect()

  return rect.left <= childRect.left && rect.right >= childRect.right
})

过滤完项目后,我们需要检查项目是否为第一个或最后一个项目,以便知道相应地隐藏左侧或右侧按钮。我们将创建两个辅助函数,使用 previousElementSiblingnextElementSibling 检查该条件。这样,我们可以查看列表中是否存在同级元素以及它是否为 HTML 实例,如果是,我们将返回它。

为了获取第一个元素并返回它,我们需要从可见项目列表中获取第一个项目,并检查它是否包含前一个节点。对于列表中的最后一个元素,我们将执行相同的操作,但是,我们需要获取列表中的最后一个项目,并检查它是否包含其自身之后的下一个元素。

// usePosition.js
function getPrevElement(list) {
  const sibling = list[0].previousElementSibling

  if (sibling instanceof HTMLElement) {
    return sibling
  }

  return sibling
}

function getNextElement(list) {
  const sibling = list[list.length - 1].nextElementSibling
  if (sibling instanceof HTMLElement) {
    return sibling
  }
  return null
}

一旦我们有了这些函数,我们就可以最终检查列表中是否存在任何可见元素,然后将左右按钮设置为状态。

// usePosition.js 
if (visibleElements.length > 0) {
  setPrevElement(getPrevElement(visibleElements))
  setNextElement(getNextElement(visibleElements))
}

现在我们需要调用我们的函数。此外,我们希望在每次滚动浏览列表时都调用此函数——这就是我们想要检测元素位置的时候。

// usePosition.js
export function usePosition(ref) {
  const [prevElement, setPrevElement] = useState(null)
  const [nextElement, setNextElement] = useState(null)
  useEffect(() => {
    const element = ref.current
    const update = () => {
      const rect = element.getBoundingClientRect()
      const visibleElements = Array.from(element.children).filter((child) => {
        const childRect = child.getBoundingClientRect()
        return rect.left <= childRect.left && rect.right >= childRect.right
      })
      if (visibleElements.length > 0) {
        setPrevElement(getPrevElement(visibleElements))
        setNextElement(getNextElement(visibleElements))
      }
    }

    update()
    element.addEventListener('scroll', update, {passive: true})
    return () => {
      element.removeEventListener('scroll', update, {passive: true})
    }
  }, [ref])

这里有一个解释,说明为什么我们要在那里传递{passive: true}

现在让我们从钩子中返回这些属性,并相应地更新我们的按钮。

// usePosition.js
return {
  hasItemsOnLeft: prevElement !== null,
  hasItemsOnRight: nextElement !== null,
}
// Carousel.js 
<LeftCarouselButton hasItemsOnLeft={hasItemsOnLeft}>
  <ArrowLeft />
</LeftCarouselButton>

<RightCarouselButton hasItemsOnRight={hasItemsOnRight}>
  <ArrowRight />
</RightCarouselButton>
// Carousel.styled.js
export const LeftCarouselButton = styled(CarouselButton)`
  left: 0;
  transform: translate(-100%, -50%);
  ${CarouserContainer}:hover & {
    transform: translate(0%, -50%);
  }
  visibility: ${({hasItemsOnLeft}) => (hasItemsOnLeft ? `all` : `hidden`)};
`
export const RightCarouselButton = styled(CarouselButton)`
  right: 0;
  transform: translate(100%, -50%);
  ${CarouserContainer}:hover & {
    transform: translate(0%, -50%);
  }
  visibility: ${({hasItemsOnRight}) => (hasItemsOnRight ? `all` : `hidden`)};
`

到目前为止,一切顺利。正如你将看到的,我们的箭头会根据我们在项目列表中的滚动位置动态显示。

我们只剩下最后一步才能使按钮发挥作用。我们需要创建一个函数,它将接受需要滚动到的下一个或上一个元素。

const scrollRight = useCallback(() => scrollToElement(nextElement), [
  scrollToElement,
  nextElement,
])
const scrollLeft = useCallback(() => scrollToElement(prevElement), [
  scrollToElement,
  prevElement,
])

不要忘记将函数包装在useCallback钩子中,以避免不必要的重新渲染。

接下来,我们将实现scrollToElement函数。这个想法非常简单。我们需要获取我们上一个或下一个元素的左边界(取决于单击的按钮),将其与元素的宽度相加,除以二(中心位置),并将此值偏移容器宽度的一半。这将给我们提供到下一个/上一个元素中心的精确可滚动距离。

代码如下所示

// usePosition.js  
const scrollToElement = useCallback(
  (element) => {
    const currentNode = ref.current

    if (!currentNode || !element) return

    let newScrollPosition

    newScrollPosition =
      element.offsetLeft +
      element.getBoundingClientRect().width / 2 -
      currentNode.getBoundingClientRect().width / 2

    currentNode.scroll({
      left: newScrollPosition,
      behavior: 'smooth',
    })
  },
  [ref],
)

scroll 实际上为我们执行滚动操作,同时传递我们需要滚动到的精确距离。现在让我们将这些函数附加到我们的按钮上。

// Carousel.js  
const {
  hasItemsOnLeft,
  hasItemsOnRight,
  scrollRight,
  scrollLeft,
} = usePosition(ref)

<LeftCarouselButton hasItemsOnLeft={hasItemsOnLeft} onClick={scrollLeft}>
  <ArrowLeft />
</LeftCarouselButton>

<RightCarouselButton hasItemsOnRight={hasItemsOnRight} onClick={scrollRight}>
  <ArrowRight />
</RightCarouselButton>

非常好!

作为一个优秀的开发者,我们应该稍微清理一下代码。首先,我们可以通过一个小技巧更好地控制传递的项目,该技巧会自动发送每个子元素所需的样式。 Children API 非常棒,值得一试。

<CarouserContainerInner ref={ref}>
  {React.Children.map(children, (child, index) => (
    <CarouselItem key={index}>{child}</CarouselItem>
  ))}
</CarouserContainerInner>

现在我们只需要更新我们的样式化组件即可。flex: 0 0 auto 保留容器的原始大小,因此它是完全可选的。

export const CarouselItem = styled('div')`
  flex: 0 0 auto;

  // Spacing between items
  margin-left: 1rem;
`
export const CarouserContainerInner = styled(Flex)`
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
  -ms-overflow-style: none;
  scrollbar-width: none;
  margin-left: -1rem; // Offset for children spacing

  &::-webkit-scrollbar {
    display: none;
  }

  ${CarouselItem} & {
    scroll-snap-align: center;
  }
`

可访问性

我们关心我们的用户,因此我们需要使我们的组件不仅功能强大,而且易于访问,以便人们感觉使用起来很舒适。以下是一些我的建议

  • 添加 role='region' 以突出显示此区域的重要性。
  • 添加一个area-label作为标识符。
  • 为我们的按钮添加标签,以便屏幕阅读器可以轻松地将其识别为“上一个”和“下一个”,并告知用户按钮的方向。
// Carousel.js
<CarouserContainer role="region" aria-label="Colors carousel">

  <CarouserContainerInner ref={ref}>
    {React.Children.map(children, (child, index) => (
      <CarouselItem key={index}>{child}</CarouselItem>
    ))}
  </CarouserContainerInner>
  
  <LeftCarouselButton hasItemsOnLeft={hasItemsOnLeft}
    onClick={scrollLeft}
    aria-label="Previous slide
  >
    <ArrowLeft />
  </LeftCarouselButton>
  
  <RightCarouselButton hasItemsOnRight={hasItemsOnRight}
    onClick={scrollRight}
    aria-label="Next slide"
   >
    <ArrowRight />
  </RightCarouselButton>

</CarouserContainer>

随意添加其他轮播以查看它在不同大小的项目中的行为。例如,让我们添加一个第二个轮播,它只是一个数字数组。

const numbersArray = Array.from(Array(10).keys()).map((number) => (
  <Item size={5} style={{color: 'black'}} key={number}>
    {number}
  </Item>
))

function App() {
  return (
    <Container>
      <H1>Easy Carousel</H1>
      <HorizontalCenter>
        <Carousel>{colorsArray}</Carousel>
      </HorizontalCenter>

      <HorizontalCenter>
        <Carousel>{numbersArray}</Carousel>
      </HorizontalCenter>
    </Container>
  )
}

瞧,魔法!添加一堆项目,你就拥有了一个开箱即用的完全可用的轮播。


随意修改它并在你的项目中使用它。我真诚地希望这是一个良好的起点,可以按原样使用,或者进一步增强它以创建更复杂的轮播。有问题?想法?请在TwitterGitHub或下面的评论中联系我!