๐ก์คํฌ๋กค ๋ฐ์ค ์ปดํฌ๋ํธ๋?
์คํฌ๋กค ๋ฐ์ค(Scroll Box) ์ปดํฌ๋ํธ๋ ์ฝํ ์ธ ๊ฐ ์ง์ ๋ ํฌ๊ธฐ๋ฅผ ์ด๊ณผํ ๋ ์คํฌ๋กค์ ํตํด ๋ด์ฉ์ ํ์ธํ ์ ์๋๋ก ํ๋ UI ์์์ ๋๋ค. ์ผ๋ฐ์ ์ผ๋ก CSS์ overflow ์์ฑ์ ํ์ฉํ๋ฉฐ, ๊ฐ๋ก ๋๋ ์ธ๋ก ์คํฌ๋กค์ ์ ๊ณตํ ์ ์์ต๋๋ค.
์คํฌ๋กค ๊ธฐ๋ฅ๋ฟ๋ง ์๋๋ผ, ๋ฒํผ์ ํตํด ์ด์ฉ์๊ฐ ํด๋ฆญ ์ ์ ์ ํ ์๋งํผ ๋ฐ์ดํฐ๋ฅผ ์ด๋์์ผ ๊ฐ๋ก๋ก ๋ง์ ์์ ๋ฐ์ดํฐ๋ฅผ ์ ๊ณตํด์ผ ํ ๋ ๋ง์ด ์ฌ์ฉํ๋ ์ปดํฌ๋ํธ์ ๋๋ค.
โ๏ธ๊ตฌํ ๋ด์ฉ
๋ค์์ element๋ฅผ ๋ฐ์ ์ ์๋ useIntersectionObserver
const useIntersectionObserver = (
elemRef: RefObject<Elem | Elem[]>,
options: IntersectionObserverInit = DefaultOption,
) => {
const observerRef = useRef<IntersectionObserver>()
const [entries, setEntries] = useState<IntersectionObserverEntry[]>([])
useEffect(() => {
const node = elemRef.current
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
setEntries(prev => {
return Array.from(new Map(prev.concat(entries).map(e => [e.target, e])).values()).filter(
e => e.isIntersecting,
)
})
}
if (!node) return
const observer = new IntersectionObserver(handleIntersect, options)
observerRef.current = observer
if (Array.isArray(node)) node.forEach(n => n && observer.observe(n))
else observer.observe(node)
return () => {
observer?.disconnect()
}
}, [elemRef, options])
return {
entries,
observerRef,
}
}
setEntries(prev => {
// entries: ์๋ก ๋ณ๊ฒฝ๋ ๋ด์ฉ
// prev: ๊ธฐ์กด entries
// ์ ๋์ ์กฐํฉํด์ newEntries๋ฅผ ๋ง๋ค์ด์ผ ํจ. (isIntersecting: true์ธ ์ ๋ค๋ก๋ง)
// ์ค๋ณต ์ ๊ฑฐ / ์ต์ ์ ๋ณด ์
๋ฐ์ดํธ => Map์ ์ฌ์ฉํด๋ณด๊ฒ ๋ค.
return Array.from(new Map(prev.concat(entries).map(e => [e.target, e])).values()).filter(
e => e.isIntersecting,
)
})
IntersectionObserve
๋ intersection
์ ๋ณด๊ฐ ๋ณ๊ฒฝ๋ ๊ฐ๋ค์ด ์์ ๋, ๋ณ๊ฒฝ๋ ๊ฐ๋ค์ ์ธ์(entries)๋ก ์คํ๋๊ธฐ ๋๋ฌธ์ ๊ธฐ์กด ๊ฐ๊ณผ ๊ฐ์ ์์๋ค์ด ์์ ์ ์์ต๋๋ค.
์ด๋ฌํ ์ค๋ณต์ ๋ง๊ธฐ ์ํด Map
๊ฐ์ฒด๋ฅผ ํ์ฉํ์์ผ๋ฉฐ, isIntersecting ์ฆ
ํ๋ฉด์ ๋ณด์ด๋ ์์๋ค๋ง ์ํ๋ก ๋ฐํํ์์ต๋๋ค
์ข์ฐ ๋ฒํผ์ ํ์ฑํ ์ฌ๋ถ
const { entries: watcherEntries } = useIntersectionObserver(watcherRef)
useEffect(() => {
if (!watcherEntries.length) {
setButtonEnabled(DefaultButtonState)
}
setButtonEnabled(prev => {
const newState = { ...DefaultButtonState }
watcherEntries.forEach(e => {
const direction = (e.target as HTMLLIElement).dataset.direction as Direction
newState[direction] = false
})
return newState
})
}, [watcherEntries])
return (
<div className={cx('scrollBox', wrapperClassName)}>
<ul className={cx('list')} ref={listRef}>
<li
className={cx('observer')}
ref={r => {
watcherRef.current[0] = r
}}
data-direction="prev"
/>
{... ์ค๋ต}
<li
className={cx('observer')}
ref={r => {
watcherRef.current[1] = r
}}
data-direction="next"
/>
</ul>
<button
className={cx('nav-button', 'prev', { on: buttonEnabled.prev })}
onClick={() => move('prev')}
/>
<button
className={cx('nav-button', 'next', { on: buttonEnabled.next })}
onClick={() => move('next')}
/>
</div>
)
์ขํด๋น ๋ฆฌ์คํธ๊ฐ ๋์ธ์ง ์๋์ง๋ฅผ ํ์ธํ๊ธฐ ์ํด ๊ฐ๊ฐ์ ์จ๊น์์๋ฅผ ๋ฃ๊ณ , ์ด ์์๋ค์ useInterscetionObserver๋ฅผ
ํ์ฉํ์ฌ ๊ฐ์ํ์ฌ ์ค๋๋ค. ์์์ ๋ณธ ๊ฒ์ฒ๋ผ useIntersectionObserver์
enties
๋ ํ์ฌ ๋ณด์ด๊ณ ์๋ ์์๋ค์ ๋ฐฐ์ด๋ก ๋ฐํํ๊ธฐ ๋๋ฌธ์, ํด๋น์์ (ex:prev)๊ฐ ๋ณด์ธ๋ค๋ฉด ๊ทธ ๊ฐ์ false๋ก ๋ฐ๊พธ์ด ๋ฒํผ์ ๋นํ์ฑํ ์ฒ๋ฆฌํฉ๋๋ค.
์ผ๋ง๋ ์ด๋ํ ์ง ๊ฒฐ์ ํจ๋ ํจ์
๊ฐ ๋ฒํผ์ ํด๋ฆญํ ๋, ์ด๋ป๊ฒ ๋ค์ ํ์ด์ง๋ฅผ ๋ณด์ฌ์ฃผ๋ ๋ก์ง์ ๋๋ค.
const move = useCallback((direction: Direction) => {
if (!listRef.current || !itemsRef.current.length) return
const { left, right } = getVisibileEdgeItems(listRef.current, itemsRef.current)
const elem = direction === 'prev' ? left : right // ๋ณด์ฌ์ง๋ ๋งจ ๋ ์์ดํ
!
elem?.scrollIntoView({
inline: direction === 'prev' ? 'end' : 'start', // ๊ฐ๋ก์์น 'start' | 'end' | 'nearest' | 'center'
block: 'nearest', // ์ธ๋ก์์น 'start' | 'end' | 'nearest' | 'center'
behavior: 'smooth', // ์ ๋๋ฉ์ด์
์ ๋ฌด. smooth: O / instant: X / auto: ์์์...
})
}, [])
getVisibleEdgeItems
๋ฅผ ํ์ฉํด์ ํ์ฌ ๋ณด์ฌ์ง๋ ์ ๋ ์์ดํ
์ ์ฐพ์๊ณ , ํ์ฌ ํด๋ฆญํ ๋ฒํผ์ ๋ฐ๋ผ(ex: prev) ํ์ฌ ๊ฐ์ฅ ์ผ์ชฝ์ ์๋ ์์๊ฐ ๊ฐ๋ก์์น ์ค๋ฅธ์ชฝ ๋์ผ๋ก ์ด๋ํ๊ฒ ํ์์ต๋๋ค.
๋ทฐํฌํธ ๋ด์์ ์ข์ฐ ๋์์๋ ์์๋ฅผ ๊ตฌํ๋ ํจ์
const getVisibileEdgeItems = ($list: HTMLUListElement, $items: ItemElemType[]) => {
const { left: lLeft, right: lRight } = $list.getBoundingClientRect()
const isVisible = ($item: ItemElemType) => {
const { left, right } = $item?.getBoundingClientRect() || { left: 0, right: 0 }
// ์ ๋ถ ํ๋ฉด์์ ์กด์ฌํ๋ ์กฐ๊ฑด: left >= lLeft && right <= lRight
// ์ ๋งคํ๊ฒ ๊ฑธ์น ๊ฒฝ์ฐ๊น์ง ์ธ์ ํ๋ ์กฐ๊ฑด: left <=lRight && right >= lLeft
return left <= lRight && right >= lLeft // ์ ๋งคํ๊ฒ ๋ณด์ด๋ ๊ฒฝ์ฐ๊น์ง ๋ชจ๋ ํฌํจ์ํด.
}
const leftIndex = Math.max($items.findIndex(isVisible), 0)
const rightIndex = Math.min($items.findLastIndex(isVisible), $items.length - 1)
return { left: $items[leftIndex], right: $items[rightIndex] }
}
๋ด๋ถ์์๋ค์ ๊ฐ์ธ๋ ul
์์์ ๋ด๋ถ์์๋ค li
๋ฅผ ์ธ์๋ก ๋ฐ๋ ํจ์์
๋๋ค. ul
์์์ getBoundingClientRect
๋ฅผ ํ์ฉํด์ ul ์์์ ์ผ์ชฝ๊ณผ ์ค๋ฅธ์ชฝ์ ๋ทฐํฌํธ๊ธฐ์ค ์๋์ขํ๋ฅผ ๊ตฌํฉ๋๋ค.
isVisible
์ li
์์๋ค์ ์๋์ขํ๋ฅผ ๊ตฌํ๋ ํจ์์
๋๋ค.
๋ง์ฝ li
์ left๊ฐ ul
์ left → lleft ๋ณด๋ค ํฌ๊ณ , right ๊ฐ lright ๋ณด๋ค ์๋ค๋ฉด ํ์ฌ ๊ทธ ์์๋ ์๋ฒฝํ๊ฒ ๋ณด์ด๊ณ ์๋ ์ํ์
๋๋ค.
๋ฐ๋ฉด์, left ๊ฐ lleft ๋ณด๋ค ์์ง๋ง, right ๊ฐ lright ๋ณด๋ค ์๋ค๋ฉด ์ผ์ชฝ์ ๊ฑธ์น๊ณ ์๋ ์ํ ์ฆ ๋ณด์ด๋ ์์ ์ค์ ๊ฐ์ฅ ์ผ์ชฝ์ ์๋ ๊ฒฝ์ฐ์ด๋ฉฐ, ๊ทธ ๋ฐ๋๋ ์ค๋ฅธ์ชฝ์ ๊ฑธ์น ์ํ์ ๋๋ค.
์ ์์๋ ๊ฑธ์น๋ ๊ฒฝ์ฐ๋ isVisible
๋ก ํฌํจํ์ฌ ๊ฐ์ฅ ์ผ์ชฝ์์์ ์ค๋ฅธ์ชฝ ์์๋ฅผ ์ฐพ๊ณ ์์ต๋๋ค.
์ด๋ ๊ฒ ๋ฐํ๋ ๊ฐ์ด ์์ move๋ก ์ ๋ฌ๋์ด prev ๊ธฐ์ค ๊ฐ์ฅ ์ค๋ฅธ์ชฝ์ ์๋ ์์๊ฐ ๋ทฐํฌํธ ๊ฐ์ฅ ์ผ์ชฝ์ผ๋ก, next ๊ธฐ์ค ๊ฐ์ฅ ์ผ์ชฝ์ ์๋ ์์๊ฐ ๋ทฐํฌํธ ๊ฐ์ฅ ์ค๋ฅธ์ชฝ์ผ๋ก ์ด๋๋๊ฒ ํฉ๋๋ค.
๋์ผ๋ก
IntersectionObserver
์ getBoundingClient
๋ฅผ ํ์ฉํด์ ํ์ฌ ์์๊ฐ ๋ณด์ด๊ณ ์๋์ง ์ ๋ณด์ด๊ณ ์๋์ง, ๋ํ ์ด๋ค ์์๊ฐ ์๋์ ์๋์ง ํ์
ํ ์ ์์์ต๋๋ค. ๊ฐ๋ก ์คํฌ๋กค ๋ฐ์ค์ ๊ฒฝ์ฐ์๋ ์บ๋ฌ์
์ด๋ ์ด๋ฏธ์ง์ฌ๋ผ์ด๋์ ๋น์ทํ์ง๋ง ์คํ ์ฌ๋ผ์ด๋๊ฐ ์๋ค๋ ์ ์์ ๊ตฌํ์ด ํจ์ฌ ๊ฐ๋จํ์ต๋๋ค.
์ด๋ฐ ๋น์ทํ ์๊ตฌ์ฌํญ์ ๋ณดํต slider๋ slick ๊ณผ ๊ฐ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๋ง์ด ์ฌ์ฉํ๋๋ฐ ์ด์ ๊ฐ์ด ๋น๊ต์ ๊ฐ๋จํ ๊ธฐ๋ฅ์๋ ์ด๋ ๊ฒ ์ง์ ๊ตฌํํ๋ ๊ฒ ์คํ๋ ค ์๊ฐ์ด ๋ ๋ค ์๋ ์์ ๊ฒ ๊ฐ์ต๋๋ค.
'๊ฐ๋ฐ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[UI] Snackbar [UI์์ ๋ง๋ค๊ธฐ] (0) | 2025.04.07 |
---|---|
[UI] Scroll Spy [UI ์์ ๋ง๋ค๊ธฐ] (0) | 2025.03.30 |
[UI] ๋ฌดํ ์คํฌ๋กค(feat: UI ์์ ์ง์ ๋ง๋ค๊ธฐ) (0) | 2025.03.03 |
[UI] ํดํ ์ปดํฌ๋ํธ (feat: UI ์ง์ ๋ง๋ค๊ธฐ) (0) | 2025.01.18 |
[UI] ์์ฝ๋์ธ ์ปดํฌ๋ํธ (feat: UI ์์ ์ง์ ๋ง๋ค๊ธฐ) (0) | 2025.01.06 |