React

React Floating Button 만들기 (with Intersection Observer)

Kir93 2024. 11. 9. 21:13
728x90
반응형

플로팅 버튼(Floation Button)은 사용자 경험을 높이기 위해 자주 사용되는 요소 중 하나입니다.

Intersection Observer를 활용해 Footer 영역에서 멈추는 플로팅 버튼을 구현해 보겠습니다.

1. 인피니티 스크롤 구현

인피니티 스크롤은 사용자가 스크롤을 내릴 때마다 새로운 콘텐츠를 불러오는 기능입니다.

간단한 예제로 구현해 보겠습니다.

// App.js
import React, { useState, useEffect } from 'react';

function App() {
  const [items, setItems] = useState(Array.from({ length: 20 }, (_, i) => i));

  const loadMore = () => {
    setItems((prevItems) => [
      ...prevItems,
      ...Array.from({ length: 20 }, (_, i) => prevItems.length + i),
    ]);
  };

  useEffect(() => {
    const handleScroll = () => {
      if (
        window.innerHeight + window.scrollY >= document.body.offsetHeight - 500
      ) {
        loadMore();
      }
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <div>
      {items.map((item) => (
        <div key={item} style={{ height: '100px', border: '1px solid #ccc' }}>
          Item {item + 1}
        </div>
      ))}
      {/* 나중에 Footer와 FloatingButton을 추가할 예정입니다 */}
    </div>
  );
}

export default App;
  • items 상태를 관리하여 리스트를 렌더링 합니다.
  • 스크롤 이벤트를 감지하여 사용자가 페이지 하단에 가까워지면 loadMore 함수를 호출합니다.

2. 플로팅 버튼 만들기

화면 오른쪽 하단에 위치하는 플로팅 버튼을 생성합니다.

// FloatingButton.js
import React from 'react';
import './FloatingButton.css';

function FloatingButton() {
  return <button className="floating-button" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>Top</button>;
}

export default FloatingButton;
/* FloatingButton.css */
.floating-button {
  position: fixed;
  right: 20px;
  /* 추가적인 스타일을 여기에 추가하세요 */
}

2.1 App.js에 플로팅 버튼을 추가합니다.

// App.js
import FloatingButton from './FloatingButton';
// 기존 코드 생략
return (
  <div>
    {/* 기존 코드 */}
    <FloatingButton />
  </div>
);

3. Intersection Observer로 버튼 제어하기

이제 버튼이 Footer 영역에 도달하면 멈추도록 구현하겠습니다.

3.1 Footer 컴포넌트 추가

// Footer.js
import React from 'react';

function Footer(props, ref) {
  return (
    <div ref={ref} style={{ height: '200px', backgroundColor: '#f1f1f1' }}>
      Footer
    </div>
  );
}

export default React.forwardRef(Footer);

3.2 App.js에서 Footer를 추가하고 ref를 전달합니다.

// App.js
import Footer from './Footer';
// 기존 코드 생략
import { useRef } from 'react';

function App() {
  // 기존 코드
  const footerRef = useRef(null);

  return (
    <div>
      {/* 기존 코드 */}
      <Footer ref={footerRef} />
      <FloatingButton footerRef={footerRef} />
    </div>
  );
}

3.3 FloatingButton 수정

Intersection Observer를 사용하여 Footer가 보이는지 감지하고, 버튼의 위치를 변경합니다.

// FloatingButton.js
import React, { useEffect, useState } from 'react';
import './FloatingButton.css';

function FloatingButton({ footerRef }) {
  const [isFooterVisible, setIsFooterVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsFooterVisible(entry.isIntersecting);
      },
      {
        root: null,
        threshold: 0,
      }
    );

    if (footerRef.current) {
      observer.observe(footerRef.current);
    }

    return () => {
      if (footerRef.current) {
        observer.unobserve(footerRef.current);
      }
    };
  }, [footerRef]);

  return (
    <button
      className="floating-button"
      style={{
        position: isFooterVisible ? 'absolute' : 'fixed',
        bottom: isFooterVisible ? '200px' : '20px',
      }}
    >
      Top
    </button>
  );
}

export default FloatingButton;
  • isFooterVisible 상태로 Footer의 가시성을 관리합니다.
  • Footer가 보이면 버튼의 position을 absolute로 변경하고, 그렇지 않으면 fixed로 설정합니다.

마지막으로 조금 더 최적화하는 과정을 끝으로 글을 마무리하겠습니다.

처음 인피니티 스크롤을 구현할 때 스크롤 이벤트를 사용하여 글을 더 불러오는 방식으로 구현했지만, 플로팅 버튼을 구현할 때와 동일하게 Intersection Observer를 활용해 최적화를 진행해 보겠습니다.

// App.js
import React, { useState, useEffect, useRef } from 'react';
import Footer from './Footer';
import FloatingButton from './FloatingButton';

function App() {
  const [items, setItems] = useState(
    Array.from({ length: 20 }, (_, i) => i)
  );

  const loadMore = () => {
    setItems((prevItems) => [
      ...prevItems,
      ...Array.from({ length: 20 }, (_, i) => prevItems.length + i),
    ]);
  };

  const observerRef = useRef(null);
  const sentinelRef = useRef(null);
  const footerRef = useRef(null);

  useEffect(() => {
    observerRef.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      {
        root: null,
        threshold: 0.1,
      }
    );

    if (sentinelRef.current) {
      observerRef.current.observe(sentinelRef.current);
    }
    
    if (items.length > 100) {
      observerRef.current.disconnect();
    }

    return () => {
      if (observerRef.current && sentinelRef.current) {
        observerRef.current.unobserve(sentinelRef.current);
      }
    };
  }, [sentinelRef.current]);

  return (
    <div>
      {items.map((item) => (
        <div key={item} style={{ height: '100px', border: '1px solid #ccc' }}>
          Item {item + 1}
        </div>
      ))}
      {/* 감시자 요소 */}
      <div ref={sentinelRef} style={{ height: '1px' }} />
      <Footer ref={footerRef} />
      <FloatingButton footerRef={footerRef} />
    </div>
  );
}

export default App;

 

  • IntersectionObserver를 생성하여 observerRef에 저장합니다.
  • sentinelRef가 뷰포트에 들어오면 entries[0].isIntersecting이 true가 되고, loadMore 함수를 호출합니다.
  • 리스트의 마지막에 위치한 <div>로, 높이를 최소화하여 스크롤에 영향이 없도록 합니다.
  • 이 요소가 뷰포트에 나타날 때마다 새로운 아이템을 로드합니다.
  • items의 길이가 100이 넘어가면 더 이상 데이터를 가지고 오지 않게 멈춥니다.
반응형