본문 바로가기
source-code/FrontEnd

JS 종료 시점 제어되는 ScrollIntoView 구현하기

by mattew4483 2023. 12. 1.
728x90
반응형

요구 사항은 다음과 같다.

1. xPath를 통해 target HTMLElement를 조회한다.
2. target element가 존재할 경우, 해당 요소 옆에 툴팁을 띄운다.
3. 툴팁 클릭 시 다음 툴팁으로 이동한다. (1 반복)
3-1. 다음 target element가 스크롤로 인해 화면에 보이지 않을 경우, 해당 요소가 가운데 오게끔 자동으로 스크롤한다.
3-2. 이때 스크롤이 진행되는 동안 이전 툴팁은 보이지 않고, 스크롤 이동 종료 후 다음 툴팁이 뜬다.

 

3-1, 3-2에 대한 기능을 구현해 볼 예정!

최종적으로 구현한 결과물

일반적인 자동 스크롤

대부분의 자동 스크롤 로직은

1) 이벤트 발생 시 최하단으로 자동 스크롤 (ex 채팅방)

2) 특정 요소가 화면 상에 계속해서 보이도록 자동 스크롤 (ex 스크롤 포커싱)

둘 중 하나인 경우가 많다!

 

1)과 같은 경우는

function scroll() {
  const container = document.querySelector('#container');
  if (container) {
    // ... 내부 높이 변경 이벤트 처리 (ex 메시지 수신)

    // scrollHeight만큼 스크롤을 발생시켜, 메시지 목록의 최하단까지 스크롤이 발생
    container.scrollTop = container.scrollHeight;
  }
}

위와 같이 container의 높이가 변한 후, 해당 요소의 scrollHeight만큼 scrollTop을 변경함으로써

자동적으로 최하단에 스크롤이 위치하게 만들면 된다.

 

2)와 같은 경우는 container가 아닌, 보여야 하는 element가 특정되므로

https://developer.mozilla.org/ko/docs/Web/API/Element/scrollIntoView

 

element.scrollIntoView - Web API | MDN

Element (en-US) 인터페이스의 scrollIntoView() 메소드는 scrollIntoView()가 호출 된 요소가 사용자에게 표시되도록 요소의 상위 컨테이너를 스크롤합니다.

developer.mozilla.org

function scroll() {
  const target = document.querySelector('#target');
  if (target) {
    target.scrollIntoView({ behavior: 'smooth' });
  }
}

위와 같이 scrollIntoView 메서드를 사용해,

해당 요소가 사용자에게 보일 수 있도록 상위 요소를 스크롤하면 된다.

 

문제 상황

사실 우리의 요구 사항도 2)와 비슷한 맥락이다.

xPath를 통해 taget element를 조회한 후,

해당 요소가 화면에 보이지 않을 경우 → tagetElement.scrollIntoView()를 실행하면 될테니까!

(실제로도 훌륭하게 동작한다)

 

하지만 문제는...

3-2), 즉 스크롤이 진행되는 동안에는 이전 툴팁이 사라지고, 스크롤이 완료된 후 다음 툴팁이 떠야 한다는 것!

function scroll() {
  const target = document.querySelector('#target');
  if (target) {
    deletePrevTooltip(); // 이전 툴팁 삭제
    
    target.scrollIntoView({ behavior: 'smooth' }); // target이 화면에 보이도록 자동 스크롤
    
    renderNextTooltip(); // 새로운 툴팁 렌더링
  }
}

이렇게 하면?

scrollIntoView를 통한 스크롤 진행 상황과 무관하게, 새로운 툴팁을 렌더링 하는 함수가 동작하므로...

스크롤이 진행되고 있는 시점에도 다음 툴팁이 화면에 보여버리고 만다! 

 

scrollIntoView 직접 구현하기

이를 해결하기 위해...

scrollIntoView처럼 동작은 하되, 스크롤이 완료된 후 true를 resolve 하는 promise 반환 함수를 구현하기로 했다!

async function scroll() {
  const target = document.querySelector('#target');
  if (target) {
    deletePrevTooltip(); // 이전 툴팁 삭제

    await customScrollIntoView(target); // 스크롤이 완료될 때까지 대기

    renderNextTooltip(); // 새로운 툴팁 렌더링
  }
}

function customScrollIntoView(target: HtmlElement): Promise<boolean> {
  // 화면에 보일 요소를 입력받아
  // 상위 요소에서 스크롤을 발생시켜, 해당 요소가 보이게 하고
  // 스크롤이 종료된 후 true를 반환하는 비동기 함수
}

위와 같이 사용해 주면 

이전 툴팁 삭제 → 스크롤 → 스크롤 종료 → 새로운 툴팁 렌더링 이 자연스레 동작할 테다.

 

1. 스크롤 발생시킬 상위 요소 찾기

현재 우리는 사용자가 봐야 하는 target element을 알고 있다.

const target = document.querySelector('#target');

scrollIntoView처럼 해당 요소가 보일 때까지 상위 요소를 스크롤하기 위해서는

1) 스크롤을 발생시킬 상위 요소를 찾고

2) 해당 요소의 scrollTop을 target이 보일 위치만큼(화면의 세로 가운데로 할 예정) 변경해 주면 될 테다.

 

function customScrollIntoView(target: HtmlElement): Promise<boolean> {
    // 부모 중에서 스크롤을 가진 요소를 찾음
    let scrollParent: HTMLElement | null = target;

    while (scrollParent) {
      if (scrollParent.scrollHeight > scrollParent.clientHeight) {
        // 스크롤이 있는 경우
        break;
      }
      // 부모 요소로 이동
      scrollParent = scrollParent.parentElement;
    }

    if (scrollParent) {
      // 스크롤이 있는 부모 요소를 찾았을 때
      // 사용자가 보고 있는 화면의 세로 가운데로 스크롤 이동
      const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
      const desiredScrollPosition = target.offsetTop - viewportHeight / 2;

      scrollParent.scrollTop = desiredScrollPosition;
    } else {
      console.error('No scrollable parent found');
    }
  }

1) 스크롤을 발생시킬 상위 요소를 찾기 위해

while문을 돌며 target element부터 parentElement를 거슬러 올라가며, 스크롤이 발생하는 요소를 찾아줬다.

 

scrollParent가 존재할 경우 → 해당 HTMLElement의 scrollTop을 변경해 주면 된다!

(위 경우 화면의 세로 가운데에 위치하도록 작성해 줌)

 

2. 애니메이션 적용하기

위 로직을 통해 target element를 사용자가 볼 수 있도록, 상위 요소에 대한 스크롤을 변경할 수 있다.

하지만 이 경우 마치 화면이 깜빡인다고 느껴질 정도로 부자연스럽게 화면이 이동하고 만다.

우리가 원하는 건, scrollIntoView({ behavior: 'smooth' }) 처럼 부드러운 스크롤 이동이 발생하는 것.

 

https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior

 

scroll-behavior - CSS: Cascading Style Sheets | MDN

The scroll-behavior CSS property sets the behavior for a scrolling box when scrolling is triggered by the navigation or CSSOM scrolling APIs.

developer.mozilla.org

물론 해당 효과의 css 속성이 존재한다.

하지만 무턱대고 scrollParent의 style 속성을 건드릴 수는 없으므로... (툴팁이 보인 이후에는 위 속성이 없어야 할지도 모르니)

스르륵 스크롤이 발생하는 애니메이션 효과를 직접 적용해 줄 예정!

function customScrollIntoView(target: HtmlElement) {
  // 부모 중에서 스크롤을 가진 요소를 찾음
  let scrollParent: HTMLElement | null = target;

  while (scrollParent) {
    if (scrollParent.scrollHeight > scrollParent.clientHeight) {
      // 스크롤이 있는 경우
      break;
    }
    // 부모 요소로 이동
    scrollParent = scrollParent.parentElement;
  }

  if (scrollParent) {
    const viewportHeight =
      window.innerHeight || document.documentElement.clientHeight;
    const desiredScrollPosition = target.offsetTop - viewportHeight / 2;

    // 스르륵 스크롤 되는 효과를 위한 애니매이션
    const duration = 500; // 애니메이션 지속 시간 (밀리초)

    const startTime = performance.now();
    const startScrollPosition = scrollParent.scrollTop;

    function animateScroll(currentTime: number) {
      if (scrollParent) {
        const elapsed = currentTime - startTime;
        const progress = Math.min(elapsed / duration, 1);
        const easedProgress = easeInOutQuad(progress);

        const newScrollPosition =
          startScrollPosition +
          (desiredScrollPosition - startScrollPosition) * easedProgress;

        scrollParent.scrollTop = newScrollPosition;

        if (progress < 1) {
          requestAnimationFrame(animateScroll);
        }
      }
    }

    function easeInOutQuad(t: number) {
      return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
    }

    requestAnimationFrame(animateScroll);
  } else {
    console.error('No scrollable parent found');
  }
}

 

https://developer.mozilla.org/ko/docs/Web/API/window/requestAnimationFrame

 

Window: requestAnimationFrame() method - Web API | MDN

화면에 애니메이션을 업데이트할 준비가 될 때마다 이 메서드를 호출해야 합니다. 이는 브라우저가 다음 리페인트를 수행하기 전에 애니메이션 함수를 호출하도록 요청합니다. 콜백의 수는 보

developer.mozilla.org

requestAnimationFrame을 사용해 브라우저에서 애니메이션을 구현할 수 있다.

 

3. 종료 시 resolve 하기

우리가 최종적으로 원하는 모습은...

async function scroll() {
  const target = document.querySelector('#target');
  if (target) {
    deletePrevTooltip(); // 이전 툴팁 삭제

    await customScrollIntoView(target); // 스크롤이 완료될 때까지 대기

    renderNextTooltip(); // 새로운 툴팁 렌더링
  }
}

 

위와 같이 customScrollIntoView가 promise를 반환해

사용처에서 스크롤 완료 시점을 비동기로 제어하는 것!

 

// 스크롤 완료 시 resolve되는 promise를 반환
function customScrollIntoView(target: HTMLElement): Promise<boolean> {
  return new Promise((resolve, reject) => {
    // 부모 중에서 스크롤을 가진 요소를 찾음
    let scrollParent: HTMLElement | null = target;

    /**
     * @todo 예외 케이스를 고려해, 최상단 부모 Node까지 탐색한 후 맨 마지막 스크롤 요소를 사용하는게 정확함
     */
    while (scrollParent) {
      if (scrollParent.scrollHeight > scrollParent.clientHeight) {
        // 스크롤이 있는 경우
        break;
      }

      // 부모 요소로 이동
      scrollParent = scrollParent.parentElement;
    }

    if (scrollParent) {
      // 스크롤이 있는 부모 요소를 찾았을 때
      // 사용자가 보고 있는 화면의 세로 가운데로 스크롤 이동
      const viewportHeight =
        window.innerHeight || document.documentElement.clientHeight;
      const desiredScrollPosition = target.offsetTop - viewportHeight / 2;

      // 스르륵 스크롤 되는 효과를 위한 애니매이션

      const duration = 500; // 애니메이션 지속 시간 (밀리초)

      const startTime = performance.now();
      const startScrollPosition = scrollParent.scrollTop;

      function animateScroll(currentTime: number) {
        if (scrollParent) {
          const elapsed = currentTime - startTime;
          const progress = Math.min(elapsed / duration, 1);
          const easedProgress = easeInOutQuad(progress);

          const newScrollPosition =
            startScrollPosition +
            (desiredScrollPosition - startScrollPosition) * easedProgress;

          scrollParent.scrollTop = newScrollPosition;

          if (progress < 1) {
            requestAnimationFrame(animateScroll);
          } else {
            // 애니메이션 발생 X => promise resolve
            resolve(true);
          }
        }
      }

      function easeInOutQuad(t: number) {
        return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
      }

      requestAnimationFrame(animateScroll);
    } else {
      reject(false);
      console.error('No scrollable parent found');
    }
  });
}

따라서 해당 함수가 Promise<boolean>을 반환하도록 하고, animateScroll 함수에서 애니메이션 종료 시 resolve 해줬다.

→ 스크롤이 시작되고 종료된 후의 동작을 사용처에서 제어해주면 된다!

728x90
반응형