Open
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
Labels
No labels