Javascript Scroll Event와 Element 위치를 활용한 목차 하이라이팅 기능 구현하기

Yoochan·2024년 1월 20일
0
post-thumbnail

웹 UI, UX개발을 하다보면 scroll event 및 특정 Element의 위치는 거의 무조건 다루게 된다.

때문에 JavaScript에서 Scroll event를 이해하고, Element의 위치에 따라 특정 로직을 매끄럽게 수행하도록 하는 것은 웹 개발에서 중요한 부분 중 하나이다.

내가 구현하고자 하는 부분은 DOM을 활용하여 아래 GIF와 같이 스크롤 시에 해당 id가 나타나게 되면 목차에 하이라이팅을 하는 것이다.

우선 해당 기능을 구현하기 위해 알아야할 것이 어떤 정보들이 있을까?

  1. 수직 스크롤바 위치
  2. 목차에 해당하는 elements 요소들의 Y축 위치 값
    즉, 수직 스크롤바 위치 = (elements 요소들의 Y축 위치 값 - 80) 다음과 같은 조건으로 해당 기능을 구현하도록 로직을 구성했다.

Javascript에서는 scroll과 element의 위치와 관련된 속성과 메서드를 제공해준다.

요소의 수직 스크롤 바 위치는 💡 scrollTop 속성으로 확인할 수 있다.

우선 스크롤 높이를 확인하기 위해 해당 코드를 작성한 후, console에 입력해봤다.

window.addEventListener('scroll', () => {
  console.log(window.scrollTop)
})

그런데 스크롤을 해도 console.log가 출력되지 않았다. 무엇이 문제인지 찾아보니 scroll이 화면 전체가 아닌 특정 content 영역에 생성되어 있었다.

문제의 원인을 찾기 위해 스크롤 위치를 확인해보니, header를 제외한 content 영역에 스크롤이 걸려있었다.

내가 구현하고자 하는 페이지에서 .whitepaper_wrapper 클래스에 overflow: auto 가 설정되어 있었다. 즉, 해당 클래스에 event를 등록해야된다는 것을 알 수 있었다.

수정 후 아래와 같이 코드를 입력해서 console.log을 찍어보니 제대로 출력되는 것을 볼 수 있었다.

const contentLayout = document.querySelector(".whitepaper_wrapper");

contentLayout.addEventListener('scroll', () => {
  console.log(contentLayout.scrollTop)
})

스크롤 수직 위치의 값을 알았으니, 이제 목차에 해당하는 element들의 수직 높이를 알아야한다.

요소의 Y축 위치와 관련된 속성에는 clientTop, offsetTop, getBoundingClientRect().top 가 있다.

💡 clientTop 속성은 부모를 기준으로 Y축 위치 값을 가져온다.

유심히 비교해봐야할 것이 offsetTop, getBoundingClientRect().top 속성이다.

비교를 위해 다음 코드를 입력 후 결과를 확인해봤다.

const contentLayout = document.querySelector(".whitepaper_wrapper");

contentLayout.addEventListener("scroll", () => {
  const first_heading = document.querySelector("#채널톡과_첫_만남");

  console.log("offsetTop: ", first_heading.offsetTop);
  console.log(
    "getBoundingClientReact().top: ",
    first_heading.getBoundingClientRect().top
  );
});


💡 getBoundingClientRect().top은 viewport 즉, 보이는 화면을 기준으로 좌표값을 반환한다. 따라서 scroll 위치 등에 따라 값이 변하는 것이다. 동적으로 움직이는 좌표가 필요하다면 해당 속성을 사용해볼 수 있다.

💡 offsetTop은 부모 요소에게서 상대적인 top좌표를 반환한다. 중요한 것이 position이 relative인 부모 요소 만을 찾아서 기준으로 삼는다. 해당 코드에서는 부모 요소 중에 position이 relative인 요소가 없다. 따라서 최상위 dom을 기준으로한 좌표를 반환하며, 즉 절대좌표가 된다.

확인을 위해 구하고자하는 element 바로 한단계 위에 있는 부모 요소에 relative를 줘봤고, relative를 기준으로 한다는 것을 확인할 수 있었다.

const contentLayout = document.querySelector(".whitepaper_wrapper");

contentLayout.addEventListener("scroll", () => {
  const first_heading = document.querySelector("#채널톡과_첫_만남");
  const parentOfFirstHeading = first_heading.parentElement;

  parentOfFirstHeading.style.position = "relative";

  console.log("offsetTop: ", first_heading.offsetTop);
  console.log(
    "getBoundingClientReact().top: ",
    first_heading.getBoundingClientRect().top
  );
});

🚨 그렇다면 부모 요소에 relative가 있다면 절대좌표를 어떻게 구할 수 있을까?

const absoluteTop = scrollLayout.scrollTop + element.getBoundingClientRect().top;

여기서 scrollLayout.scrollTopscrollLayout 요소의 스크롤된 Y 좌표를 나타낸다. element.getBoundingClientRect().topelement 요소의 상대적인 Y 좌표를 나타내며, 두 값을 더해서 first_heading의 절대적인 Y 좌표를 얻는다.

이제 해당 개념을 적용시켜 기능을 구현해보자.

해당 html에서 목차들의 element에는 heading이라는 class가 지정이 되어 있었다. 그래서 heading class에 해당하는 모든 값들을 선택해서 그 요소들에 각각 로직을 작성하는 방식으로 코드를 작성했다.

const contentLayout = document.querySelector(".whitepaper_wrapper");
const targetClass = "heading";

contentLayout.addEventListener("scroll", () => {
  const indexSections = document.querySelectorAll(`.${targetClass}`);
  const scrollPosition = contentLayout.scrollTop;

  indexSections.forEach((section) => {
    const sectionId = section.getAttribute("id");
    const targetElement = document.querySelector(`[href="#${sectionId}"]`);
    const targetOffset = section.offsetTop;

    if (scrollPosition < targetOffset - 80) {
      targetElement.style.color = "";
    } else {
      targetElement.style.color = "#6119D1";
      targetElement.style.transition = "all 1s linear";
    }
  });
});

하지만 이렇게 작성했을 때, 다음 목차에 도달했을 때 기존에 하이라이트 된 목차가 지워지지 않는 버그가 발생했다.

해당 조건만 고려를 했기 때문에, 목차보다 80px 밑으로 내려가기만 하면 변경하도록 되어 있었다. 그래서 다른 목차를 만나도 기존 하이라이트를 지워주는 로직을 추가하도록 했다.

const contentLayout = document.querySelector(".whitepaper_wrapper");
const targetClass = "heading";
let highlightedElement = null;

contentLayout.addEventListener("scroll", () => {
  const indexSections = document.querySelectorAll(`.${targetClass}`);
  const scrollPosition = contentLayout.scrollTop;

  let currentElement = null;

  indexSections.forEach((section) => {
    const sectionId = section.getAttribute("id");
    const targetElement = document.querySelector(`[href="#${sectionId}"]`);
    const targetOffset = section.offsetTop;

    if (scrollPosition >= targetOffset - 80) {
      currentElement = targetElement;
    }
  });

  if (highlightedElement !== currentElement) {
    if (highlightedElement) {
      highlightedElement.style.color = "";
    }

    if (currentElement) {
      currentElement.style.color = "#6119D1";
      currentElement.style.transition = "all 1s linear";
    }

    highlightedElement = currentElement;
  }
});

highlightedElement와 currentElement를 이용해서, 이전에 하이라이팅 된 element와 하이라이팅 해야할 element를 비교 후 처리하는 로직을 추가한 것이다.

위 코드에서 if (highlightedElement), if (currentElement) 조건이 없으면 highlightedElement 혹은 currentElement가 없을 때도 접근하므로 Cannot read properties of null (reading ‘style’) 에러 발생했기 때문에 작성했다.

이렇게 작성해도 코드는 잘 동작한다. 하지만 해당 heading 클래스 부모 요소에 position: relative인 속성이 있다면 예외가 발생할 것이다. 따라서 위에서 언급한 요소의 절대좌표를 구하는 방식으로 이를 사전에 방지할 수 있다.

아래 코드가 최종본이다.

const contentLayout = document.querySelector(".whitepaper_wrapper");
const targetClass = "heading";
let highlightedElement = null;

contentLayout.addEventListener("scroll", () => {
  const indexSections = document.querySelectorAll(`.${targetClass}`);
  const scrollPosition = contentLayout.scrollTop;

  let currentElement = null;

  indexSections.forEach((section) => {
    const sectionId = section.getAttribute("id");
    const targetElement = document.querySelector(`[href="#${sectionId}"]`);
    const targetAbsoluteOffset =
      section.getBoundingClientRect().top + contentLayout.scrollTop;

    if (scrollPosition >= targetAbsoluteOffset - 80) {
      currentElement = targetElement;
    }
  });

  if (highlightedElement !== currentElement) {
    if (highlightedElement) {
      highlightedElement.style.color = "";
    }

    if (currentElement) {
      currentElement.style.color = "#6119D1";
      currentElement.style.transition = "all 1s linear";
    }

    highlightedElement = currentElement;
  }
});
profile
FrontEnd Developer

0개의 댓글