최종 팀 프로젝트를 진행하면서 청첩장은 꾸밀 때 스크롤에 따른 입력 폼 컴포넌트의 전환과 next, previous 버튼을 통한 단계별 스크롤 이벤트를 구현하는 과정에서 버튼을 통한 스크롤 과정에서의 버벅임을 해결하기 위한 과정을 적어보려 한다.
우선 맨 처음 목표로 한 구현 사항은 input component와 뒤에서 보일 element와 연결하여 next와 previous 버튼을 통해 단계별로 스크롤이 진행되도록 하는 것이었다.
input form component의 전환을 위해서 뒤에 보이는 미리보기 요소들을 ref가 담긴 배열로 관리하고 각 단계를 관리하는 state를 생성하여 단계에 맞는 ref로 스크롤이 되도록 구현하였다.
const CreateCardPage = () => {
...
const [currentStep, setCurrentStep] = useState(1);
const refs = [
useRef<HTMLDivElement | null>(null),
useRef<HTMLDivElement | null>(null),
useRef<HTMLDivElement | null>(null),
useRef<HTMLDivElement | null>(null),
useRef<HTMLDivElement | null>(null),
useRef<HTMLDivElement | null>(null),
];
const handleNext = () => {
if (currentStep < refs.length) {
setCurrentStep((prev) => prev + 1);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep((prev) => prev - 1);
}
};
useEffect(() => {
if (refs[currentStep - 1].current) {
refs[currentStep - 1].current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [currentStep]);
...
return(
...
<div
className='min-h-[calc(100vh-114px)]'
ref={refs[0]}
>
<PersonalInfoPreview control={methods.control} />
</div>
{/*r계좌 프리뷰*/}
<div
className='min-h-[calc(100vh-114px)]'
ref={refs[1]}
>
<AccountPreView control={methods.control} />
</div>
{/*웨딩 정보 프리뷰*/}
<div
className='min-h-[calc(100vh-114px)]'
ref={refs[2]}
>
<WeddingInfoPreView control={methods.control} />
</div>
{/* 지도, 교통정보 */}
<div
className='min-h-[calc(100vh-114px)]'
ref={refs[3]}
>
<NavigationDetailsPreview control={methods.control} />
</div>
{/*참석여부*/}
<div
className='min-h-[calc(100vh-114px)]'
ref={refs[4]}
>
<GuestInfoPreview control={methods.control} />
</div>
{/*참석여부*/}
<div
className='min-h-[calc(100vh-114px)]'
ref={refs[5]}
>
colorpalette
</div>
...
{currentStep === 1 && <PersonalInfoInput />}
{currentStep === 2 && <AccountInput />}
{currentStep === 3 && <WeddingInfoInput />}
{currentStep === 4 && <NavigationDetailInput />}
{currentStep === 5 && <GuestInfoInput />}
{currentStep === 6 && <MainViewInput />}
...
)
}
다음 구현 사항은 미리보기 요소들을 스크롤 했을 때 앞의 입력 폼이 연결된 폼들을 화면에 맞게 전환 시켜주어야 했다. 그렇기 때문에 스크롤을 감지하고 그에 맞는 동작을 구현하기 위해서 Intersection Observer API를 사용하기로 했다. 그렇다면 Intersection Observer API는 무엇이고 어떻게 사용하는 것일까?
문서에 따르면 InterSection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공한다. Intersection Observer API는 특정 요소가 다른 요소(또는 viewport)와의 교차점에 들어가거나 나갈 때 또는 두 요소 간의 교차점이 지정된 양만큼 변화될 때 실행되는 콜백 함수를 코드에 등록할 수 있다.
Intersection Observer API는 겹치는 픽셀의 정확한 수나 구체적으로 어떤 픽셀인지 알려주지 못한다. 하지만 "약 N% 정도 겹친다면 어떤 작업을 수행해야 한다." 정도는 가능하다.
Intersection Observer API는 두 상황이 발생했을 때 콜백함수를 실행한다.
타깃 요소와 타깃 요소의 루트 사이의 교차의 정도는 intersection ratio이다. 0.0과 1.0 사이의 값으로 보이는 대상 요소의 백분율로 나타낸다.
let options = {
root: document.querySelector("#scrollArea"),
rootMargin: "0px",
threshold: 1.0,
};
let observer = new IntersectionObserver(callback, options);
intersection Observer는 생성자를 호출하고 threshold가 한 방향 혹은 다른 방향으로 교차할 때마다 callback 함수를 전달하여 생성한다.
root
: 대상 가시성을 체크하기 위한 뷰포트로 사용되는 요소. 반드시 타깃의 상위 요소이어야 한다. 만약 뷰포트를 지정하지 않거나 null이면 브라우저 뷰포트가 기본 설정된다.rootMargin
: 루트 주위의 여백. CSS margin 속성과 비슷한 값을 가질 수 있다. 예시. "10px 20px 30px 40px" (위, 오른쪽, 아래, 왼쪽). 값은 백분율이 될 수 있다. 이 값의 집합은 교차 지점을 계산하기 전에 루트 요소 경계 박스의 각 사이드 값을 늘리거나 줄일 수 있다. 기본값은 0이다.threshold
: 관찰자의 콜백이 무조건 실행되어야 하는 대상의 가기성 백분율을 나타내는 숫자 혹은 숫자 배열이다. 만약 가시성이 50% 넘는 경우만 감지하고 싶으면 0.5를 지정한다. 25%가 지날 때마다 실행하고 싶으면 [0,0.25,0.5,0.75,1]을 지정해서 사용한다. 기본값은 0이다.let target = document.querySelector("#listItem");
observer.observe(target);
// observer를 위해 설정한 콜백은 바로 지금 최초로 실행된다
// 대상을 관찰자에 할당할 때까지 기다린다. (타깃이 현재 보이지 않더라도)
let callback = (entries, observer) => {
entries.forEach((entry) => {
// 각 엔트리는 관찰된 하나의 교차 변화을 설명한다.
// 대상 요소:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};
이 Observer를 사용하여 우리는 스크롤을 통한 element를 감지하여 그에 맞는 입력 폼 컴포넌트 전환을 하고자 했다.
우선 Observer API를 사용하기 위해선 3가지의 설정이 필요하다.
먼저 옵저버의 옵션 설정을 다음과 같이 했다.
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.9,
};
root가 null일 경우엔 최상위 문서의 뷰포트를 사용하는데 우리는 이 미리보기 전체 페이지를 사용할 것이기 때문에 null로 설정하였다. 루트의 마진도 0px 기본으로 사용하였고 threshold 같은 경우는 observer가 대상이 얼마큼 보이는지, 전체 바운딩 박스의 비율에 대한 역치를 나타내는데, 다음 요소가 90%가 보일 때 옵저버의 callback이 실행되도록 설정했다.
// observer callback
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const stepIndex = refs.findIndex((ref) => {
return ref.current === entry.target;
});
setCurrentStep(stepIndex + 1);
}
});
};
이후 useEffect를 통해 observer를 생성하고 refs 배열을 순회하며 각 ref를 observer로 감시하도록 하고 cleanup 함수를 통해 감시를 해제하는 식으로 구현하였다.
useEffect(() => {
const observer = new IntersectionObserver(observerCallback, observerOptions);
refs.forEach((ref) => {
if (ref.current) {
observer.observe(ref.current);
}
});
return () => {
refs.forEach((ref) => {
if (ref.current) {
observer.unobserve(ref.current);
}
});
};
}, [refs]);
저 두가지를 구현하는 과정에서 에러가 발생한 것은 아니지만 의도치 않은 동작이 생겨버렸다. 스크롤을 통한 입력폼 전환과 버튼을 통해 스크롤 이벤트를 진행하는 과정이 충돌하면서 버튼을 눌렀을 때 스크롤이 한번에 되지 않는 현상이 생겼다.
내 생각에는 버튼을 통해 currentStep을 증가 시킬때 observer의 callback도 currentStep을 업데이트 하면서 매끄럽지 않게 업데이트가 되고 있는 것 같았다. 그렇기 때문에 버튼을 통해 currentStep을 업데이트 할 때는 옵저버의 감지를 비활성화 해야겠다고 생각했다.
//기존 observer 콜백 함수
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const stepIndex = refs.findIndex((ref) => ref.current === entry.target);
if (stepIndex !== -1) {
setCurrentStep(stepIndex + 1); // 옵저버가 감지되었을 때 currentStep을 1증가 시킴
}
}
});
};
const handleNext = () => {
if (currentStep < refs.length) {
setCurrentStep((prev) => prev + 1); //Next 버튼을 눌렀을때 또한 step을 증가시킴
}
};
그렇기 때문에 버튼을 통해 currentStep을 업데이트할 때는 옵저버의 감지를 비활성화해야겠다고 생각했다.
버튼을 눌렀을 때 스크롤이 되는 동안에는 옵저버의 동작이 하지 않아야 해서 isNavigating이란 boolean을 가진 ref를 생성하고 next, previous를 눌렀을 때 ref의 상태를 true로 설정 후 scroll이 동작하는 useEffect훅에서 스크롤이 되는 애니메이션의 시간이 지난 후 isNavigating을 다시 false로 돌려주어 재활성화를 해주었다.
const handleNext = () => {
if (currentStep < refs.length) {
isNavigating.current = true;
setCurrentStep((prev) => prev + 1);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
isNavigating.current = true;
setCurrentStep((prev) => prev - 1);
}
};
동시에 observer의 callback이 isNavigating 중일 때는 동작하지 않아야 하기 때문에 callback에서 옵저버가 무시되도록 해야한다.
const observerCallback = (entries: IntersectionObserverEntry[]) => {
if (isNavigating.current) return; // 수동 전환 중에는 옵저버 무시
entries.forEach((entry) => {
if (entry.isIntersecting) {
const currentStepIndex = refs.findIndex((ref) => ref.current === entry.target);
if (currentStepIndex + 1 !== currentStep) {
setCurrentStep(currentStepIndex + 1);
}
}
});
};
이제 매끄럽게 동작하는 것을 볼 수 있다. 하지만 여기서도 문제가 있었는데
next 버튼을 연달아 눌렀을 때 일시적으로 currentStep이 의도한 단계보다 더 넘어가서 뒤의 스크롤 뷰와 input form이 맞지 않는 화면이 보이고 다시 원래대로 돌아가는 것을 볼 수 있다. 그래서 이 문제는 디바운싱을 통해서 마지막 입력으로만 currentStep이 업데이트되도록하여 해결할 수 있었다.
export const debounce = <T extends unknown[]>(func: (...args: T) => void, delay: number) => {
let timer: NodeJS.Timeout;
return (...args: T) => {
clearTimeout(timer);
timer = setTimeout(() => func(...args), delay);
};
};
debounce함수를 구현하고 debounce에 함수와 delay 시간을 입력하여 입력된 함수에 입력된 시간만큼 디바운싱 되도록 했고 handleNext와 handlePrev함수에 씌워주어 해결하였다.
const handleDebouncedNext = debounce(() => {
if (currentStep < refs.length) {
isNavigating.current = true;
setCurrentStep((prev) => prev + 1);
}
}, 300);
const handleDebouncedPrevious = debounce(() => {
if (currentStep > 1) {
isNavigating.current = true;
setCurrentStep((prev) => prev - 1);
}
}, 300);
delay 시간을 300ms로 준 이유는 직접 버튼을 연속으로 눌러보고 최대한 의도하지 않은 동작이 나오지 않는 최소 시간을 300ms라 생각했고 또한 스크롤이 될 때 수동 전환을 관리하는 시간 또한 300ms였기 때문에 300ms로 정하게 되었다.
observer를 사용한 스크롤 이벤트 동작을 관리하는 것을 이번 기회에 처음 해보았는데 아직 이 동작을 구현한 코드가 완전히 이해하기 힘든 부분이 있다. 디바운싱까지는 머리에서 떠올랐으나 스크롤과 버튼을 통한 단계 조절 충돌은 해결하기가 정말 어려웠다. 이 부분이 우리 기능의 핵심이었기 때문에 더 좋은 방법이 있을 때마다 더 나은 방식으로 바꿀 것이다. 또한 내부 콘텐츠의 순서를 바꾸는 것이 더 기획이 되어있기 때문에 이 과정에서 더 변경될 것이다. 이 부분은 어떻게 해야 할지 빠른 시일 내에 고민해서 구현해야겠다고 생각했다.