열심히 코딩을 하는 중, IntersectionObserver Api를 활용한 Scroll-Spying 및 애니메이션을 바닐라고 한번 다시 구현해볼 기회가 있었다.
이전 Bootstrap을 사용할 때는 ScrollSpy라는
https://getbootstrap.com/docs/4.0/components/scrollspy/
기능을 사용해서 구현했었기에, 한번 바닐라로 만들어보면 좋을 거라는 생각이 들었다.
만들고자 하는 것은 다음과 같다.
IntersectionObserver Api을 활용해서, 현재 활성화된(브라우저에서 보이는)component를 감지해서, 투명도를 늘려주는 Trasition 애니메이션을 실행시키는 것.
https://usehooks-ts.com/react-hook/use-intersection-observer
에서 해당 Api를 사용하는 example은 쉽게 찾을 수 있었다.
// useIntersectionObserver.tsx
import { RefObject, useEffect, useState } from 'react'
interface Args extends IntersectionObserverInit {
freezeOnceVisible?: boolean
}
function useIntersectionObserver(
elementRef: RefObject<Element>,
{
threshold = 0,
root = null,
rootMargin = '0%',
freezeOnceVisible = false,
}: Args,
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>()
const frozen = entry?.isIntersecting && freezeOnceVisible
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
setEntry(entry)
}
useEffect(() => {
const node = elementRef?.current // DOM Ref
const hasIOSupport = !!window.IntersectionObserver
if (!hasIOSupport || frozen || !node) return
const observerParams = { threshold, root, rootMargin }
const observer = new IntersectionObserver(updateEntry, observerParams)
observer.observe(node)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef, JSON.stringify(threshold), root, rootMargin, frozen])
return entry
}
export default useIntersectionObserver
//SectionRenderer.tsx
import { useIntersectionObserver } from 'usehooks-ts'
const Section = (props: { title: string }) => {
const ref = useRef<HTMLDivElement | null>(null)
const entry = useIntersectionObserver(ref, {})
const isVisible = !!entry?.isIntersecting
console.log(`Render Section ${props.title}`, { isVisible })
return (
<div
ref={ref}
>
,,,
</div>
)
}
위 hooks를 그대로 SectionRenderer.tsx에 사용하여도 크게 문제는 없겠지만, 내가 하고싶은 것은 SectionRenderer 상위에서 Ref를 handling하는 것이다. 맨 상위 index에서 스크롤링 기능까지 구현할 생각이기 때문에...
우선 위 SectionRenderer를 React.fowardRef로 만드는것이 가장 먼저가 될 것이다.
https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/forward_and_create_ref
의 내용을 참고하여,
// forwarded Renderer
import useIntersectionObserver from './hooks/UseIntersectionObserver'
// const workRef = useRef<HTMLDivElement | null>(null);
// 위 index에서 내려오는 Ref를 React.forwardRef hoc로 감싸고, 타입은 ref와 같은 element로 잡는다.
type RefType = HTMLDivElement | null;
const SectionRenderer = forwardRef<RefType, SectionProps>(({ type = 'work' }, ref) => {
//이 부분 넘어갈때는 다르게 넘겨줘야 함!
const entry = useIntersectionObserver(ref, {})
const isVisible = !!entry?.isIntersecting
return (
,,,
)
}
문제는, 위 Return Type 패턴으로 짜여져 있는 useIntersectionObserver에 어떻게 이 ref를 넘기냐 하는 것이다.
예전에 개발할 때 이런식으로 Custom hook에 ref를 넘기다가, type 오류를 해결 못하고 그냥 any 타입으로 지정해놓은적이 한번 있었기 때문에...
그때처럼 그대로 ForwardedRef를 RefObject로 받을 수 없다는 에러가 나타난다.
따라서
// new UseIntersectionObserver
// 기존 HTMLDivElement에서, ForwardedRef 타입으로 변경
interface RefProps {
ref: React.ForwardedRef<HTMLDivElement>;
}
function useIntersectionObserver(
elementRef: RefProps,
{ threshold = 0, root = null, rootMargin = "0%", freezeOnceVisible = false }: Args
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const frozen = entry?.isIntersecting && freezeOnceVisible;
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
setEntry(entry);
};
useEffect(() => {
//forwardedRef에서 current를 조회하기 위해, 타입 명시를 RefObject로 assert해 준다.
const asserted = elementRef as RefObject<HTMLDivElement>;
const node = asserted.current; // DOM Ref
const hasIOSupport = !!window.IntersectionObserver;
,,,
와 같은 방법으로 해결할 수 있었다.
타입스크립트를 해나가면서 느끼는 점은, 아직 '왜'에 대한 해답이 좀 부족하다는 것을 느낀다. 위 Cheetsheet나 여러 Stackoverflow 답변들을 보면서 해결하고는 있지만 조금 더 React types를 잘 설명하는 그런 위키가 있으면 좋지 않을까..