학부 시절, 교육 계획안의 ‘활동 내용’ 영역에서 소제목과 교사의 발문이 뒤섞여 유독 핵심 내용이 들어있는 해당 영역에서 가독성이 떨어지는 문제를 느꼈다.
또한 소제목을 우선적으로 봐야 계획안의 어느 부분이 중요한지 파악이 가능하고 수업의 전체 흐름이 보이는데 학부때 쓰던 기존 한글 파일 템플릿으로는 이러한 문제를 개선하기가 어려웠다.
특히나 웹 문서화가 중요한 이번 프로젝트에서 한글이나 docx에서는 구현하지 못하는 웹의 장점을 많이 살려보기로 했다. 이번에는 긴 내용을 효율적으로 숨기거나 보여주는 토글 기능을 활용하여 가독성을 개선한 교육계획안을 구현해보겠다.
처음에 구현하고자 했던 기능은 핵심 소제목 섹션만 노출되는 UI였다.
각 섹션의 제목을 클릭하면 해당 섹션의 내용이 표시되거나 숨겨져야 했고, 전체를 한 번에 열거나 닫는 기능도 추가하고 싶었다.
기능을 정리하면 다음과 같다:
또한 추가적으로 소제목에 글씨를 키우고 볼드체를 적용하며 강조를 하고, 소제목의 하위에 들어가는 교사 발문 파트는 옅게 처리하여 한눈에 소제목이 가장 먼저 눈에 띄게 CSS를 조정하고자 하였다.
리액트에서 토글은 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
로 관리했다.
expandedIndex
와 allExpanded
의 역할:expandedIndex
: 개별 항목을 열고 닫을 때 사용된다. 각 항목에 대해 인덱스를 지정하여 해당 항목을 열거나 닫는 상태이다. 예를 들어, expandedIndex
가 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 설정)
}
};
이 방식은 개별 섹션을 열거나 닫는 동작과 전체를 열고 닫는 동작을 모두 처리했지만, 상태 충돌 문제가 발생했다.
특히, expandedIndex
와 allExpanded
상태가 서로 간섭하면서 전체 열기와 개별 항목 토글이 동시에 작동할 때 개별 버튼이 작동하지 않는 사태가 일어났다.
개별 항목을 위한 expandedIndex
와 전체 토글을 위한 allExpanded
가 충돌:
전체 토글을 수행할 때 setExpandedIndex(-1)
로 설정했지만, 이후 개별 토글 동작이 제대로 반영되지 않았다.
이는 expandedIndex
와 allExpanded
라는 두 상태 값이 서로 다른 방식으로 항목의 열림/닫힘 상태를 관리하고 있었기 때문에, 한쪽이 설정되면 다른 쪽이 올바르게 작동하지 않는 상태 충돌이 발생한 것이다.
예를 들어:
allExpanded
가 true
로 설정되면 모든 항목이 열리게 된다.expandedIndex
가 하나의 특정 항목을 열려고 시도하면, allExpanded
가 false
로 특정 항목 인덱스로 변경되지 않기 때문에 개별 항목이 제대로 열리지 않는다.expandedIndex
는 특정 인덱스를 기반으로 작동하지만, allExpanded
는 모든 항목을 동시에 제어하려고 하기 때문에 두 상태가 상호 배타적이다. 즉, expandedIndex
와 allExpanded
는 논리적으로 동시에 작동할 수 없는 두 가지 상태인데, 동시에 작동을 시도하면 서로의 동작을 방해하는 문제가 발생하게 됩니다.
이러한 이유 때문에 expandedIndex
와 allExpanded
상태를 동시에 관리하는 것이 충돌을 일으켰고, 각 항목별로 상태를 독립적으로 관리하는 방식으로 해결해야 했다.
가장 먼저 문제를 해결하기 위해, 각 항목의 상태를 배열 형태로 관리하여 개별 항목의 열림/닫힘 상태를 독립적으로 제어할 수 있도록 변경했다.
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));
};
expandedIndex
대신, 상태 배열로 관리한다. 이를 통해 각 항목의 상태를 독립적으로 제어할 수 있게 하였다.true
또는 false
로 변경하여 문제를 해결했다.애니메이션 효과 없이는 급작스럽게 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;
최종본에서는 이렇게 드르륵한 느낌으로 나오게 된다!