๐ ์คํฌ๋กค ์คํ์ด(ScrollSpy) ์ปดํฌ๋ํธ๋?
ScrollSpy(์คํฌ๋กค ์คํ์ด) ์ปดํฌ๋ํธ๋ ํ์ด์ง์์ ํน์ ์์๊ฐ ๋ทฐํฌํธ(viewport)์ ๋ค์ด์ค๋์ง ๊ฐ์งํ๊ณ , ์ด๋ฅผ ๊ธฐ๋ฐ์ผ๋ก UI๋ฅผ ์ ๋ฐ์ดํธํ๋ ๊ธฐ๋ฅ์ ํฉ๋๋ค. ๋ณดํต ๋ด๋น๊ฒ์ด์ ๋ฉ๋ด์ ํจ๊ป ์ฌ์ฉ๋๋ฉฐ, ์ฌ์ฉ์๊ฐ ํ์ด์ง๋ฅผ ์คํฌ๋กคํ ๋ ํ์ฌ ๋ณด๊ณ ์๋ ์น์ ์ ๊ฐ์กฐํ๋ ๋ฐ ์ฌ์ฉ๋ฉ๋๋ค.
๐ฏ ์ฃผ์ ๊ธฐ๋ฅ
- ํน์ ์น์ ์ด ๋ทฐํฌํธ์ ๋ค์ด์ฌ ๋ ๋ค๋น๊ฒ์ด์ ์ ํ์ฑํ
- ์คํฌ๋กค ์์น๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ์ฌ ์น์ ์ ๊ฐ์กฐ ํ์
IntersectionObserver
๋๋scroll
์ด๋ฒคํธ๋ฅผ ์ฌ์ฉํ์ฌ ๋์
โ๏ธ๊ตฌํ ๋ด์ฉ
ํ์ฌ ๋ณด์ฌ์ง๊ณ ์๋ ์์ ํ์ธํ๊ธฐ
1. Scroll Event ํ์ฉ
useEffect(() => {
const calculateItems = () => {
const scrollTop = document.scrollingElement!.scrollTop
itemsRef.current = data.map((d, i) => {
const $item = document.getElementById(d.id)
if (!$item) return null
const { top, height } = $item.getBoundingClientRect()
return { elem: $item, top: top + scrollTop, height, index: i }
})
}
calculateItems()
const resizeObserver = new ResizeObserver(calculateItems)
resizeObserver.observe(document.scrollingElement!)
return () => {
resizeObserver.disconnect()
}
}, [])
document.scrollingElement
์ฌ์ด์ฆ์ ๋ณํ๊ฐ ์์ ๋๋ง๋ค ์์ดํ
๋ค์ ์ ๋ณด(scroll Top
) ๊ฐฑ์
// viewportTop์ document์ resizeObserver๋ฅผ ํตํด scrollTop์ด ๊ฐ์ง๋๊ณ ์์
const setCurrentItem = useCallback(() => {
const scrollTop = viewportTop * -1
const target = itemsRef.current.find(
item =>
item &&
scrollTop >= item.top - HeaderHeight - item.height / 2 &&
scrollTop < item.top - HeaderHeight + item.height / 2,
)
if (target) {
setCurrentIndex(target.index)
navsRef.current[target.index]?.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'instant',
})
}
}, [viewportTop])
useEffect(() => {
setCurrentItem()
}, [viewportTop])
์คํฌ๋กค ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ ๋๋ง๋ค viewportTop
์ด ๋ณ๊ฒฝ๋๋ ๊ฒ์ ํ์ฉ. ํด๋น ๋ณ๊ฒฝ์ด ์์ ๋๋ง๋ค, ํ์ฌ ๋ณด์ด๋ ์์ดํ
๊ณ์ฐ
- ์คํฌ๋กคํ + ํค๋์ ๋์ด๋ณด๋ค top์ด ์๋์ ์์ด์ผํ๋ ์์ ์ค์ ๊ฐ์ฅ ์์ ๊ฐ์ ์ฐพ์
- ์ด๋,
-item.height / 2
,+item.height / 2
๋ฅผ ํตํด์ ์์ดํ ์ด ๋ฐ์ ๋ ์คํฌ๋กคํ์ ๋๋๋ผ๋, ํ์ฉ. ์ฆ ์คํฌ๋กค์ ๋ฐ๋ก ์ฌ๋ฆฌ๊ฑฐ๋ ๋ด๋ฆฌ์๋ง์ ํฌ์ปค์ฑ์ด ๋ฐ๋์ง ์์
2. Intersection Observer ํ์ฉ
const IOOptions: IntersectionObserverInit = {
rootMargin: `-${HeaderHeight}px 0% 0% 0%`,
threshold: [0.5, 1],
}
const { entries } = useIntersectionObserver(itemsRef, IOOptions)
IntersectionObserverOption์ ํตํด header๋งํผ ๋ง์ง์ ์ฃผ์์ผ๋ฉฐ, ์์๊ฐ ์ ๋ฐ์ด ๋ณด์ด์ง ์๋๋ผ๋ ๋ณด์ด๋ ๊ฒ์ผ๋ก ์ฒ๋ฆฌ๋๊ฒ ํจ
useEffect(() => {
const entryIndexes = entries.map(e => +((e.target as HTMLElement).dataset.index || 0))
const minIndex = Math.min(...entryIndexes)
const $target = entries.find(e => +((e.target as HTMLElement).dataset.index || 0) === minIndex)
?.target as HTMLElement
const index = $target?.dataset.index
if (typeof index === 'string') setCurrentItem(+index)
}, [entries])
entries
์ ๋ณ๊ฒฝ์ ๊ฐ์ง(IntersectionObserverOption
๋๋ถ์ ์ด๋ฏธ ์กฐ๊ฑด์ ๋ง๋ ๊ฐ๋ค๋ง ๊ฐ์ง๋จ), ๊ฐ์ฅ ์์ index
๋ฅผ ๋ฐ๊ฒฌํด์ ๊ทธ index
์ ๋ฒํธ๋ฅผ ํ์ฌ ํฌ์ปค์ค ํ๋ ์์๋ก ๋ณ๊ฒฝ
์ธ๋ถ์์ ํน์ ๋ฒํผ์ ํด๋ฆญํ ๋, ํน์ ์์๋ก ์ด๋
const handleNavClick = useCallback(
(item: unknown, index: number) => () => {
const scrollTop = document.scrollingElement!.scrollTop
const itemY = itemsRef.current[index]?.getBoundingClientRect().top || 0
const top = scrollTop + itemY - HeaderHeight
window.scrollTo({
top,
behavior: 'smooth',
})
},
[],
)
- ๋ ๋๋ง ํ item์ ๋ณด๋ฅผ ๋ฏธ๋ฆฌ itemRefs์ ์ ์ฅ
- index๋ฅผ ๋ฐํ์ผ๋ก items๋ฅผ index๋ก ์ ๊ทผํ๋ฉฐ, ํด๋น์์์ ์์น๋ก ์ด๋
์คํฌ๋กค ์ด๋ ์, ํค๋์ ์คํฌ๋กค ์ด๋ (์คํฌ๋กค ๋ฐ์ค ํ์ฉ)
const scrollFocus = useCallback((index: number, behavior: 'instant' | 'smooth' = 'instant') => {
itemsRef.current[index]?.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior,
})
}, [])
useImperativeHandle(
ref,
() => ({
scrollFocus,
}),
[],
)
useImperativeHandle
์ ํ์ฉํ์ฌ, ๋ถ๋ชจ์์์์ ํน์ ์์ดํ
์ ํ๋ฉด์ ์ค๊ฐ์ผ๋ก ์ด๋ํ ์ ์๋๋ก ํ์ฉ
const setCurrentItem = useCallback((index: number) => {
setCurrentIndex(index)
scrollboxRef.current?.scrollFocus(index)
}, [])
ํน์ ์ธ๋ฑ์ค๋ฅผ ํฌ์ปค์ค ํ ๋, ์ ํจ์๋ ํจ๊ป ํธ์ถํ์ฌ ํค๋์ ์คํฌ๋กค๋ ์กฐ์
'๊ฐ๋ฐ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[UI] Popover[UI์์ ๋ง๋ค๊ธฐ] (0) | 2025.04.20 |
---|---|
[UI] Snackbar [UI์์ ๋ง๋ค๊ธฐ] (0) | 2025.04.07 |
[UI] Scroll Box (0) | 2025.03.28 |
[UI] ๋ฌดํ ์คํฌ๋กค(feat: UI ์์ ์ง์ ๋ง๋ค๊ธฐ) (0) | 2025.03.03 |
[UI] ํดํ ์ปดํฌ๋ํธ (feat: UI ์ง์ ๋ง๋ค๊ธฐ) (0) | 2025.01.18 |