이 편에서는 탭 전환 효과, 한 탭에서 여러 개의 요소 보이기 등 스타일링에 가까운 내용을 다룹니다. 스와이프, 오토 플레이, 반응형 대응과 관련된 얘기는 다음 편에서 다룰 예정입니다.
프로젝트 전반에 걸쳐서 사용할 Swiper 이기에 요구 사항을 정리해두는 것이 좋을 것 같았다. 아래는 도출한 요구사항 중 중요한 사항들만 정리한 것이다.
위 요구 사항뿐만 아니라 Swiper 인터페이스에 대해서 고민을 많이했었다. 최소한의 인터페이스로 Swiper를 사용할 때 헷갈리지 않고 편리하게 사용할 수 있도록 하며, 다양한 환경에서 유연하게 동작할 수 있도록 하기 위함이었다.
이 Swiper는 우아한테크코스 4레벨 '재사용 가능한 레이아웃 컴포넌트' 미션의 Tab Layout을 기반으로 만들어졌다. 그래서 첫 번째 것과 두 번째 것은 이전의 개인미션에서 Tab Layout 컴포넌트를 구현할 때 어느정도 완료를 해둔 상태였다.
Tab Layout이라 함은 선택한 탭에 해당하는 요소만 보이는 컴포넌트이다. 즉 다음과 같다.
이것을 구현하는 방법은 여러 가지가 있다. 대표적으로 탭을 상태로 두어 사용자가 선택한 탭의 인덱스 또는 ID를 통해 해당 값에 알맞는 요소를 렌더링하는 방식이다. 나는 Swiper로 만들 것을 염두해두었기에 이 방식보단 사용자가 설정한 탭의 개수만큼 배로 긴 요소 컨테이너를 두고, 사용자가 선택한 탭에 맞는 위치로 이동하는 방식을 선택했다.
즉 실제로는 이렇게 되어있고 탭의 witdh
만큼 위치를 이동시키는 셈이다. 전환효과는 transition
으로 만들었다. 코드로 보면 다음과 같다. 위 기능에 해당하는 코드만 뽑아오다보니 코드 중간중간 생략된 부분이 많다. 추가로 이 Swiper는 styled-components
를 사용하였다.
interface TabProps {
label: string;
}
interface Props {
children: React.ReactNode;
width?: number;
height?: number;
// 중략..
}
function Swiper({
width = 400,
height = 400,
// props..
children,
}: Props) {
const childrenList = React.Children.toArray(
children,
) as React.ReactElement<TabProps>[];
const [pos, setPos] = useState<number>(0);
// 중략..
return (
<Wrapper
width={width}
// props..
>
// 중략..
<TabSectionWrapper
width={width}
height={height}
$childrenLength={childrenList.length}
pos={pos}
// props..
>
{children}
</TabSectionWrapper>
// 중략..
</Wrapper>
);
}
const Wrapper = styled.div<{
width: number;
// props..
}>`
width: ${({ width }) => `${width}px`};
overflow: hidden;
margin: 0 auto;
position: relative;
display: flex;
// 중략..
`;
const TabSectionWrapper = styled.div<{
width: number;
height: number;
$childrenLength: number;
pos: number;
// props..
}>`
display: flex;
// ✅A
width: ${({ width, $childrenLength }) => `${width * $childrenLength}px`};
height: ${({ height }) => `${height}px`};
// ✅B
transform: ${({ width, pos }) => `translateX(${-width * pos}px)`};
transition: 0.3s ease transform;
// 중략..
`;
✅A 부분이 바로 요소(Tab)를 담는 Container의 넓이를 사용자가 지정한 가로 넓이 * 요소의 개수 만큼 설정해주는 부분이다. 그리고 ✅B 부분이 탭 박스 또는 스와이프 좌우 버튼을 클릭하여 해당 요소로 이동할 때 동작하는 부분이다.
이 부분은 정말 간단하게 CSS 단에서 구현했다. 지금은 사용해보니 Suspense를 이용한 Skeleton UI 대응이 어렵고, 화면에 보이지 않는 요소까지 그리다보니 요소가 만약 서버에서 가져오는 데이터라면.. 성능면에서나 비용면에서 좋지 않은 것은 사실이다. 이는 추후 개선해볼 사항이다.
아무튼 전환효과를 포함하여 탭에 해당하는 요소만 보이는 기능은 위와 같이 구현하였다.
기능의 이름을 뭐라고 해야할지 몰라서 일단 위와 같이 적었다. Tab Layout 미션을 할 때, 예시에서는 사용처에서 탭의 개수를 prop로 내려주었다. 이를테면 다음과 같다.
<TabLayout tabs={["Tab 1", "Tab 2", "Tab 3"]}>
<div>Content 1</div>
<div>Content 2</div>
<div>Content 3</div>
</TabLayout>
tabs
라는 prop 없이 자식 태그의 요소를 알아서 파악해서 사용자가 일일히 탭의 개수를 세서 전달하는 과정이 없었으면 좋겠다고 생각했다. 그 과정이 아래의 코드이다.
interface TabProps {
label: string;
}
interface Props {
children: React.ReactNode;
// 중략..
}
function Swiper({
// props..
children,
}: Props) {
const childrenList = React.Children.toArray(
children,
) as React.ReactElement<TabProps>[];
// 중략..
}
childrenList
라는 배열을 통해서 사용처에서 tabs
와 같은 탭의 개수를 prop으로 넘겨주지 않아도 자동으로 파악할 수 있게 하였다. 그래서 만든 Swiper는 아래와 같이 사용이 가능하다.
import { Tab, Swiper } from '.';
function App() {
return (
<Swiper>
<Tab label="숫자 1">1</Tab>
<Tab label="숫자 2">2</Tab>
<div>3</div>
<section>4</section>
</Swiper>
);
}
export default App;
그에 따른 결과 화면은 다음과 같다.
Tab 컴포넌트는 Swiper의 일종의 자식 컴포넌트라고 생각하면 될 것 같은데, 상단의 탭 박스에 써야하는 내용이 있다면, label
prop을 통해 지정할 수 있다. Tab 컴포넌트는 필수가 아니라서 일반적인 div
, section
등의 태그를 사용하여 탭을 만들 수 있다. 이런 경우에는 index + 1
의 값이 상단의 탭 박스에 내용으로 들어갈 수 있도록 하였다.
Tab 컴포넌트는 다음과 같이 생겼다.
interface Props {
label: string;
children?: React.ReactNode;
}
function Tab({ children }: Props) {
return <div>{children}</div>;
}
export default Tab;
label
값은 Swiper 컴포넌트에서 ✅ 표시한 곳과 같이 읽어드리기 때문에 Tab 컴포넌트에서 직접적으로 사용할 일은 없다.
// Swiper 일부
{isShowTabBox && (
<TabBoxWrapper $simpleTab={$simpleTab} $tabBoxHeight={$tabBoxHeight}>
{calculateTabCountUsingElements(childrenList, elementsCount).map(
(children, idx) =>
children && (
<TabBox
// props..
>
{!$simpleTab && (✅children.props.label || idx + 1)}
</TabBox>
),
)}
</TabBoxWrapper>
)}
이 기능은 Tab Layout 컴포넌트라면 굳이 필요 없는 기능이다. Swiper의 특성상 한 탭에 하나 이상의 요소가 보여져야하는 상황이 있을 수 있다. 이 상황을 대응하기 위해서 만든 기능이다. 우리 서비스에서 예를 들면 다음과 같다.
비교를 해보자면 원래의 Tab Layout 컴포넌트라면 아래와 같이 한 탭에 하나의 요소만 보일 수 있다.
배너 같이 한 탭에 한 요소만 보이려면 문제가 없겠지만, 위와 같은 경우는 문제가 좀 있다. 그래서 한 탭에 여러 요소들을 배치할 수 있도록 하는 것이다. 조금 더 정확히 말하면 아래와 같이 사용자가 지정한 Swiper width
에 몇 개의 탭을 넣을 것인가를 지정하는 것이다.
사실 사용처에서 한 탭에 넣을 요소를 계산해서 맞춰서 넣어줄 수도 있다. 하지만 이는 사용처에 지나치게 복잡한 로직을 작성하게 될 가능성이 높아보였다. 더군다나 반응형 로직도 생각해야하니 Swiper를 쓰는 곳마다 이런 부분을 생각해야한다면.. 나라도 안 쓸 것 같았다.
먼저 Swiper width
에(편의상 Tab Container 라고 하겠다.) 몇 개의 Tab을 지정할 것인지 사용자에게로부터 받아온다.
interface Props {
$elementsOneTab?: number;
// 중략..
}
그런 다음 사용자가 지정한 Tab 의 개수가 몇 개인지 파악하고, $elementsOneTab
의 값과 비교하여 Swiper 가 나타내야하는 실제 Tab Container의 개수를 구한다.
const calculateTabCountUsingElements = (
childrenList: React.ReactElement<TabProps>[],
$elementsOneTab: number,
) => {
if ($elementsOneTab > 1) {
const tabBoxesCount = Math.ceil(childrenList.length / $elementsOneTab);
return childrenList.filter((_, idx) => idx < tabBoxesCount);
}
return childrenList;
};
배열의 형태로 반환하는 이유는 이 함수의 반환 값을 TabBox를 구현할 때 쓰이기 때문이다. 그래서 개수가 필요할 땐 함수 반환값에 length
를 붙여서 쓰는 방식으로 진행했다. 이제 CSS 레벨로 내려가보자.
// 중략..
<TabSectionWrapper
$childrenLength={childrenList.length}
$elementsOneTab={elementsCount}
// props..
>
{children}
</TabSectionWrapper>
// 중략..
const calculateWidthUsingElementsCount = (
width: number | '100vw',
elementCount: number,
) => {
if (typeof width === 'number' && elementCount > 1)
return width / elementCount;
if (typeof width === 'string' && elementCount > 1)
return `calc(${width} / ${elementCount})`;
return width;
};
const TabSectionWrapper = styled.div<{
width: number;
$childrenLength: number;
$elementsOneTab: number;
// props..
}>`
display: flex;
// 중략..
// ✅A
& > * {
width: ${({ width, $elementsOneTab }) =>
calculateWidthUsingElementsCount(width, $elementsOneTab)}px;
${({ responsive, width, $elementsOneTab }) =>
responsive &&
css`
@media (max-width: ${width}px) {
width: ${calculateWidthUsingElementsCount('100vw', $elementsOneTab)};
}
`}
}
`;
// 중략..
✅A 부분을 보면 Swiper의 width
에서 $elementsOneTab
의 개수만큼 Tab 컴포넌트 또는 Swiper 바로 아래의 자식 컴포넌트의 width
를 나누는걸 볼 수 있다. 해당 연산을 수행하는 곳이 calculateWidthUsingElementsCount
라는 함수이다. width
가 string
타입인 경우는 반응형에 대응하기 위함이다.
$elementsOneTab
을 지정해서 다음과 같이 Swiper를 사용해보자.
import { Tab, Swiper } from '.';
function App() {
return (
<Swiper width={1000} $elementsOneTab={4}>
<Tab label="첫 번째">1</Tab>
<Tab label="두 번째">2</Tab>
<Tab label="세 번째">3</Tab>
<Tab label="네 번째">4</Tab>
<Tab label="다섯 번째">5</Tab>
<Tab label="여섯 번째">6</Tab>
</Swiper>
);
}
export default App;
결과는 다음과 같다.
Swiper의 width
를 1000px
로 지정하고 $elementsOneTab
을 4로 지정한다면, 하나의 Tab의 width
값은 250px
이 되는 셈이다. 그래서 하나의 Tab Container에서 4개의 Tab을 볼 수 있다. Tab Container의 개수는 calculateTabCountUsingElements
함수에서 구해서 2
개의 Tab Container 만 생성할 것이고 이는 TabBox 에 첫 번째
, 두 번째
탭만 보이는 것에서 확인할 수 있다.
TabBox가 있으니 좀 어색해보이기도 한다. 하지만 TabBox는 Tab Layout 컴포넌트의 형태로 쓸 때 의미가 있으며, Tab Layout 컴포넌트는 위에서 말했듯이 하나의 탭에서 여러 개의 요소를 보이는 목적이 아니다.
Swiper에 맞게 사용해보자.
import styled from 'styled-components';
import { Swiper } from '.';
function App() {
return (
<Swiper
width={1000}
$elementsOneTab={4}
$simpleTab
$tabBoxPosition="bottom"
swiper
>
<Tab label="null">
<GreenElement>1</GreenElement>
</Tab>
<Tab label="null">
<BlueElement>2</BlueElement>
</Tab>
<Tab label="null">
<GreenElement>3</GreenElement>
</Tab>
<Tab label="null">
<BlueElement>4</BlueElement>
</Tab>
<Tab label="null">
<GreenElement>5</GreenElement>
</Tab>
<Tab label="null">
<BlueElement>6</BlueElement>
</Tab>
</Swiper>
);
}
const GreenElement = styled.div`
width: 100%;
height: 400px;
background-color: #91ce11;
`;
const BlueElement = styled.div`
width: 100%;
height: 400px;
background-color: #1faaf1;
`;
export default App;
결과는 다음과 같다.
이 기능은 메인 페이지의 지도 카드 조회, 장소 이미지 조회 시에 쓰이고 있다. 이 기능을 도입하면서 Swiper의 로직이 전반적으로 복잡해졌다. 컴포넌트 로직과 CSS 로직간에 책임 분리가 잘 안되서 코드가 지저분한 것도 덤.. 🫠
Swiper 리팩토링 때 손봐야하는 부분 1순위인데, 어떻게 고쳐나가야할지는 여러 레퍼런스를 찾아보며 좀 더 고민해봐야겠다.
기가막힌걸요 ?