Skip to content

Carousel Infinite Forward Scrolling Improve #38

Open
@BensonLiao

Description

Carousel(輪播)組件是一個常見且重要的 UI 元素,但透過Intesecttion Observer來記錄頁面資訊是比較新的做法。本文在分析如何在實現無限往後滾動的功能,以及這種優化帶來的好處。
原本的程式碼不算是完全的無限滾動,在滑動到底之後需要往前回到開頭才能繼續往後滾動

import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { css } from 'styled-components'
import { Button } from 'antd'
import { LeftOutlined, RightOutlined } from '@ant-design/icons'
import { match, P } from 'ts-pattern'

export function Carousel({
  maxWidth,
  xPadding = 56,
  Card,
  cardWidth,
  cardConfig,
  mobile = false,
  compact,
  items,
  autoPlay = false,
  autoplayInterval = 5000,
  scrollMode = 'page',
  cardsPageContainerCss,
  template_lang,
  onPage,
  onCardChange,
  onClick,
  ...props
}) {
  const gap = mobile ? 0 : compact ? 0 : 0
  // the gap between page and card are defined differently in compact mode,
  // while the default/mobile mode are defined in card component,
  // e.g if card's style are set as margin: '0 8px', then cardMargin will be 16px,
  // make sure update this comment when refactor scope related.
  const cardGapInCompactMode = 10
  const cardMargin = mobile ? 0 : compact ? cardGapInCompactMode * 2 : 10
  const cardGap = cardMargin / 2
  const pageGap = compact ? cardGap : 0

  const containerRef = useRef()
  const scrollingTimeoutRef = useRef()
  const [currentPage, setCurrentPage] = useState(0)
  const [hasPrevItems, setHasPrevItems] = useState(true)
  const [hasNextItems, setHasNextItems] = useState(false)

  const pageSize = Math.max(
    1,
    Math.floor((maxWidth - 2 * xPadding) / (cardWidth + cardMargin + 2 * gap)),
  )

  const pages = useMemo(
    () =>
      items.reduce((acc, item, index) => {
        const pageIndex = Math.floor(index / pageSize)

        if (!acc[pageIndex]) {
          acc[pageIndex] = []
        }

        acc[pageIndex].push(item)

        return acc
      }, []),
    [items, pageSize],
  )

  const pageWidth =
    compact && !mobile
      ? pageSize * (cardWidth + cardGap) - cardGap
      : pageSize * (cardWidth + cardMargin) + gap

  const isInCompactModeAndHasMultipleCardsPerPage =
    compact && !mobile && pageSize > 1

  useEffect(() => {
    if (maxWidth === 0) {
      return
    }

    const container = containerRef.current

    const handleIntersection = (entries) => {
      entries.forEach((entry) => {
        const pageIndex = parseInt(entry.target.dataset.index, 10)

        if (entry.isIntersecting) {
          setCurrentPage(pageIndex)
          onPage?.({ index: pageIndex, items: pages[pageIndex] })
        }
      })
    }

    const observerOptions = {
      root: container,
      threshold: 1,
    }

    const observer = new IntersectionObserver(
      handleIntersection,
      observerOptions,
    )

    let fromPageIndex = 0

    const handleCardIntersection = (entries) => {
      entries.forEach((entry) => {
        const pageIndex = parseInt(entry.target.dataset.pageIndex, 10)
        const itemIndex = parseInt(entry.target.dataset.index, 10)
        if (entries.length === 1 && !entry.isIntersecting) {
          fromPageIndex = pageIndex
        }
        if (entries.length === 1 && entry.isIntersecting) {
          const isMovingForward = pageIndex > fromPageIndex
          const itemsFromPage =
            pages[isMovingForward ? pageIndex - 1 : pageIndex + 1] ?? []
          const displayedItemsInTargetPage = pages[pageIndex].filter(
            (_, idx) => (isMovingForward ? idx <= itemIndex : idx >= itemIndex),
          )
          const numberOfDisplayedItemsInFromPage =
            pageSize - displayedItemsInTargetPage.length
          const displayedItemsInFromPage = isMovingForward
            ? itemsFromPage.slice(
                numberOfDisplayedItemsInFromPage === 0
                  ? pageSize
                  : -numberOfDisplayedItemsInFromPage,
                pageSize,
              )
            : itemsFromPage.slice(0, numberOfDisplayedItemsInFromPage)
          const displayedItems = isMovingForward
            ? [...displayedItemsInFromPage, ...displayedItemsInTargetPage]
            : [...displayedItemsInTargetPage, ...displayedItemsInFromPage]
          onCardChange?.({
            pageIndex,
            index: itemIndex,
            items: displayedItems,
            isAutoTrigger: container.dataset.isAutoTrigger === 'true',
          })
        }
      })
    }
    const cardIntersectObserver = new IntersectionObserver(
      handleCardIntersection,
      observerOptions,
    )

    const pageElements = Array.from(container.children)

    pageElements.forEach((page, index) => {
      page.dataset.index = index
      observer.observe(page)
      const itemElements = Array.from(page.children)
      itemElements.forEach((item, index) => {
        item.dataset.pageIndex = page.dataset.index
        item.dataset.index = index
        cardIntersectObserver.observe(item)
      })
    })

    return () => {
      pageElements.forEach((page) => {
        observer.unobserve(page)
        const itemElements = Array.from(page.children)
        itemElements.forEach((item) => {
          cardIntersectObserver.unobserve(item)
        })
      })
    }
  }, [maxWidth, pages, pageSize, onPage, onCardChange])

  const scrollToPage = useCallback(
    ({ page, isAutoTrigger = false }) => {
      const container = containerRef.current
      container.dataset.isAutoTrigger = isAutoTrigger
      const targetScroll =
        page *
        (pageWidth -
          (isInCompactModeAndHasMultipleCardsPerPage ? cardMargin : 0))
      container.scrollTo({
        left: targetScroll,
        behavior: 'smooth',
      })
    },
    [pageWidth, cardMargin, isInCompactModeAndHasMultipleCardsPerPage],
  )

  const scrollByCard = useCallback(
    ({ direction = 'forward', isAutoTrigger = false }) => {
      const container = containerRef.current
      container.dataset.isAutoTrigger = isAutoTrigger
      const scrollOffset = cardWidth + cardMargin
      container.scrollBy({
        left: direction === 'forward' ? scrollOffset : -scrollOffset,
        behavior: 'smooth',
      })
    },
    [cardWidth, cardMargin],
  )

  useEffect(() => {
    const handleResize = () => {
      const container = containerRef.current
      const currentScrollLeft = container.scrollLeft
      const isAtScrollEndPosition =
        currentScrollLeft === container.scrollWidth - container.clientWidth
      setHasPrevItems(pageSize <= items.length && currentScrollLeft > 0)
      // always at scroll end if screen size is large enough that doesn't have pagination
      setHasNextItems(pageSize <= items.length && !isAtScrollEndPosition)
    }

    const handleScroll = () => {
      clearTimeout(scrollingTimeoutRef.current)
      scrollingTimeoutRef.current = setTimeout(() => {
        const container = containerRef.current
        container.dataset.isAutoTrigger = false
        handleResize()
      }, 200)
    }

    const container = containerRef.current
    if (scrollMode === 'card') {
      handleResize()
      container.addEventListener('resize', handleResize)
      container.addEventListener('scroll', handleScroll, { passive: true })
    }
    return () => {
      container.removeEventListener('resize', handleResize)
      container.removeEventListener('scroll', handleScroll, { passive: true })
    }
  }, [scrollMode, pageSize, items])

  useEffect(() => {
    let autoPlayId
    if (autoPlay) {
      autoPlayId = setInterval(() => {
        !hasNextItems
          ? scrollToPage({ page: 0, isAutoTrigger: true })
          : scrollByCard({ direction: 'forward', isAutoTrigger: true })
      }, autoplayInterval)
    }
    return () => {
      clearInterval(autoPlayId)
    }
  }, [
    autoPlay,
    autoplayInterval,
    pages,
    hasNextItems,
    scrollToPage,
    scrollByCard,
  ])

  const handleClickNext = () => {
    scrollMode === 'page'
      ? scrollToPage({ page: currentPage + 1 })
      : scrollByCard({ direction: 'forward' })
  }

  const handleClickPrev = () => {
    scrollMode === 'page'
      ? scrollToPage({ page: currentPage - 1 })
      : scrollByCard({ direction: 'backward' })
  }

  useEffect(() => {
    if (scrollMode === 'page') {
      setHasNextItems(currentPage < pages.length - 1)
    }
  }, [scrollMode, currentPage, pages.length])

  useEffect(() => {
    if (scrollMode === 'page') {
      setHasPrevItems(currentPage > 0)
    }
  }, [scrollMode, currentPage])

  return (
    <div
      css={css`
        width: fit-content;
      `}
      {...props}
    >
      <div
        css={css`
          position: relative;
          display: flex;
          justify-content: center;
          align-items: center;
          width: fit-content;
          margin: 0 auto;
          padding: 0
            ${isInCompactModeAndHasMultipleCardsPerPage ? xPadding / 2 : 0}px;
        `}
      >
        <div
          css={css`
            display: flex;
            justify-content: center;
            align-items: center;

            ${match([mobile, compact])
              .with(
                [true, P._],
                () => css`
                  flex: 1 0 46px;
                  min-width: 46px;
                `,
              )
              .with(
                [false, true],
                () => css`
                  position: absolute;
                  bottom: 40%;
                  left: ${cardGap}px;
                  z-index: 1;
                `,
              )
              .with(
                [false, false],
                () => css`
                  flex: 1 0 56px;
                  min-width: 56px;
                  padding-left: ${cardGap}px;
                `,
              )
              .run()}
          `}
        >
          {hasPrevItems && (
            <CircularButton
              mobile={mobile}
              compact={compact}
              icon={<LeftOutlined />}
              onClick={handleClickPrev}
            />
          )}
        </div>
        <div
          css={css`
            display: flex;
            width: ${pageWidth}px;
          `}
        >
          <div
            ref={containerRef}
            css={css`
              position: relative;
              display: flex;
              width: ${pages.length * pageWidth +
              (pages.length - 1) * (compact ? 0 : 0)}px;
              gap: ${pageGap}px;
              overflow-x: auto;
              scroll-snap-type: x mandatory;
              overscroll-behavior: contain;

              // force to hide scroll bar
              scrollbar-width: none;
              &::-webkit-scrollbar {
                display: none;
              }
            `}
          >
            {pages.map((page, i) => (
              <div
                key={i}
                css={[
                  css`
                    display: flex;
                    scroll-padding: 0;
                    justify-content: center;
                    width: ${pageWidth}px;
                    gap: ${compact ? cardGapInCompactMode : 0}px;
                  `,
                  scrollMode === 'page' &&
                    css`
                      scroll-snap-align: start;
                    `,
                  cardsPageContainerCss,
                ]}
              >
                {page.map((item, j) => (
                  <div
                    key={j}
                    css={[
                      scrollMode === 'card' &&
                        css`
                          scroll-snap-align: start;
                        `,
                    ]}
                  >
                    <Card
                      config={cardConfig}
                      mobile={mobile}
                      compact={compact}
                      index={i * pages[0].length + j}
                      template_lang={template_lang}
                      onClick={() => {
                        onClick?.(item)
                      }}
                      style={{
                        width: `${cardWidth}px`,
                      }}
                      {...item}
                    />
                  </div>
                ))}
              </div>
            ))}
          </div>
        </div>
        <div
          css={css`
            display: flex;
            justify-content: center;
            align-items: center;

            ${!mobile && compact
              ? css`
                  position: absolute;
                  bottom: 40%;
                  right: ${cardGap}px;
                  z-index: 1;
                `
              : mobile
              ? css`
                  flex: 1 0 46px;
                  min-width: 46px;
                `
              : css`
                  flex: 1 0 56px;
                  min-width: 56px;
                `}

            ${!mobile &&
            !compact &&
            css`
              padding-right: ${cardGap}px;
            `}
          `}
        >
          {hasNextItems && (
            <CircularButton
              mobile={mobile}
              compact={compact}
              icon={<RightOutlined />}
              onClick={handleClickNext}
            />
          )}
        </div>
      </div>
    </div>
  )
}

function CircularButton({ mobile, compact, ...props }) {
  return (
    <Button
      css={[
        css`
          background: var(--neutral-1);
          border-radius: 50%;
          border: 1px solid var(--neutral-5);
          box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.016);
        `,
        match(mobile || compact)
          .with(
            true,
            () => css`
              width: 32px;
              height: 32px;
            `,
          )
          .with(
            false,
            () => css`
              width: 40px;
              height: 40px;
            `,
          )
          .run(),
      ]}
      {...props}
    />
  )
}

頁面結構的變化

舊版本:

const pages = useMemo(
  () =>
    items.reduce((acc, item, index) => {
      const pageIndex = Math.floor(index / pageSize)
      if (!acc[pageIndex]) {
        acc[pageIndex] = []
      }
      acc[pageIndex].push(item)
      return acc
    }, []),
  [items, pageSize],
)

新版本:

const pages = useMemo(() => {
  const originalPages = items.reduce((acc, item, index) => {
    const pageIndex = Math.floor(index / pageSize)
    if (!acc[pageIndex]) {
      acc[pageIndex] = []
    }
    acc[pageIndex].push(item)
    return acc
  }, [])

  // 在開頭和結尾添加複製的頁面
  return [
    originalPages[originalPages.length - 1],
    ...originalPages,
    originalPages[0],
  ]
}, [items, pageSize])

主要區別:新版本在頁面數組的開頭和結尾添加了額外的頁面。這種變化使得無限滾動成為可能,因為它創建了一個循環結構。

交集觀察器(Intersection Observer)的使用

兩個版本都使用了 Intersection Observer API 來檢測 item 的可見性,但新版本增加了對循環邏輯的處理:

const handleIntersection = (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        let pageIndex = parseInt(entry.target.dataset.index, 10)

        // 處理循環邏輯
        if (pageIndex === 0) {
          pageIndex = pages.length - 2
        } else if (pageIndex === pages.length - 1) {
          pageIndex = 1
        }

        setCurrentPage(pageIndex)
        onPage?.({ index: pageIndex, items: pages[pageIndex] })
      }
    })
  }
...  
const handleCardIntersection = (entries) => {
  entries.forEach((entry) => {
    let pageIndex = parseInt(entry.target.dataset.pageIndex, 10)
    const itemIndex = parseInt(entry.target.dataset.index, 10)
    if (entries.length === 1 && entry.isIntersecting) {
      const isMovingForward = pageIndex > fromPageIndex
      let itemsFromPage =
        pages[isMovingForward ? pageIndex - 1 : pageIndex + 1] ?? []

      // 處理循環邏輯
      if (isMovingForward && pageIndex === 1) {
        itemsFromPage = pages[pages.length - 2]
      } else if (!isMovingForward && pageIndex === pages.length - 2) {
        itemsFromPage = pages[1]
      }

      const displayedItemsInTargetPage = pages[pageIndex].filter(
        (_, idx) => (isMovingForward ? idx <= itemIndex : idx >= itemIndex),
      )
      const numberOfDisplayedItemsInFromPage =
        pageSize - displayedItemsInTargetPage.length
      const displayedItemsInFromPage = isMovingForward
        ? itemsFromPage.slice(
            numberOfDisplayedItemsInFromPage === 0
              ? pageSize
              : -numberOfDisplayedItemsInFromPage,
            pageSize,
          )
        : itemsFromPage.slice(0, numberOfDisplayedItemsInFromPage)
      const displayedItems = isMovingForward
        ? [...displayedItemsInFromPage, ...displayedItemsInTargetPage]
        : [...displayedItemsInTargetPage, ...displayedItemsInFromPage]

      onCardChange?.({
        pageIndex,
        index: itemIndex,
        items: displayedItems,
        isAutoTrigger: container.dataset.isAutoTrigger === 'true',
      })
    }
  })
}

const cardIntersectObserver = new IntersectionObserver(
      handleCardIntersection,
      observerOptions,
    )

    const pageElements = Array.from(container.children)

    pageElements.forEach((page, pageIndex) => {
      page.dataset.index = pageIndex
      observer.observe(page)
      const itemElements = Array.from(page.children)
      itemElements.forEach((item, index) => {
        // 重新計算項目索引,讓交集觸發時能正確取得當前顯示項目
        item.dataset.pageIndex = pageIndex % pages.length
        item.dataset.index = index % pageSize
        cardIntersectObserver.observe(item)
      })
    })

這個改變確保了在無限滾動模式下,頁面索引始終保持正確。

滾動控制的改進

舊版本沒有實現真正的無限滾動。當到達最後一頁時,繼續往後滾動會返回第一頁。

新版本在 scrollByCard 函數中加入了循環邏輯:

const scrollByCard = useCallback(
    ({ direction = 'forward', isAutoTrigger = false }) => {
      const container = containerRef.current
      container.dataset.isAutoTrigger = isAutoTrigger
      const scrollOffset = cardWidth + cardMargin
      const currentScroll = container.scrollLeft
      const totalWidth = container.scrollWidth
      const viewportWidth = container.clientWidth

      let targetScroll =
        direction === 'forward'
          ? currentScroll + scrollOffset
          : currentScroll - scrollOffset

      // 檢查是否已經滾動到最後一個實際內容
      const isAtEnd = currentScroll >= totalWidth - viewportWidth - pageWidth

      // 檢查是否已經滾動到第一個實際內容之前
      const isAtStart = currentScroll <= pageWidth

      if (isAtEnd && direction === 'forward') {
        // 如果在最後並繼續向前滾動,跳回第一個實際內容
        container.scrollTo({ left: pageWidth, behavior: 'auto' })
        targetScroll = pageWidth + scrollOffset
      } else if (isAtStart && direction === 'backward') {
        // 如果在開始並繼續向後滾動,跳到最後一個實際內容
        container.scrollTo({
          left: totalWidth - pageWidth * 2,
          behavior: 'auto',
        })
        targetScroll = totalWidth - pageWidth * 2 - scrollOffset
      }

      container.scrollTo({
        left: targetScroll,
        behavior: 'smooth',
      })
    },
    [cardWidth, cardMargin, pageWidth],
  )

這種改進確保了無論是手動滾動還是自動播放,都能實現無縫的循環效果。

自動播放邏輯

新版本保持了與舊版本相似的自動播放邏輯,但由於實現了無限滾動,它不需要特別處理到達最後一項的情況:

useEffect(() => {
  let autoPlayId
  if (autoPlay) {
    autoPlayId = setInterval(() => {
      scrollByCard({ direction: 'forward', isAutoTrigger: true })
    }, autoplayInterval)
  }
  return () => {
    clearInterval(autoPlayId)
  }
}, [autoPlay, autoplayInterval, scrollByCard])

總結:

新版本的 Carousel 組件通過引入循環頁面結構和相應的邏輯處理,實現了真正的無限滾動效果。這種改進不僅提升了用戶體驗,使得內容瀏覽更加流暢,還簡化了某些邏輯處理(如自動播放)。然而,這種實現方式也帶來了一些額外的 DOM 元素和複雜性。同時也還有一些現有的功能受到影響尚待處理,一次滾一整頁的滾動方式在複製頁面後的切換有閃一下頁面項目的問題,以觸控板或滑鼠滑動來滾動項目無法無限滾動

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions