자주 묻는 질문 페이지 - 아코디언 UI 구현하기 (styled-components)

김세현·2023년 1월 11일
0

아코디언이란? (Accordian)
아코디언은 콘텐츠를 숨기거나 보여줄 때 적용할 수 있는 UI이며, 흔히 FAQ 페이지에서 많이 볼 수 있습니다.
(쉽게 말해, 접었다 폈다 할 수 있는 UI입니다.)
저 또한 개인 프로젝트에서 FAQ 페이지를 구성하면서 아코디언 UI를 적용했습니다.

사실, 부트스트랩이나 Tailwind 등의 라이브러리를 사용하면 쉽게 아코디언 UI를 적용할 수 있지만

이번 개인 프로젝트에서는 스타일과 관련된 라이브러리는 styled-components만 사용하고자 했습니다. (react-icons는 제외...)

따라서 이번에 직접 아코디언 UI를 구현해야 했고, 이를 통해 학습한 내용을 기록하고자 했습니다.

또한, 필요하신 분들에게도 도움이 되게끔 최대한 쉽게 해당 포스트를 작성했습니다.

참고 사항) 타입스크립트로 작성된 코드입니다.


구현

1단계 : 컴포넌트 생성

먼저, 빈 <Accordian/><AccordianList/> 컴포넌트를 생성합니다.

위의 사진에서 파란색 영역이 <AccordianList/> 컴포넌트를 나타내며 이 컴포넌트에서 여러 아코디언 아이템들(<Accordian/>)을 불러올 것입니다.

그리고 빨간색 영역 하나하나가 <Accordian/> 컴포넌트이며, 이 컴포넌트에 아코디언 핵심 코드가 담길 것입니다.

참고)
1. 위의 사진에서 빨간색 영역은 아코디언이 열렸을 때의 영역까지 포함합니다.
2. 아코디언의 핵심 코드가 담기는 컴포넌트는 <Accordian/> 컴포넌트입니다.
3. 하나의 아코디언 UI만 필요하시면, <Accordian/> 컴포넌트 코드만 참고하셔도 무방합니다.

2단계 : 아코디언이 닫혀 있을 때

아코디언 UI를 디자인하기 위해서는 어려울 것 없이 단순하게 접근하면 됩니다.

먼저, 아코디언은 어떻게 보여지나요??

위에서 보이는 것처럼 아코디언은 버튼이나 요소 자체를 누르면 그 안에 숨어있던 컨텐츠가 보여지거나 다시 사라집니다.

그렇다면 아코디언을 만들기 위해선 다음의 두 가지 상태에 대한 UI를 순서대로 만들어 나가면 됩니다.

  1. 아코디언이 닫혀 있을 때 (비활성)

  2. 아코디언이 열려 있을 때 (활성)

그렇다면, 2번은 전혀 고려하지 않은 채로 1번: 아코디언이 닫혀 있을 때만 생각해 보겠습니다.

아코디언이 닫혀있을 때는 어떻게 보여지나요?

아코디언이 닫혀 있을 때에는 그냥 div 요소 안에 텍스트와 버튼이 끝입니다.

그리고 이 영역을 앞으로 아코디언의 머리라고 하겠습니다.
(사라졌다 보이는 콘텐츠 영역을 아코디언의 몸통이라고 하겠습니다.)

그리고 아코디언 머리에는 텍스트와 버튼이 양쪽 끝에 위치하고 있습니다.

그렇다면, 대략적으로 아래와 같이 코드를 작성할 수 있습니다.


const Accordian = () => {
  return (
    <AccordianHeader>
      <h3>아코디언 머리</h3>
      <button>열기</button>
    </AccordianHeader>
  );
};

//styled-components
const AccordianHeader = styled.div`
	display:flex;
	justify-content:space-between;
	align-items:center;
	border:1px solid lightgray;
	...
`;
  • 핵심 속성에 대한 설명
    display:flex : 텍스트와 버튼의 위치를 결정하기 위해 flex box를 사용했습니다.
    justify-content:space-between : 요소들을 아코디언 머리의 양쪽 끝에 위치시킵니다.

그리고 각자 상황에 맞게 요소의 세부 스타일을 설정해 주시면 됩니다.

저는 다음과 같이 디자인을 했습니다.

이제, 아코디언의 머리는 완성 되었습니다.

3단계 : 아코디언이 열려있을 때

위에서 아코디언은 아코디언이 열려있을 때의 영역까지 포함한다고 했습니다.

그리고 현재 아코디언의 머리는 완성되었고, 아코디언의 몸통을 디자인해야 합니다.

그렇다면 먼저, 아코디언의 머리와 아코디언의 몸통을 감싸고 있는 컨테이너가 필요합니다.

따라서 아코디언의 몸통을 만들기 전에, 아코디언의 컨테이너를 디자인해주면 됩니다.

(아코디언의 머리를 만들기 이전에, 처음부터 컨테이너를 만들고 시작해도 상관없습니다.)

아코디언 컨테이너는 다음과 같이 디자인해주면 됩니다.


const Accordian = () => {
  return (
    <AccordianContainer> // 아코디언 컨테이너
      <AccordianHeader>  // 아코디언 머리
        <h3>아코디언 머리</h3>
        <button>열기</button>
      </AccordianHeader>
      <AccordianBody>아코디언 몸통</AccordianBody> //아코디언 몸통
    </AccordianContainer>
  );
};

export default Accordian;

//styled-components
const AccordianContainer = styled.div`
  display: flex;
  flex-direction: column;
`;

const AccordianBody = styled.div`
	...
`;

그리고 아코디언 컨테이너는 아코디언의 머리와 아코디언의 몸통을 감싸고 있습니다.

  • <AccordianContainer/>의 주요 속성

<AccordianContainer/> 안에서 아코디언 머리와 몸통은 세로 방향으로 정렬되어 있습니다.

따라서 flex box로 설정해 준 뒤 정렬 방향을 column으로 설정해 주었습니다.

(또한, 각자 상황에 맞게 세부 스타일을 설정해 주시면 됩니다.)

현재까지 디자인을 기준으로 다음과 같이 보여질 것입니다.

먼저, 눈에 보이기 좋도록 디자인을 조금 수정하겠습니다.

  1. border 설정
  2. 아코디언 몸통 크기 조정
  3. 아코디언 몸통 배경 색 설정
const Accordian = () => {
  return (
    <AccordianContainer>
      <AccordianHeader>
        <h3>아코디언 머리</h3>
        <button>열기</button>
      </AccordianHeader>
      <AccordianBody>아코디언 몸통</AccordianBody>
    </AccordianContainer>
  );
};

export default Accordian;

const AccordianContainer = styled.div`
  display: flex;
  flex-direction: column;
  width: 70%; // width는 상황에 맞게 설정하시면 됩니다.
`;

const AccordianBody = styled.div`
  background-color: lightgray;
  height: 15vh; // height는 상황에 맞게 설정하시면 됩니다.
`;

const AccordianHeader = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-between;
  border: 1px solid lightgray;
`;

그리고 대략 다음과 같은 디자인이 나왔습니다.

지금까지 아코디언 컨테이너, 아코디언 머리, 아코디언 몸통을 디자인했습니다.

그리고 다음 단계로는 아코디언의 몸통을 어떻게 숨기고 보여줄 것인지에 대해 생각해 보겠습니다.

4단계 : 아코디언의 몸통을 숨기기

처음 사용자에게 아코디언이 보여질 때에는 아코디언이 닫혀 있는 상태입니다. (비활성 상태)

즉, 아코디언의 머리만 보이는 형태입니다.

하지만, "아코디언의 머리만 보인다"로 접근하기보다는 "아코디언의 몸통이 숨겨져 있다"라고 생각해서

접근하는 것이 맞습니다.

그렇다면 아코디언의 몸통을 어떻게 숨길 수 있을까요?

이를 위해, 아코디언의 몸통을 감싸고 있어야 하는 부모 요소(컴포넌트)가 하나 필요합니다.

저는 <AccordianBodyWrapper/>라고 이름 짓겠습니다.

		...
    <AccordianContainer>
      <AccordianHeader>
        <h3>아코디언 머리</h3>
        <button>열기</button>
      </AccordianHeader>
      <AccordianBodyWrapper> //아코디언 몸통을 감싸는 Wrapper 추가
        <AccordianBody>아코디언 몸통</AccordianBody>
      </AccordianBodyWrapper>
    </AccordianContainer>
		...

그리고 이 <AccordianBodyWrapper/>height 속성을 0으로 설정해 준 뒤, overflow:hidden을 적용해 줍니다.

const AccordianBodyWrapper = styled.div`
  height: 0;
  overflow: hidden;
`;

그렇다면, 아코디언 몸통의 부모 요소의 css가 height:0이고, overflow:hidden이기 때문에

아코디언의 몸통은 숨겨지게 됩니다.

(구체적인 설명 : 아코디언 몸통의 부모 요소의 height0 이어도, 아코디언 몸통의 컨텐츠 높이는 0보다 크기 때문에 넘쳐서 보여지게 됩니다. 따라서 height 속성만 설정한다면 의미가 없고,
넘쳐서 보여지는 부분을 숨기기 위해 overflow:hidden 속성이 필요합니다.)

따라서 이제는 다음과 같이 보여질 것입니다. (아코디언의 몸통이 숨겨져있음)

5단계 : 아코디언의 몸통을 보여주기

이제 다시, 아코디언을 클릭하는 이벤트가 발생하면 아코디언의 몸통을 보여줘야 합니다.

이전에 아코디언의 몸통을 숨기기 위해 부모 요소의 height 속성을 0으로 설정했기 때문에

다시 아코디언의 몸통을 보여주기 위해선 height 속성을 이용하면 됩니다.

이때 아코디언의 몸통을(height) 컨트롤하기 위해 ref 객체를 이용할 것입니다.

그리고 두 개의 ref 객체를 <AccordianBodyWrapper/><AccordianBody/>에 각각 매핑하여 다음과 같이 사용할 것입니다.

  1. <AccordianBodyWrapper/>높이를 설정하기 위한 ref 객체
  2. <AccordianBody/>높이를 계산하기 위한 ref 객체

그리고 아코디언의 활성 상태와 비활성 상태를 체크하기 위해 상태 변수 isOpen를 만들고 초기 상태를 false로 설정하겠습니다.

지금까지의 코드는 다음과 같습니다.

const Accordian = () => {
  const [isOpen, setIsOpen] = useState(false);
  const parentRef = useRef<HTMLDivElement>(null);
  const childRef = useRef<HTMLDivElement>(null);

  return (
    <AccordianContainer>
      <AccordianHeader>
        <h3>아코디언 머리</h3>
        <button>열기</button> ...
      </AccordianHeader>
      <AccordianBodyWrapper ref={parentRef}>
        <AccordianBody ref={childRef}>아코디언 몸통</AccordianBody>
      </AccordianBodyWrapper>
    </AccordianContainer>
  );
};

그리고 아코디언의 머리를 클릭할 때마다 아코디언의 상태가 변화(열림, 닫힘)하므로 아코디언의 머리 쪽에 클릭 핸들러를 설정해 주겠습니다.

참고) 아코디언 머리의 오른쪽 부분에 열기, 닫기 버튼이 존재하지만 이 버튼에 핸들러를 연결할 경우 정확히 버튼을 눌렀을 때만 동작하므로 약간 불편합니다.
저는 아코디언 머리 영역 전체를 클릭하는 경우 열리거나 닫히는 게 더 편하다고 생각했기 때문에 버튼이 아닌 <AccordianHeader/>에 이벤트 핸들러를 설정해 주었습니다.

코드는 다음과 같습니다.

  const [isOpen, setIsOpen] = useState(false);
  const parentRef = useRef<HTMLDivElement>(null);
  const childRef = useRef<HTMLDivElement>(null);

  const clickAccordianHandler = (
    event: React.MouseEvent<HTMLDivElement, MouseEvent>
  ) => {
    event.preventDefault();
  };

  return (
    <AccordianContainer>
      <AccordianHeader onClick={clickAccordianHandler}>
        <h3>아코디언 머리</h3>
        <button>열기</button>
      </AccordianHeader>
      <AccordianBodyWrapper ref={parentRef}>
        <AccordianBody ref={childRef}>아코디언 몸통</AccordianBody>
      </AccordianBodyWrapper>
    </AccordianContainer>
  );

이제 아코디언 클릭 시 구체적으로 어떻게 동작할지 코드를 작성해 주어야 합니다.

  1. 만약, <AccordianBodyWrapper/>height0 보다 크다면?
    -> 아코디언이 열려있는 상태이므로, 다시 height0으로 설정한다.
  1. 만약, <AccordianBodyWrapper/>height === 0 이라면?
    -> 아코디언이 닫혀있는 상태이므로, height를 컨텐츠의 높이만큼 설정해 준다.

이때, 요소의 높이를 계산하는 데 저는 clientHeight를 사용할 것이지만, offsetHeight도 상관없습니다.
(둘의 차이는 아래 추가 설명 부분에서 확인)

  const clickAccordianHandler = (
    event: React.MouseEvent<HTMLDivElement, MouseEvent>
  ) => {
    event.preventDefault();

    // 1번
    if (parentRef.current!.clientHeight > 0) {
      parentRef.current!.style.height = "0";
    } 
    //2번
    else {
      parentRef.current!.style.height=`${childRef.current!.clientHeight}px`;
    }
  };

마지막으로, 상태 변경 함수를 통해 활성화 상태를 변경하는 코드를 추가합니다.

  const clickAccordianHandler = (
    event: React.MouseEvent<HTMLDivElement, MouseEvent>
  ) => {
    event.preventDefault();
    
    //1번, 2번 코드
	... 

    //상태 변경
    setIsOpen((prevState) => !prevState);
  };

이제 아코디언의 모든 구현이 끝났습니다.

참고)
제 코드와 완전 똑같이 작성했다면, 문제없지만

각자 커스텀하게 스타일을 적용한 경우 (margin이나 padding, border)을 따로 적용하신 경우에는

예상과 다르게 아코디언의 몸통 안에 있는 컨텐츠가 잘려 보일 수 있습니다.

이때에는, <AccordianBody/> 부분의 paading을 적당히 설정해 주시면 됩니다.

(clientHeight은 높이를 계산할 때 marginborder를 고려하지 않기 때문입니다.)


최종 코드 및 결과 화면

const Accordian = () => {
  const [isOpen, setIsOpen] = useState(false);
  const parentRef = useRef<HTMLDivElement>(null);
  const childRef = useRef<HTMLDivElement>(null);

  const clickAccordianHandler = (
    event: React.MouseEvent<HTMLDivElement, MouseEvent>
  ) => {
    event.preventDefault();

    if (parentRef.current!.clientHeight > 0) {
      parentRef.current!.style.height = "0";
    } else {
      parentRef.current!.style.height = `${childRef.current!.clientHeight}px`;
    }
    //상태 변경
    setIsOpen((prevState) => !prevState);
  };

  return (
    <AccordianContainer>
      <AccordianHeader onClick={clickAccordianHandler}>
        <h3>아코디언 머리</h3>
        <button>{!isOpen ? "열기" : "닫기"}</button>
      </AccordianHeader>
      <AccordianBodyWrapper ref={parentRef}>
        <AccordianBody ref={childRef}>아코디언 몸통</AccordianBody>
      </AccordianBodyWrapper>
    </AccordianContainer>
  );
};

export default Accordian;

const AccordianContainer = styled.div`
  display: flex;
  flex-direction: column;
  width: 70%; // width는 상황에 맞게 설정하시면 됩니다.
`;

const AccordianHeader = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-between;
  border: 1px solid lightgray;
`;

const AccordianBodyWrapper = styled.div`
  height: 0;
  overflow: hidden;
`;

const AccordianBody = styled.div`
  background-color: lightgray;
  height: 15vh; // height는 상황에 맞게 설정하시면 됩니다.
`;
  • 최종 구현 화면

추가 설명

1. 애니메이션 추가

<AccordianBodyWrapper/> 스타일 코드에 transition을 다음과 같이 추가해 주시면 조금 더 부드러운 효과가 적용됩니다.

const AccordianBodyWrapper = styled.div`
  height: 0;
  overflow: hidden;
  transition: height 0.4s ease;
`;

2. clientHeightoffsetHeight

  • clientHeight : 요소의 높이를 계산하는데 padding까지만 포함한다.

  • offsetHeight : 요소의 높이를 계산하는데 border를 포함한다.

3. 아코디언 클릭 핸들러 코드의 두 가지 버전

clickAccordianHandler 함수의 코드 중 parentRef.current!.styled.height 중간에 !를 붙여줬습니다.

const clickAccordianHandler = (
    event: React.MouseEvent<HTMLDivElement, MouseEvent>
  ) => {
    event.preventDefault();

    if (parentRef.current!.clientHeight > 0) {
      parentRef.current!.style.height = "0";
    } else {
      parentRef.current!.style.height = `${childRef.current!.clientHeight}px`;
    }
    //상태 변경
    setIsOpen((prevState) => !prevState);
  };

만약, !가 없다면 타입스크립트는 이 코드를 해석하는 시점에서 current 속성을 가진 parentRef 객체가 있다는 것을 확인할 수 없기 때문에 에러가 발생합니다.

왜냐하면, 우리는 ref 객체를 요소에 매핑하는 코드를 이보다 아래인 return (...) 부분에서 작성하기 때문입니다.

return (
  				...
 <AccordianBodyWrapper ref={parentRef}>
        <AccordianBody ref={childRef}>아코디언 몸통</AccordianBody>
 </AccordianBodyWrapper>
 				...
)

따라서 개발자가 명시적으로 이 코드가 실행되는 시점에는 확실하게 current 속성을 가진 parentRef 객체가 있다는 것을 타입스크립트에게 알려주는 것입니다.

(왜냐하면, 클릭 핸들러가 호출되는 시점은 모든 요소가 화면에 렌더링 된 이후이기 때문에 이 시점에서는 확실히 current 속성을 가진 ref 객체가 있다는 것을 개발자는 알 수 있습니다.)

다른 방법으로는 바로 위에서 타입을 체크해 주면 됩니다. 이때 !는 사용하지 않아도 됩니다.

	// 매핑된 요소가 없을 경우
    if (parentRef.current === null || childRef.current === null) {
      return;
    }
    if (parentRef.current.clientHeight > 0) {
      parentRef.current.style.height = "0";
    } else {
      parentRef.current.style.height = `${childRef.current.clientHeight}px`;
    }
profile
under the hood

0개의 댓글