From b48c936f12c391dc714a35d20c4041e2e195cd70 Mon Sep 17 00:00:00 2001 From: Jay Sheen Date: Thu, 16 Oct 2025 18:23:13 +0900 Subject: [PATCH] =?UTF-8?q?=ED=83=AD=EB=B0=94=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=97=B0=EB=8F=99=20=EA=B0=9C=EC=84=A0:=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=9C=84=EC=B9=98=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EB=B0=8F=20=EC=8A=A4=EB=83=85=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스크롤 양의 50%만큼 탭바가 실시간으로 이동 - currentTranslateY 변수로 위치 추적 (DOM 읽기 제거) - 스크롤 중지 시 50% 기준으로 자동 스냅 - 점진적 opacity 변화 적용 (0.1~1) - will-change와 cubic-bezier로 성능 최적화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/shared/ui/assets/css/style.css | 13 ++--- src/widgets/navigation/footer.tsx | 83 +++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/shared/ui/assets/css/style.css b/src/shared/ui/assets/css/style.css index 59c2761..f384780 100644 --- a/src/shared/ui/assets/css/style.css +++ b/src/shared/ui/assets/css/style.css @@ -296,11 +296,11 @@ footer { align-items: center; z-index: 1000; border-top: 0.1px solid var(--color-E5E5E5); - transition: transform 0.3s ease-in-out; + will-change: transform; } -.bottom-tabbar.hidden { - transform: translateY(100%); +.bottom-tabbar.snapping { + transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); } .tab-button { @@ -313,14 +313,9 @@ footer { background: none; border: none; cursor: pointer; - transition: all 0.2s ease, opacity 0.3s ease-in-out; + transition: all 0.2s ease; padding: 8px 4px; box-sizing: border-box; - opacity: 1; -} - -.bottom-tabbar.hidden .tab-button { - opacity: 0.1; } .tab-button:hover { diff --git a/src/widgets/navigation/footer.tsx b/src/widgets/navigation/footer.tsx index 400ae1a..9bcdc2e 100644 --- a/src/widgets/navigation/footer.tsx +++ b/src/widgets/navigation/footer.tsx @@ -15,7 +15,6 @@ export const FooterNavigation = ({ }: FooterProps) => { const { navigate } = useNavigate(); const [isFooterOn, setIsFooterOn] = useState(true); - const [isFooterVisible, setIsFooterVisible] = useState(true); const onClickToNavigate = (path?: string) => { if(!!path){ @@ -98,21 +97,59 @@ export const FooterNavigation = ({ }; useEffect(() => { + const tabbar = document.querySelector('.bottom-tabbar') as HTMLElement; + if (!tabbar) return; + + const TABBAR_HEIGHT = 70; + const SCROLL_THRESHOLD = 50; + const SNAP_THRESHOLD = 0.5; // 50% 보이면 표시, 아니면 숨김 + const SCROLL_MULTIPLIER = 0.5; // 스크롤의 50%만큼 이동 + let lastScrollY = 0; + let currentTranslateY = 0; // 별도로 추적 + let isScrolling = false; + let scrollTimeout: NodeJS.Timeout; let ticking = false; const handleScroll = () => { const currentScrollY = window.scrollY; + const scrollDelta = currentScrollY - lastScrollY; if (!ticking) { window.requestAnimationFrame(() => { - // 스크롤 다운 & 일정 거리 이상 스크롤된 경우 -> 숨김 - if (currentScrollY > lastScrollY && currentScrollY > 50) { - setIsFooterVisible(false); + // 스크롤 시작 + if (!isScrolling) { + isScrolling = true; + tabbar.classList.remove('snapping'); } - // 스크롤 업 -> 표시 - else if (currentScrollY < lastScrollY) { - setIsFooterVisible(true); + + // 스크롤 방향에 따라 translateY 조정 + if (currentScrollY > SCROLL_THRESHOLD) { + // 스크롤 다운 + if (scrollDelta > 0) { + currentTranslateY = Math.min(currentTranslateY + scrollDelta * SCROLL_MULTIPLIER, TABBAR_HEIGHT); + } + // 스크롤 업 + else { + currentTranslateY = Math.max(currentTranslateY + scrollDelta * SCROLL_MULTIPLIER, 0); + } + + tabbar.style.transform = `translateY(${currentTranslateY}px)`; + + // opacity 계산 (0 = 완전히 보임, TABBAR_HEIGHT = 완전히 숨김) + const opacityRatio = 1 - (currentTranslateY / TABBAR_HEIGHT); + const buttons = tabbar.querySelectorAll('.tab-button') as NodeListOf; + buttons.forEach(button => { + button.style.opacity = `${Math.max(0.1, opacityRatio)}`; + }); + } else { + // 페이지 상단 근처에서는 완전히 표시 + currentTranslateY = 0; + tabbar.style.transform = 'translateY(0)'; + const buttons = tabbar.querySelectorAll('.tab-button') as NodeListOf; + buttons.forEach(button => { + button.style.opacity = '1'; + }); } lastScrollY = currentScrollY; @@ -121,19 +158,49 @@ export const FooterNavigation = ({ ticking = true; } + + // 스크롤 중지 감지 + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + isScrolling = false; + + // 현재 위치 확인 후 snap + const visibleRatio = 1 - (currentTranslateY / TABBAR_HEIGHT); + + tabbar.classList.add('snapping'); + + if (visibleRatio >= SNAP_THRESHOLD) { + // 50% 이상 보이면 완전히 표시 + currentTranslateY = 0; + tabbar.style.transform = 'translateY(0)'; + const buttons = tabbar.querySelectorAll('.tab-button') as NodeListOf; + buttons.forEach(button => { + button.style.opacity = '1'; + }); + } else { + // 50% 미만이면 완전히 숨김 + currentTranslateY = TABBAR_HEIGHT; + tabbar.style.transform = `translateY(${TABBAR_HEIGHT}px)`; + const buttons = tabbar.querySelectorAll('.tab-button') as NodeListOf; + buttons.forEach(button => { + button.style.opacity = '0.1'; + }); + } + }, 150); }; window.addEventListener('scroll', handleScroll, { passive: true }); return () => { window.removeEventListener('scroll', handleScroll); + clearTimeout(scrollTimeout); }; }, []); return ( <> { isFooterOn && -