원했던 기능

  1. 유저가 스크롤을 내릴 때도 상단에 고정되는 헤더를 만들고 싶었다.
  2. 원래 헤더는 투명으로 하고, 브라우저에서 스크롤을 내리고 있는 동안 헤더가 잘보이도록 헤더의 배경색을 하얀색으로 바꿔주고 싶었다.

 

Window.scrollY를 사용해서 스크롤되고 있는지를 감지

Window.scrollY - Web API | MDN

scrollY는 읽기 전용 속성으로, 문서가 수직으로 얼마나 스크롤됐는지 픽셀 단위로 반환한다.

updateScroll 함수를 만들어서 scrollY가 75 (=헤더의 높이) 이상이면 현재 스크롤되고 있는 것으로 판단해서 scrollFlag를 true로 한다. 여기서 scrollFlag는 useState를 활용한다.

 

HeaderContainer에 isScroll 변수명으로 props를 만들고 scrollFlag를 넣는다.

scrollFlag의 초기값은 false로 하고, 스크롤이 감지되면 true로 업데이트하고 리렌더링 한다.

 

scrollFlag가 false일 땐 배경색을 투명으로 하고, true일 땐 배경색을 흰색으로 하고 밑테두리 css를 추가했다.

또한 상태 전환 시 transition으로 끊김이 없어보이도록 변화를 부드럽게 만들었다.

const [scrollFlag, setScrollFlag] = useState(false);

 const updateScroll = () => {
    const { scrollY } = window;
    const scrolled = scrollY > 75;

    setScrollFlag(scrolled);
  };

<HeaderContainer isScroll={scrollFlag}></HeaderContainer>

const HeaderContainer = styled.header`
  position: fixed;
  width: 100%;
  height: 75px;
  background-color: transparent;
  color: #fff;
  z-index: 9999;
  display: flex;
  flex-direction: row;
  justify-content: space-around;
  align-items: center;

  transition: all 0.8s ease-out;
  ${(props) =>
    props.isScroll &&
    css`
      background-color: #ffffff;
      border-bottom: 1px solid rgba(0, 0, 0, 0.3);
    `};
`;

 

그래서 window.scrollY로 스크롤이 발생할 때마다 state를 업데이트하면 되겠다는 생각까진 도달했다.


 

하지만 scroll 이벤트는 유저가 스크롤을 조금만 움직여도 실행되기 때문에 scroll을 할 때마다 state를 변경하려는 생각은 잠시 접어두자… 해당 컴포넌트가 수십번 불필요하게 리렌더링되기 때문이다.

 

scroll 이벤트를 제어하기 위해서는 일정 시간 걸어두고 그 안에서 이벤트를 한 번만 실행되도록하는 throttle을 적용하는 게 좋겠다. 여러번 발생하는 이벤트를 일정 시간 동안 한 번만 실행시키는 게 적합해 보이기 때문이다.

 

throttle 만들기

const throttle = (callback, delay) => {
    let timer = null;
    return () => {
      if (timer) return;
      timer = setTimeout(() => {
        callback();
        timer = null;
      }, delay);
    };
};
  1. timer를 담을 변수를 정의
  2. 존재 여부에 따라 setTimeout 함수를 실행시킨다.
    1. timer가 없다면 setTimeout이 실행되지 않은 상태이므로, setTimeout 함수를 실행시키며 정해진 시간 후에 callback 함수를 실행시키고 timer를 다시 비워준다
    2. timer가 있다면 그대로 리턴한다.

 

src/components/Header.jsx

import { useEffect, useState } from "react";
import { Link as L, useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import { logo } from "assets/img";

function Header() {
  let { pathname } = useLocation();
  const [scrollFlag, setScrollFlag] = useState(false);

  const throttle = (callback, delay) => {
    let timer = null;
    return () => {
      if (!timer) {
        timer = setTimeout(() => {
          callback();
          timer = null;
        }, delay);
      }
    };
  };

  const updateScroll = () => {
    const { scrollY } = window;
    const scrolled = scrollY > 75;

    setScrollFlag(scrolled);
  };

  const handleScroll = throttle(updateScroll, 100);

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

  return (
    <HeaderContainer isScroll={scrollFlag}>
      <Link to="/">
        <img src={logo} alt="LOGO" />
      </Link>

      <nav>
        <NavContainer>
          <li>
            <Link to="/intro" open={pathname === "/intro" && true}>
              Intro
            </Link>
          </li>
          <li>
            <Link to="/crime" open={pathname === "/crime" && true}>
              금융 사기
            </Link>
          </li>
          <li>
            <Link to="/prevent" open={pathname === "/prevent" && true}>
              범죄 예방
            </Link>
          </li>
        </NavContainer>
      </nav>
    </HeaderContainer>
  );
}

export default Header;

 

 

https://velog.io/@dami/ReactThrottle%EB%A1%9C-Sticky-Header-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

리액트 프로젝트는 기본적으로 index.js에서 렌더링한다.

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <Router>
      **<App />**
    </Router>
  </React.StrictMode>,
);

reactDOM 라이브러리가 제공하는 reactRoot 함수가 반환하는 render 함수를 이용하는데, createRoot 함수는 인자로 먼저 `document.getElementById(”root”)`를 받는다.

여기서 id가 root인 HTML 요소는 `public/index.html`에 있다.

아래 html 파일이 개발서버를 구동했을 때 화면에 보이게 되는 html 파일이라고 볼 수 있다.

 

public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
  </head>
  <body>
    <div id="root"></div>
    **<div id="modal"></div>**
  </body>
</html>

root 페이지와 분리해서 만들기 위해 `id=”modal”`를 index.html에 만들어 뒀다.

 

 

 

CreatePortal 이란?

자식들을 DOM의 다른 부분으로 렌더링해주는 기능을 제공해준다.

createPortal – React

https://xionwcfm.tistory.com/316#google_vignette

 

src/components/Modal/ModalContainer.jsx

import { createPortal } from "react-dom";

function ModalContainer({ children }) {
  return createPortal(<>{children}</>, document.getElementById("modal"));
}

export default ModalContainer;

`createPortal(children, domNode, key?)`

  • 첫번째 인자: domNode의 children이 될 컴포넌트 혹은 jsx가 들어간다
  • 두번째 인자: children의 부모가 될 domNode를 넣어준다. index.html에 `<div id="modal"></div>`를 미리 만들어 뒀다.
  • 세번째 인자: 옵션인 키. 포털의 키로 고유한 문자열이나 숫자가 들어간다.
  • JSX에 포함될 수 있는 것이나 리액트 컴포넌트로 반환될 수 있는 React 노드를 반환한다.
  • 그래서 리액트가 렌더링 output에서 이를 발견하면 domNode안의 children을 출력한다!

 

src/components/Modal/index.jsx

import { useEffect } from "react";
import { useRef } from "react";
import styled from "styled-components";
import useOutsideClick from "hooks/useOutSideClick";
import ModalContainer from "./ModalContainer";

function Modal({ onClose, children }) {
  const modalRef = useRef(null);

  const handleClose = () => {
    onClose?.();
  };

  return (
    <ModalContainer>
      <Overlay>
        <ModalWrap ref={modalRef}>
          <Contents>{children}</Contents>
        </ModalWrap>
      </Overlay>
    </ModalContainer>
  );
}

export default Modal;

 

모달 닫고 끄는 로직은 useState를 이용!

state가 true일 땐 모달을 보여주고, false일 땐 모달을 보여주지 않는다.

 

HelpIcon눈에 띄는 색깔로 모달을 띄워 줄 글자(LRAT)를 강조하고, 사용자가 글자를 클릭하면 모달이 열린다.

→ LratModal.jsx가 랜딩된다

 

src/pages/Intro/MethodArea.jsx

import LratModal from "./LratModal";

function MethodArea() {
  const [isOpen, setIsOpen] = useState(false);

  const onOpen = () => {
    setIsOpen(true);
  };

  const onClose = () => {
    setIsOpen(false);
  };

  return (
    <Container>
      <SubTitle content="데이터 분석 방법" />

      <SubDesc>
        <span onClick={onOpen}>
          <HelpIcon /> LRAT (Lifestyle and Routine Activity Theories)
        </span>
        의 세 가지 요소를 개념화(operationalization)했습니다.
      </SubDesc>

      ...
      {isOpen && <LratModal onClose={onClose} />}
    </Container>
  );
}

export default MethodArea;

 

 

components 폴더에 만들어 둔 Modal을 import해서 모달 내용을 채운다.

‘확인’ 버튼이나 ‘X’ 아이콘을 클릭하면 모달이 닫힌다.

 

src/pages/Intro/LratModal.jsx

import styled from "styled-components";
import { Modal } from "components";
import { modalTree } from "assets/img";
import { MdClose } from "react-icons/md";

function LratModal({ onClose }) {
  return (
    <Modal onClose={onClose}>
      <CloseBtn onClick={onClose} />

      <Content>
        ...
      </Content>

      <Button onClick={onClose}>
        <p align="center">확인</p>
      </Button>
    </Modal>
  );
}

export default LratModal;

모달 도입 이유?

  • 페이지 상 중요 내용은 아니었다. (LRAT 이론을 사용하게 된 배경)
  • 그러나 UX를 생각했을 때, 사용자가 이해하기 어려울 수 있을 것 같았다.
  • 정보가 궁금한 사용자는 글자를 클릭함으로써 → 정보를 보기 쉽게 정리해서 보여주고 → ‘확인’이나 ‘X’ 버튼을 누르면 다시 메인 페이지로 가는 플로우로 짜면 되겠다.
  • 그러면 Modal 을 도입하면 되겠지!

 

모달의 역할?

  • FE 포지션 친구와 얘기하면서 친구가 보통 모달은 알림창이나 입력창으로만 봐서 추가정보도 띄워주는 것인지? 의문을 제기해줬다.
  • 그래서 의문을 해결하기 위해 모달 컴포넌트는 왜 사용하는지, 역할이나 목적은 어떤 게 있는지 검색해봤다.

Modal과 Popup의 차이를 먼저 알아봤다.

두 가지 모두 사용자에게 정보를 보여주거나 상호작용을 유도하는 데 사용된다.

  • Popup은 친구가 보통 봐왔던 광고나 알림 메시지 등 짧은 정보를 표시하는 데 주로 사용된다.
  • Modal은 팝업과 비슷하게 사용자에게 정보를 보여주거나 추가 작업을 유도하는 데 사용하지만, 뷰포트 안에 콘텐츠를 중앙에 위치시켜 모달 외부의 요소에 대한 상호작용이 일시적으로 막히는 특징이 있다. 그래서 추가적인 정보를 제공, 정보를 입력받는 양식으로 주로 사용되며 사용자의 주의를 집중시키는 힘이 팝업보다 강하다.

정리하면,

Popup은 현재 창을 벗어난 새 창으로 나타나지만, Modal은 현재 창인 뷰포트 안에 나타난다.

참고: https://brunch.co.kr/@minakoro/156, https://www.coneboy.kr/54/?idx=18153167&bmode=view

 

검색한 결과를 토대로 친구와 다시 얘기해본 결과,

  • 현재 띄워줄 추가 정보는 Popup이 아니라 Modal로 만들면 알맞을 것 같다는 의견으로 일치되었다.
  • Front-end 개발자는 사용자가 보기 쉽게 화면을 구성하는 것도 중요하지만, 사용하려는 컴포넌트가 내가 의도하는 역할과 상응하는지 검토하는 작업도 필요하다는 것을 깨달았다.

 

모달 사용 시 신경 쓴 점

  • 필요한 경우에만 사용
    • 사용자의 결정으로 필요할 때만 띄워주기
  • 간결하고 명확하게 집중할 수 있게 함
    • 모달 표시 내용은 한 눈에 이해할 수 있도록 디자인하기
    • 모달을 띄웠을 땐 메인 페이지의 스크롤은 막아놓기
  • 쉽게 닫을 수 있어야 함
    • 사용자가 쉽게 닫을 수 있도록 ‘닫기’나 ‘확인’버튼을 명확하게 하기
    • 모달 바깥 배경을 클릭해서 모달을 닫는 옵션도 제공하기

 

개발과정

src/components/Modal

→ 모달은 한 번 만들어 두면 나중에 또 쓰일 것 같아서 공통 컴포넌트 폴더에 생성했다.

 

React에서 기본적으로 제공해주는 createPortal api를 사용

모달을 띄웠을 땐 메인 페이지 body의 스크롤은 막아놓기

모달 바깥 배경을 클릭해서 모달을 닫는 옵션 제공하기

 

CSS 의 ::before및 ::after 가상 요소를 사용하면 HTML에 있을 필요 없이 페이지에 콘텐츠를 삽입할 수 있다. 최종 결과는 실제로 DOM에 없지만 페이지에는 마치 DOM에 있는 것처럼 나타난다.

왜 사용하나?

  • 생성된 콘텐츠가 위치적으로 요소 콘텐츠 앞에 오기를 원한다.
  • 콘텐츠 ::after는 소스 순서에서도 "이후"이므로 자연스럽게 서로 쌓이면 ::before 위에 배치됩니다.

콘텐츠는 여전히 적용된 요소 내에 있다. 

https://css-tricks.com/almanac/selectors/a/after-and-before/

 

 

z-index: -1

CSS ::before나 ::after에 `z-index: -1` 을 준다면, 가상요소의 컨텐츠가 요소의 컨텐츠보다 뒤에 위치하게 된다.

→ figure의 ::after에 `z-index:-1` 을 주지 않는다면 a태그가 뒤로 가려지기 때문에 범죄분석 페이지로 이동할 수 없게 된다.

 

그런데 ::before에서는 `z-index:-1`  없어도 가능하네?

어찌보면 당연한 .. !

-> before는 a 태그의 전에, after는 a 태그의 뒤에 위치하기 때문에 before는 이미 a태그보다 뒤에 위치해 있다.

 

  • 가상 요소의 컨텐츠가 표시될 때 가장 중요한 것은 선택한 요소의 태그 안에 가상요소가 위치해야 한다는 것이다.
  • 가상 요소도 실제 요소처럼 배치 속성을 부여해서 자유롭게 위치를 조절할 수 있다!

 

figure에 `position: relative`를 넣고 :before이나 :after에 `position:absolute`를 하면 figure를 기준으로 자유롭게 위치를 조절할 수 있다.

&:before, &:after {
    content: "";
    position: absolute;
    top: 0px;
    bottom: 0px;
    right: 0px;
    left: 0px;
	z-index: -1;
    border-radius: 50%;
    border: 3px solid #bbb;
  }

 

위처럼 top, bottom, right, left를 모두 0px로 하면, top(위), bottom(아래), right(오른쪽), left(왼쪽)으로부터 0px 떨어져 있도록 하는 것이기 때문에 아래와 같이 요소의 테두리를 감싸고 있는 형태가 된다. (흥미 돋았던 내용이었다)

+ Recent posts