[React_Library] 휠 스크롤 날짜 선택 라이브러리, 하지만 직접 구현하는 방법을 곁들인 (react-simple-wheel-picker)
아이폰에서 볼 수 있는 기본 UI 중에 스크롤로 날짜를 선택하는 UI가 있다.
앱 환경에서는 기본 UI로 불러올 수 있어서 이런거에 대해 UI 생각을 크게 하지 않겠지만
이걸 웹에서 구현하려고 하라는 미션을 받아 옙!을 외치고 바로 라이브러리를 찾아봤다.
사막에서 오아시스를 발견했지만 신기루였다
npm에는 달력과 연관된 라이브러리는 상당히 많이 있다.
react-date-picker / react-day-picker 등 여러 라이브러리가 있는데 내가 세운 조건에는 부합하지 않았다.
- 스크롤을 통한 연 / 월 선택
- 스크롤이 멈춘 위치의 값을 자동으로 선택
- 해당 컴포넌트를 열었을 경우 선택했던 값이 컴포넌트 중앙으로 위치하기
위와 같은 조건들이 충족되는 라이브러리를 찾기 쉽지 않았었는데
그러던 중 발견한 라이브러리가 바로 이것이다.
react-simple-wheel-picker
<div align="center"> <h1>react-simple-wheel-picker</h1> <img src="https://raw.githubusercontent.com/keiya01/react-simple-wheel-picker/master/demo.gif" alt="demo"> <br> <p>You can set up simple and flexible wheel picker</p> <br> </div> <hr/>. Latest version
www.npmjs.com
해당 라이브러리는 스크롤을 통해 값을 선택할 수 있고, 스크롤이 멈춘 위치의 값이 선택 되는 것을 볼 수 있었다.
기본 UI가 좀 구리긴 하지만 그래도 커스터마이징이 되기 때문에 괜찮지 않을까 하는 마음으로 설치하려 했는데
가장 최근 업데이트가 5년전이었다...ㅋㅋ
UI도 구린데 업데이트도 안됐다? 그럼 그냥 내가 만드는게 낫겠다 라는 판단을 세웠다.
일단 틀을 먼저 짜보자
우선 해당 UI로 내가 원하는 기능들을 정의해봤다.
- 연도 / 월 영역이 각각 스크롤 되어야 함
- 가운데 회색 영역에 위치한 연도와 월이 선택한 연도와 월이 되어야 함
- 날짜 적용 버튼을 눌러야만 선택한 연도와 월이 정상적으로 저장이 됨
이러한 기능들을 바탕으로 UI를 구성했다.
UI를 구성할 때 고민했던 부분은 아무래도 이게 휠이 돌아가는 형태의 UI라는 점이다.
구현한 UI를 보면 휠을 감싸는 영역에 고정 높이를 주고 그 안에서 스크롤이 움직이도록 구현한 부분,
스크롤 바를 노출시키지 않은 부분 등을 적용했다.
물론 휠 UI라서 선택되지 않은 영역은 blur 처리를 해야하기도 했는데 그건 일단 넘어갔다.
UI 영역에 대한 코드는 아래 더보기를 통해 확인할 수 있다.
stack : next.js / typescript / tailwindcss
- Bottom Sheet 상단 버튼 영역
<div className="flex justify-between mb-4">
<button
className="px-4 py-3 rounded-xl border-[1px] border-[#e0e0e0] text-h5"
onClick={onClose}
>취소</button>
<button
className="px-4 py-3 rounded-xl border-[1px] border-[#e0e0e0] text-h5"
onClick={selectDate}
>날짜 적용</button>
</div>
- 연도 / 월 선택 영역
<div className="relative h-[210px] flex gap-10">
<div
className="absolute top-1/2 h-16 w-full rounded-2xl bg-[#e5e5e5] translate-y-[-50%] -z-10"
/>
<div
title="year"
className="w-1/2 h-full overflow-y-auto [&::-webkit-scrollbar]:hidden"
>
<ul className="flex flex-col gap-2.5 h-fit">
<div className="h-16 w-full" />
{years.map((year) => {
return (
<li
key={year}
className="flex justify-end gap-12 p-2.5 scroll-snap-child"
>
<div className="flex items-center gap-2.5">
<span className="text-[28px] font-medium">{year}</span>
<span className="text-2xl font-medium">년</span>
</div>
</li>
);
})}
<div className="h-16 w-full" />
</ul>
</div>
<div
className="month-container w-1/2 h-full overflow-y-auto [&::-webkit-scrollbar]:hidden"
>
<ul className="flex flex-col gap-2.5 h-fit pl-6">
<div className="h-16 w-full" />
{months.map((month) => {
return (
<li
key={month}
className="flex justify-start gap-12 p-2.5 scroll-snap-child"
>
<div className="flex items-center gap-2.5">
<span className="text-[28px] font-medium">{month}</span>
<span className="text-2xl font-medium">월</span>
</div>
</li>
);
})}
<div className="h-16 w-full" />
</ul>
</div>
</div>
</div>
해당 영역에서 [&::-webkit-scrillbar]가 있는데 이 부분이 스크롤 바를 노출 시키지 않는 부분이다.
어떻게 스크롤이 멈춘 영역을 알게 하지?
사실 이 기능의 가장 핵심이 되는 질문일 것 같다.
일반적인 달력 라이브러리는 달력을 보여주고, 사용자의 클릭에 따라 날짜를 선택하는 방식인데,
휠로 조정하는 달력은 사용자의 스크롤에 따라서 멈춘 영역을 특정해야 한다는 부분이 핵심이었다.
감시는 옵저버지
그래서 가장 먼저 생각했던 부분은 IntersectionObserver를 활용하는 것이었다.
const yearRefs = useRef<(HTMLLIElement | null)[]>([]);
const monthRefs = useRef<(HTMLLIElement | null)[]>([]);
const yearContainerRef = useRef<HTMLDivElement | null>(null);
const monthContainerRef = useRef<HTMLDivElement | null>(null);
우선 Observe를 해줄 영역과 대상을 지정하는 것이 필요했다.
그래서 연도 / 월 리스트가 담긴 영역과 각각의 리스트를 ref로 연결해 주는 작업을 거쳤다.
useEffect(() => {
const observerOptionsYear = {
root: yearContainerRef.current,
rootMargin: "-50% 0px -50% 0px",
threshold: 0.5,
};
const observerOptionsMonth = {
root: monthContainerRef.current,
rootMargin: "-50% 0px -50% 0px",
threshold: 0.5,
};
const yearObserver = new IntersectionObserver((entries) => {
const visibleEntry = entries.find((entry) => entry.isIntersecting);
if (visibleEntry) {
setSelectedYear(Number(visibleEntry.target.getAttribute("data-year")));
}
}, observerOptionsYear);
const monthObserver = new IntersectionObserver((entries) => {
const visibleEntry = entries.find((entry) => entry.isIntersecting);
if (visibleEntry) {
setSelectedMonth(visibleEntry.target.getAttribute("data-month") || "");
}
}, observerOptionsMonth);
yearRefs.current.forEach((el) => el && yearObserver.observe(el));
monthRefs.current.forEach((el) => el && monthObserver.observe(el));
return () => {
yearRefs.current.forEach((el) => el && yearObserver.unobserve(el));
monthRefs.current.forEach((el) => el && monthObserver.unobserve(el));
};
}, []);
그 후, useEffect를 통해 감시해야 할 영역과 범위 등의 조건을 지정했다.
rootMargin을 이렇게 설정한 이유로는 연도와 월이 회색 영역에 도달 했을 때,
해당 영역을 감지하기 위함이라고 보면 되겠다.
그 다음 연도와 월 리스트 각 영역에 ref를 연결했다.
근데 내가 이 IntersectionObserver에 대해서 명확하게 이해하지 못한 채 사용한 탓일까
개별적으로 감지 되는 것도 아니고, 스크롤이 내려가면서 회색 영역에 도달 했을 때 감지 되지도 않고,
스크롤이 전부 내려갔을 때 한번 감지하는 것을 볼 수 있었다.
이건 이해의 부족이 불러온 사고다.. 라고 생각해 일단 다른 방법을 고안해 보기로 한다.
스크롤을 이용하는거니까 스크롤 이벤트를 써보자
이번에는 조금 더 단순화 시켜서 어차피 스크롤로 적용이 되어야 하니까 스크롤 이벤트를 사용해 보자고 생각했다.
useEffect(() => {
const yearEl = yearContainerRef.current;
const monthEl = monthContainerRef.current;
if (yearEl) yearEl.addEventListener("scroll", () => handleScroll("year"));
if (monthEl)
monthEl.addEventListener("scroll", () => handleScroll("month"));
return () => {
if (yearEl)
yearEl.removeEventListener("scroll", () => handleScroll("year"));
if (monthEl)
monthEl.removeEventListener("scroll", () => handleScroll("month"));
};
}, []);
우선 스크롤이 발생하는 영역에다 스크롤 이벤트 발생시 동작하는 함수를 연결해줬고,
어떤 영역에서 스크롤이 발생하는지를 함께 알도록 했다.
const handleScroll = (type: string) => {
const LIST_CARD_HEIGHT = 70;
if (type === "year") {
if (yearContainerRef.current) {
const refScrollHeight = yearContainerRef.current.scrollTop;
const index = Math.floor(refScrollHeight / LIST_CARD_HEIGHT);
setSelectedYear(years[index]);
}
} else {
if (monthContainerRef.current) {
const refScrollHeight = monthContainerRef.current.scrollTop;
const index = Math.floor(refScrollHeight / LIST_CARD_HEIGHT);
setSelectedMonth(months[index]);
}
}
};
스크롤이 발생하면 해당 스크롤 영역의 스크롤 된 값을 가져와 하나의 영역을 나눠서 나온 몫을 index로 활용했다.
여기서 years나 months는 각각 연도와 월의 데이터를 담고 있는 배열이다.
이렇게 하니까 스크롤이 될 때 특정 영역의 연도와 월을 가져오기는 했는데
문제는 스크롤이 애매하게 걸칠 때, 예를 들어 2025년과 2024년의 사이에 애매하게 걸쳐질 때
둘 중에 하나 값이 들어가지는 이슈가 발생했다.
스크롤 영역 붙여버리기
특정 영역의 범위면 스크롤이 자동으로 멈추거나 더 많이 넘어간 영역에 자동으로 스크롤이 되게 하는 css가 있다.
scroll snap 이라는 css 인데, 전에는 이걸 라이브러리나 자바스크립트 코드로 구현했는데
css를 활용하면 상당히 손쉽게 원하는 기능을 구현할 수 있다.
.scroll-snap {
scroll-snap-type: y mandatory;
}
.scroll-snap-child {
scroll-snap-align: start;
}
전체 영역에는 scroll-snap-type : y mandatory를 이용해 방향을 설정하고
각 요소 영역에는 scroll-snap-align을 이용해 붙게 되는 위치를 설정하면
자연스럽게 스크롤이 턱턱 붙는 것을 볼 수 있었다.
이 css를 적용하고 좀 더 개선할 점이 생겼다.
날짜 적용 시에 현재 위치 확인하기
지금은 스크롤이 될 때마다 state를 변경하도록 설정이 되어서, 디바운스를 적용할까도 생각했는데
다시 생각해보니 결국 '날짜 적용'이라는 버튼을 눌렀을 때, state에 저장이 되어야 하는 거라
계속 state 변경이 되지 않아도 됐다.
const selectDate = () => {
if (yearContainerRef.current) {
const yearScrollHeight = yearContainerRef.current.scrollTop;
const index = Math.floor(yearScrollHeight / LIST_CARD_HEIGHT);
onChangeDate("year", years[index]);
}
if (monthContainerRef.current) {
const refScrollHeight = monthContainerRef.current.scrollTop;
const index = Math.floor(refScrollHeight / LIST_CARD_HEIGHT);
onChangeDate("month", months[index]);
}
onClose();
};
그래서 날짜 적용을 눌렀을 때 스크롤 함수가 동작하도록 분리하고
그 시점에 스크롤을 계산해서 값을 저장하도록 설정했다.
useEffect(() => {
const yearEl = yearContainerRef.current;
const monthEl = monthContainerRef.current;
const { year, month } = curDate;
if (yearEl) {
yearEl.scrollTo({
top: years.indexOf(year) * LIST_CARD_HEIGHT,
});
}
if (monthEl) {
monthEl.scrollTo({
top: months.indexOf(month) * LIST_CARD_HEIGHT,
});
}
}, []);
거기에 추가로 바텀 시트가 열렸을 경우 현재 선택된 연, 월에 포커스가 가도록 설정해주었다.
이렇게 구현해서 맨 위에 보이는 UI를 만들게 됐다.
전체 코드는 아래 더보기를 참고..!
import React, { useEffect, useRef } from "react";
import "./scrollSnap.css";
interface Props {
curDate: { year: string; month: string };
onChangeDate: (name: string, value: string) => void;
onClose: () => void;
}
const years = ["2025", "2026", "2027", "2028", "2029", "2030"];
const months = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
const LIST_CARD_HEIGHT = 70;
const DateBottomSheet = ({ curDate, onChangeDate, onClose }: Props) => {
const yearContainerRef = useRef<HTMLDivElement | null>(null);
const monthContainerRef = useRef<HTMLDivElement | null>(null);
const selectDate = () => {
if (yearContainerRef.current) {
const yearScrollHeight = yearContainerRef.current.scrollTop;
const index = Math.floor(yearScrollHeight / LIST_CARD_HEIGHT);
onChangeDate("year", years[index]);
}
if (monthContainerRef.current) {
const refScrollHeight = monthContainerRef.current.scrollTop;
const index = Math.floor(refScrollHeight / LIST_CARD_HEIGHT);
onChangeDate("month", months[index]);
}
onClose();
};
useEffect(() => {
const yearEl = yearContainerRef.current;
const monthEl = monthContainerRef.current;
const { year, month } = curDate;
if (yearEl) {
yearEl.scrollTo({
top: years.indexOf(year) * LIST_CARD_HEIGHT,
});
}
if (monthEl) {
monthEl.scrollTo({
top: months.indexOf(month) * LIST_CARD_HEIGHT,
});
}
}, []);
return (
<div>
<div className="flex justify-between mb-4">
<button
className="px-4 py-3 rounded-xl border-[1px] border-[#e0e0e0] text-h5"
onClick={onClose}
>취소</button>
<button
className="px-4 py-3 rounded-xl border-[1px] border-[#e0e0e0] text-h5"
onClick={selectDate}
>날짜 적용</button>
</div>
<div className="relative h-[210px] flex gap-10">
<div
className="absolute top-1/2 h-16 w-full rounded-2xl bg-[#e5e5e5] translate-y-[-50%] -z-10"
/>
<div
title="year"
className="w-1/2 h-full overflow-y-auto [&::-webkit-scrollbar]:hidden"
>
<ul className="flex flex-col gap-2.5 h-fit">
<div className="h-16 w-full" />
{years.map((year) => {
return (
<li
key={year}
className="flex justify-end gap-12 p-2.5 scroll-snap-child"
>
<div className="flex items-center gap-2.5">
<span className="text-[28px] font-medium">{year}</span>
<span className="text-2xl font-medium">년</span>
</div>
</li>
);
})}
<div className="h-16 w-full" />
</ul>
</div>
<div
className="month-container w-1/2 h-full overflow-y-auto [&::-webkit-scrollbar]:hidden"
>
<ul className="flex flex-col gap-2.5 h-fit pl-6">
<div className="h-16 w-full" />
{months.map((month) => {
return (
<li
key={month}
className="flex justify-start gap-12 p-2.5 scroll-snap-child"
>
<div className="flex items-center gap-2.5">
<span className="text-[28px] font-medium">{month}</span>
<span className="text-2xl font-medium">월</span>
</div>
</li>
);
})}
<div className="h-16 w-full" />
</ul>
</div>
</div>
</div>
);
};
export default DateBottomSheet;