React에서 토글 만들기 (전체 토글 버튼, UI 개선)

미키오·2024년 9월 9일
0

Gapple

목록 보기
2/4
post-thumbnail

0. 들어가며

학부 시절, 교육 계획안의 ‘활동 내용’ 영역에서 소제목과 교사의 발문이 뒤섞여 유독 핵심 내용이 들어있는 해당 영역에서 가독성이 떨어지는 문제를 느꼈다.

또한 소제목을 우선적으로 봐야 계획안의 어느 부분이 중요한지 파악이 가능하고 수업의 전체 흐름이 보이는데 학부때 쓰던 기존 한글 파일 템플릿으로는 이러한 문제를 개선하기가 어려웠다.

특히나 웹 문서화가 중요한 이번 프로젝트에서 한글이나 docx에서는 구현하지 못하는 웹의 장점을 많이 살려보기로 했다. 이번에는 긴 내용을 효율적으로 숨기거나 보여주는 토글 기능을 활용하여 가독성을 개선한 교육계획안을 구현해보겠다.

1. 문제 정의 - 나는 어떠한 기능을 구현하려고 하는가?

처음에 구현하고자 했던 기능은 핵심 소제목 섹션만 노출되는 UI였다.

각 섹션의 제목을 클릭하면 해당 섹션의 내용이 표시되거나 숨겨져야 했고, 전체를 한 번에 열거나 닫는 기능도 추가하고 싶었다.

기능을 정리하면 다음과 같다:

  1. 각 섹션의 제목을 클릭하면 해당 섹션의 내용이 나타나거나 숨겨져야 함.
  2. 모든 섹션을 한 번에 열거나 닫을 수 있는 버튼이 필요함.
  3. 시각적으로 부드러운 전환 애니메이션을 적용해 내용을 표시하거나 숨길 때 UX를 개선이 필요.

또한 추가적으로 소제목에 글씨를 키우고 볼드체를 적용하며 강조를 하고, 소제목의 하위에 들어가는 교사 발문 파트는 옅게 처리하여 한눈에 소제목이 가장 먼저 눈에 띄게 CSS를 조정하고자 하였다.

2. 초기 시도

리액트에서 토글은 useState의 기본만 안다면 누구든 쉽게 구현할 수 있다!

const [isOpen1, setIsOpen1] = useState(false);
const [isOpen2, setIsOpen2] = useState(false);

// 각 섹션에 해당하는 상태를 따로 관리
return (
  <div>
    <h3 onClick={() => setIsOpen1(!isOpen1)}>Section 1</h3>
    {isOpen1 && <p>Content for Section 1</p>}

    <h3 onClick={() => setIsOpen2(!isOpen2)}>Section 2</h3>
    {isOpen2 && <p>Content for Section 2</p>}
  </div>
);

이 상태는 true일 때 내용을 보여주고, false일 때는 숨기는 방식이다. 하지만 이렇게 구현할 경우 섹션이 많아질수록 각 섹션마다 별도의 상태를 관리해야 하므로 코드가 복잡해질 수 있다.

첫 번째 시도 : 상태 충돌 문제

초기 구현에서는 개별 토글 상태expandedIndex로 관리하고, 전체 토글 상태allExpanded로 관리했다.

expandedIndexallExpanded의 역할:

  1. expandedIndex: 개별 항목을 열고 닫을 때 사용된다. 각 항목에 대해 인덱스를 지정하여 해당 항목을 열거나 닫는 상태이다. 예를 들어, expandedIndex가 2로 설정되면 세 번째 항목이 열리게 되고, 다른 항목은 닫힌 상태가 된다.
  2. allExpanded: 전체 항목을 한 번에 열거나 닫을 때 사용된다. 이 상태가 true가 되면 모든 항목이 열리고, false가 되면 모든 항목이 닫히게 된다.
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
  const [allExpanded, setAllExpanded] = useState(false); // 전체 토글 상태 관리

  // 개별 항목 토글
  const toggleContent = (index: number) => {
    if (expandedIndex === index) {
      setExpandedIndex(null);
    } else {
      setExpandedIndex(index);
    }
  };

  // 전체 토글을 처리하는 함수
  const toggleAllContent = () => {
    setAllExpanded((prev) => !prev);
    if (allExpanded) {
      setExpandedIndex(null); // 전체 닫기
    } else {
      setExpandedIndex(-1); // 전체 열기 (확실히 모두 열기 위해 -1 설정)
    }
  };

이 방식은 개별 섹션을 열거나 닫는 동작과 전체를 열고 닫는 동작을 모두 처리했지만, 상태 충돌 문제가 발생했다.

특히, expandedIndexallExpanded 상태가 서로 간섭하면서 전체 열기개별 항목 토글이 동시에 작동할 때 개별 버튼이 작동하지 않는 사태가 일어났다.

원인 분석

개별 항목을 위한 expandedIndex와 전체 토글을 위한 allExpanded가 충돌:
전체 토글을 수행할 때 setExpandedIndex(-1)로 설정했지만, 이후 개별 토글 동작이 제대로 반영되지 않았다.
이는 expandedIndexallExpanded라는 두 상태 값이 서로 다른 방식으로 항목의 열림/닫힘 상태를 관리하고 있었기 때문에, 한쪽이 설정되면 다른 쪽이 올바르게 작동하지 않는 상태 충돌이 발생한 것이다.

예를 들어:

  • allExpandedtrue로 설정되면 모든 항목이 열리게 된다.
  • 그러나 이때 expandedIndex하나의 특정 항목을 열려고 시도하면, allExpandedfalse로 특정 항목 인덱스로 변경되지 않기 때문에 개별 항목이 제대로 열리지 않는다.

expandedIndex는 특정 인덱스를 기반으로 작동하지만, allExpanded모든 항목을 동시에 제어하려고 하기 때문에 두 상태가 상호 배타적이다. 즉, expandedIndexallExpanded는 논리적으로 동시에 작동할 수 없는 두 가지 상태인데, 동시에 작동을 시도하면 서로의 동작을 방해하는 문제가 발생하게 됩니다.

이러한 이유 때문에 expandedIndexallExpanded 상태를 동시에 관리하는 것이 충돌을 일으켰고, 각 항목별로 상태를 독립적으로 관리하는 방식으로 해결해야 했다.

3. 개선 : 상태 배열로 관리

가장 먼저 문제를 해결하기 위해, 각 항목의 상태를 배열 형태로 관리하여 개별 항목의 열림/닫힘 상태를 독립적으로 제어할 수 있도록 변경했다.

const [expanded, setExpanded] = useState<boolean[]>(
    Array(contents.length).fill(false),
  );
  const [allExpanded, setAllExpanded] = useState(false); // 전체 열림 상태 관리

  // 개별 섹션 토글
  const toggleContent = (index: number) => {
    const newExpanded = [...expanded];
    newExpanded[index] = !newExpanded[index];
    setExpanded(newExpanded);
  };

  // 전체 열기/닫기
  const toggleAllContent = () => {
    const newState = !allExpanded;
    setAllExpanded(newState);
    setExpanded(Array(contents.length).fill(newState));
  };

변경된 해결 방식

  1. 개별 항목 상태를 배열로 관리: 각 항목의 열림/닫힘 여부를 expandedIndex 대신, 상태 배열로 관리한다. 이를 통해 각 항목의 상태를 독립적으로 제어할 수 있게 하였다.
  2. 전체 토글 시 배열 전체 업데이트: 전체를 열거나 닫을 때는 상태 배열의 모든 값을 한꺼번에 true 또는 false로 변경하여 문제를 해결했다.

4. 최종 - UI 개선

애니메이션 효과 없이는 급작스럽게 content가 생기고 없어지는 느낌이 들어서 transition-max-height 속성을 통해 콘텐츠가 부드럽게 열리고 닫히는 애니메이션을 추가했다.

<div className={`transition-max-height duration-500 ease-in-out overflow-hidden ${
              expanded[index] ? 'max-h-screen' : 'max-h-0'
            }`}
          >
            <p
              className={
                'text-gray-700 whitespace-pre-line break-words mt-4 pb-2'
              }
            >
              {content.content}
            </p>
  </div>

다음은 전체 코드이다.

import { useState } from 'react';

interface ActivityContentProps {
  contents: {
    subtitle: string;
    content: string;
  }[];
}

const ActivityContent = ({ contents }: ActivityContentProps) => {
  const [expanded, setExpanded] = useState<boolean[]>(
    Array(contents.length).fill(false),
  );
  const [allExpanded, setAllExpanded] = useState(false);

  const toggleContent = (index: number) => {
    const newExpanded = [...expanded];
    newExpanded[index] = !newExpanded[index];
    setExpanded(newExpanded);
  };

  const toggleAllContent = () => {
    const newState = !allExpanded;
    setAllExpanded(newState);
    setExpanded(Array(contents.length).fill(newState));
  };

  return (
    <div className={'my-4'}>
      <div className={'flex items-center justify-between mb-4'}>
        <h2 className={'text-lg font-semibold'}>{'활동 내용'}</h2>

        <button
          type={'button'}
          onClick={toggleAllContent}
          className={'text-blue-500 '}
        >
          {allExpanded ? '소제목만 보기' : '전체 내용보기'}
        </button>
      </div>
      {contents.map((content, index) => (
        <div key={content.subtitle} className={'mb-4'}>
          <div
            className={'flex justify-between items-center cursor-pointer'}
            onClick={() => toggleContent(index)}
          >
            <h3 className={'font-semibold text-md'}>{content.subtitle}</h3>
            <button type={'button'} className={'text-lg'}>
              {expanded[index] ? '▲' : '▼'}
            </button>
          </div>
          <div
            className={`transition-max-height duration-500 ease-in-out overflow-hidden ${
              expanded[index] ? 'max-h-screen' : 'max-h-0'
            }`}
          >
            <p
              className={
                'text-gray-700 whitespace-pre-line break-words mt-4 pt-2'
              }
            >
              {content.content}
            </p>
          </div>
        </div>
      ))}
    </div>
  );
};

export default ActivityContent;

최종본에서는 이렇게 드르륵한 느낌으로 나오게 된다!

profile
교육 전공 개발자 💻

0개의 댓글

관련 채용 정보