개발일지/Front-end

Web API Javascript IntersectionObserver

cotnmin 2023. 1. 9. 20:00

Web API Javascript IntersectionObserver 사용해 보기

여러 Web API 중 변경사항을 감지하는 Observer들이 존재합니다.
이 중 매우 유용하게 쓰일 수 있는 IntersectionObserver, MutationObserver 등이 있는데 이 중에서 IntersectionObserver를 사용해보고자 합니다.

IntersectionObserver 란?

IntersectionObserver는 Web API 중 하나로 Viewport나 정해진 영역과 다른 영역이 겹치는지, 얼마나 겹치는지 감지하는데 이용하는 API입니다.
대부분 Modern browser에서 간단하게 사용가능합니다.

 

해당 API의 존재를 알기 전에는 주로 addEventListener('scroll', callback); 을 이용해 스크롤 위치와 요소의 상대위치 및 크기를 고려해 직접 계산해야 하는 고생을 해야 했는데, 해당 API를 사용하면 더 쉽고 최적화된 성능으로 개발이 가능합니다.

IntersectionObserver 사용해 보기

IntersectionObserver는 Viewport와 등록된 요소가 겹치는지 감지하는 Observer입니다.
즉, 등록된 Element가 노출이 될 때, 노출이 해제될 때 callback으로 등록된 함수가 실행된다고 생각하시면 됩니다.

 

다양한 활용이 가능하지만 가장 많이 사용되는 방식인 scroll에 따른 상호작용을 해보겠습니다.

<div class="wrap">
    <div class="div">Element</div>
    <div class="div">Element2</div>
</div>
<style>
    .wrap {
        padding: 400px 0;
    }
    .wrap > div {
        width: 200px;
        height: 80vh;
        border: 1px solid #000;
        margin-bottom: 20px;
        padding: 20px;
    }
    .div:nth-of-type(1) {
        background-color: #fafafa;
    }
    .div:nth-of-type(2) {
        background-color: #eaeaea;
    }
</style>
<script>
const options = {};
const observer = new IntersectionObserver(console.log, options);
// └ callback에 console.log를 넣어 감지된 요소를 console에 보여주는 observer 생성
document.querySelectorAll('.wrap > div').forEach(element => observer.observe(element));
// └ .wrap > div 에 해당하는 element들을 감지하도록 등록
</script>

위와 같이 세팅하고 테스트해보면 화면에서 스크롤 시 div가 사라지고 나타날 때마다 IntersectionObserverEntry 객체가 배열에 담겨 console에 찍히는 것을 확인할 수 있습니다.
또 위에서는 options에 빈 객체를 입력했지만 여러 option으로 Observer를 설정할 수 있습니다.


IntersectionObserverEntryoptions에 대해서는 아래에서 자세히 보겠습니다.

IntersectionObserverEntry 알아보기

  1. boundingClientRect
    Element.getBoundingClientRect()를 통해 얻을 수 있는 DOMRectReadOnly객체를 가져옵니다.
  2. intersectionRect
    DOMRectReadOnly 객체를 가져오나 겹치는 영역에 대해서만 값을 표시합니다.
    (intersection 밖의 영역이면 모든 값이 0으로 표시되고 잘린다면 보이는 부분의 Height나 Width만 나타내줌)
    아래 이미지를 보시면 더 쉽게 이해 가능합니다.
  3. intersectionRatio
    intersection에 겹치는 영역의 비율입니다.
    조금이라도 겹치지 않으면 0, Viewport에 완전히 들어간다면 1로 0 ~ 1의 정수값을 가집니다.
  4. isIntersecting
    intersectionthreshold 이상의 영역이 겹치는지 여부입니다.
    observer를 생성할 때 option으로 threshold값을 명시해줄 수 있습니다. threshold의 기본 값은 0.0으로 0.0일 때는 1px이라도 영역이 겹치면 true가 됩니다. 만약 threshold값이 여러 개일 경우 최솟값을 기준으로 값을 보여줍니다.
  5. rootBounds
    감지영역의 DOMRectReadOnly를 가져옵니다.
    기본적으로 Viewport이나 observer를 생성할 때 입력한 options에 따라 달라질 수 있습니다.
  6. target
    관찰한 Element입니다.
    style을 조정하거나 텍스트를 수정하는 등으로 이용할 수 있습니다.
  7. time
    time origin으로부터 해당 감지가 발생한 시점까지의 시간차를 ms단위로 나타낸 값입니다.
    time origin는 일반적으로 페이지가 호출된 시점으로 생각하면 됩니다. (Window 객체가 생성된 시점)

Options 알아보기

  1. root
    root로 지정한 Element를 Viewport로 사용합니다.
    이때 document는 cross browsing 문제로 사용을 지양하는 것이 좋습니다. (Safari, Android Firefox 에서 사용 불가)
    기본값은 null로 기본 Viewport를 사용합니다.
  2. rootMargin
    입력된 값으로 Viewport를 확장합니다.
    css margin입력하는 것처럼 입력 가능하고 px이나 %를 사용해야 합니다.
    기본값은 '0px 0px 0px 0px'입니다.
  3. threshold
    0.0 이상, 1.0 이하의 숫자 단일 값이나 해당 값으로 이루어진 배열로 감지하는 Element의 원본크기와 Viewport에 보이는 영역 크기 비가 해당 값일 때 callback을 실행합니다.
    즉, 해당 값보다 작아질 때 커질 때 각각 감지하게 되며 값이 여러 개일 경우 (배열 안에 나열) 각각의 값에 도달할 때마다 callback이 실행됩니다.
    기본값은 [0.0]입니다.

IntersectionObserver 응용하기

위의 예시는 Scroll event로도 쉽게 구현이 가능한 예시였습니다.
하지만 Scroll event로는 영역이 얼마나 겹치는지 자세하게 계산이 필요한 경우 점점 복잡한 코드를 요구합니다.


아래 예제들을 통해 IntersectionObserver가 얼마나 간단하게 구현 가능한지 확인해 보겠습니다.

<div class="wrap">
    <section class="section1">
        <h2>title1</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section2">
        <h2>title2</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section3">
        <h2>title3</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
</div>
<style>
    body {
      margin: 0;
      }
    .wrap > section {
        width: 100%;
        height: 80vh;
        border: 1px solid #000;
        padding: 20px;
        box-sizing: border-box;
    }
    .wrap > section > * {
        position: relative;
        opacity: 0;
        top: 10px;
        transition: all 500ms ease;
    }
    .wrap > section.on > * {
        opacity: 1;
        top: 0;
    }
</style>
<script>
const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            entry.target.classList.add('on');
            // 영역 겹침이 50% 이상 발생하면 -> class 'on' 추가
        } else {
            entry.target.classList.remove('on');
            // 영역 겹침이 50% 미만으로 줄어들면 -> class 'on' 삭제
        }
    });
}, {threshold: [0.5]});
document.querySelectorAll('.wrap > section').forEach(element => observer.observe(element));
</script>

각 세션의 50% 영역이 화면에 노출될 때 하위 요소들이 fade-in 하면서 아래에서 위로 떠올라 자연스럽게 노출되는 것을 확인할 수 있습니다.


조금 더 복잡하게 조건을 주게 되면

<div id="pos"><!-- 화면 내 세션이 몇 번 세션인지 --></div>
<div class="wrap">
    <section class="section1" data-tab="1">
        <h2>title1</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section2" data-tab="2">
        <h2>title2</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section3" data-tab="3">
        <h2>title3</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
</div>
<style>
    body {
        margin: 0;
    }
    .wrap {
        padding: 60px 0 0;
    }
    .wrap > section {
        width: 100%;
        height: 80vh;
        border: 1px solid #000;
        padding: 20px;
        box-sizing: border-box;
    }
    .wrap > section > * {
        position: relative;
        opacity: 0;
        top: 10px;
        transition: all 500ms ease;
    }
    .wrap > section.on > * {
        opacity: 1;
        top: 0;
    }
    #pos {
        position: fixed;
        top: 0;
        z-index: 1;
        width: 100%;
        height: 60px;
        padding: 20px;
        background-color: #d9d9d9;
        border-bottom: 1px solid #222;
        box-sizing: border-box;
    }
</style>
<script>
const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
        if (entry.intersectionRatio >= 0.5) {
            entry.target.classList.add('on');
            // 영역 겹침이 50% 이상 발생하면 -> class 'on' 추가
        }
        if (entry.intersectionRatio <= 0.3) {
            entry.target.classList.remove('on');
            // 영역 겹침이 30% 이하로 줄어들면 -> class 'on' 삭제
        }
        if (entry.intersectionRatio >= 0.7) {
            document.getElementById('pos').innerText = `This is session ${entry.target.dataset.tab}`;
            // 영역 겹침이 70% 이상 발생하면 -> #pos의 텍스트 변경
        }
    });
}, {threshold: [0.3, 0.5, 0.7], rootMargin: '-60px 0px 0px'});
document.querySelectorAll('.wrap > section').forEach(element => observer.observe(element));
</script>

세션의 intersectionRatio가 0.5에 도달 시 자연스럽게 콘텐츠를 노출, 0.3미만으로 떨어질 시 컨텐츠 숨기기
현재 세션을 알려주는 부분을 만든 뒤 0.7에 도달 시 갱신시켜 주기
이런 식으로 좀 더 세세하게 변화를 적용시켜줄 수 있습니다.

 

좀 더 극단적으로 threshold를 늘려주면

<div class="wrap">
    <section class="section1" data-tab="1">
        <h2>title1  [<span class="ratio"></span>]</h2>
    </section>
    <section class="section2" data-tab="2">
        <h2>title2  [<span class="ratio"></span>]</h2>
    </section>
    <section class="section3" data-tab="3">
        <h2>title3  [<span class="ratio"></span>]</h2>
    </section>
</div>
<style>
    body {margin: 0;}
    .wrap > section {
        width: 100%;
        height: 80vh;
        border: 1px solid #000;
        padding: 20px;
        box-sizing: border-box;
    }
</style>
<script>
const threshold = [];
for (let i = 0; i < 1; i += 0.01) {
    threshold.push(i);
}
threshold.push(1);
// threshold를 0 에서 1까지 0.01 단위로 threshold 설정
// 정수로 1을 push 해준 이유는 float 0.01을 100번 더했을 때 1이 아니기 때문
const observer = new IntersectionObserver(entries => {
    entries.forEach(({target, intersectionRatio}) => {
        target.getElementsByClassName('ratio')[0].innerText = `${Math.round(intersectionRatio * 100)}%`;
        // 각 세션의 화면 노출 비율을 %단위로 표시
    });
}, {threshold});
document.querySelectorAll('.wrap > section').forEach(element => observer.observe(element));
</script>

위와 같은 사용도 가능합니다.


Web API 중 하나인 IntersectionObserver를 사용해 보았습니다.
Scroll event를 사용하는 것보다 성능면에서도 우수하고 코드도 직관적이며 짧습니다.
여러모로 반응형 인터페이스를 구성하는데 매우 효과적인 API입니다.

 

글에 오류가 있거나 추가할 내용이 있다면 언제라도 댓글을 남겨주세요!

 

다음에는 다른 Observer인 MutationObserver를 소개하는 글을 작성해 보겠습니다.

참고

mdn web docs - Intersection Observer API