feat: 모바일 일정 검색에 가상 스크롤 적용

- @tanstack/react-virtual useVirtualizer 적용
- 동적 높이 지원 (measureElement, data-index)
- SEARCH_LIMIT 10 → 20으로 증가
- 검색 결과가 많아도 DOM에는 화면에 보이는 요소만 렌더링
This commit is contained in:
caadiq 2026-01-10 10:10:36 +09:00
parent b35dab5eea
commit 660acd0007

View file

@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useVirtualizer } from '@tanstack/react-virtual';
import { getSchedules, getCategories, searchSchedules } from '../../../api/public/schedules';
//
@ -26,7 +27,9 @@ function MobileSchedule() {
setCalendarViewDate(newDate);
};
const SEARCH_LIMIT = 10;
const SEARCH_LIMIT = 20; // 20
const ESTIMATED_ITEM_HEIGHT = 100; // ( )
const scrollContainerRef = useRef(null); //
const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
//
@ -55,6 +58,14 @@ function MobileSchedule() {
return searchData.pages.flatMap(page => page.schedules);
}, [searchData]);
// ( , )
const virtualizer = useVirtualizer({
count: isSearchMode && searchTerm ? searchResults.length : 0,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
overscan: 5, //
});
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
@ -436,12 +447,11 @@ function MobileSchedule() {
</AnimatePresence>
{/* 컨텐츠 영역 */}
<div className="mobile-content" ref={contentRef}>
<div className="mobile-content" ref={isSearchMode && searchTerm ? scrollContainerRef : contentRef}>
<div className="px-4 pt-4 pb-4">
{isSearchMode && searchTerm ? (
//
<div className="space-y-3">
{searchLoading ? (
// -
searchLoading ? (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
@ -451,14 +461,42 @@ function MobileSchedule() {
</div>
) : (
<>
{searchResults.map((schedule, index) => (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const schedule = searchResults[virtualItem.index];
if (!schedule) return null;
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className={virtualItem.index < searchResults.length - 1 ? "pb-3" : ""}>
<ScheduleCard
key={`${schedule.id}-search-${index}`}
schedule={schedule}
categoryColor={getCategoryColor(schedule.category_id)}
categories={categories}
/>
))}
</div>
</div>
);
})}
</div>
{/* 무한 스크롤 트리거 */}
<div ref={loadMoreRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
@ -467,8 +505,7 @@ function MobileSchedule() {
)}
</div>
</>
)}
</div>
)
) : loading ? (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />