Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 내비게이션 바 애니메이션 추가 #435

Merged
merged 12 commits into from
Oct 19, 2023
32 changes: 32 additions & 0 deletions frontend/src/components/@common/Navbar/NavItem.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import styled from 'styled-components';

export const Wrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;

width: 64px;
height: 100%;
`;

export const Center = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;

width: 100%;
height: 48px;

& > * {
transition: all 0.3s ease-out;
}
`;

export const Text = styled.p<{ $isActive?: boolean }>`
font-size: 1rem;
font-weight: 700;
line-height: 1.5rem;
color: ${({ $isActive, theme: { color } }) =>
$isActive ? color.fontPrimaryForBackground : color.subLight};
`;
25 changes: 25 additions & 0 deletions frontend/src/components/@common/Navbar/NavItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import SvgStroke, { ICONS } from 'components/@common/SvgIcons/SvgStroke';
import { Wrapper, Center, Text } from './NavItem.style';
import theme from 'style/theme.style';

interface NavItemProps {
isActive: boolean;
iconId: typeof ICONS[number];
label: string;
}

const NavItem = (props: NavItemProps) => {
const { isActive, iconId, label } = props;
const iconColor = isActive ? theme.color.fontPrimaryForBackground : theme.color.subLight;

return (
<Wrapper>
<Center>
<SvgStroke color={iconColor} size={24} icon={iconId} />
<Text $isActive={isActive}>{label}</Text>
</Center>
</Wrapper>
);
};

export default NavItem;
52 changes: 17 additions & 35 deletions frontend/src/components/@common/Navbar/Navbar.style.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Link } from 'react-router-dom';
import { styled } from 'styled-components';
import { keyframes, styled } from 'styled-components';

export const Wrapper = styled.nav`
export const Wrapper = styled.nav<{ $hide: boolean }>`
position: fixed;
z-index: ${(props) => props.theme.zIndex.fixed};
bottom: 0;

display: flex;
display: grid;
grid-template-rows: 2px 58px;
align-items: center;
justify-content: space-around;

Expand All @@ -17,43 +17,25 @@ export const Wrapper = styled.nav`

background: white;
box-shadow: 0 -1px 1px -1px ${(props) => props.theme.color.subLight};
`;

export const NavLink = styled(Link)`
height: 100%;
transform: translateY(${({ $hide }) => ($hide ? '60px' : '0')});
transition: transform 0.3s ease-out;
`;

export const NavButton = styled.button`
export const Button = styled.button`
height: 100%;
grid-row-start: 2;
`;

export const NavItemArea = styled.div<{ $active?: boolean }>`
display: flex;
align-items: center;
justify-content: center;

width: 64px;
height: 100%;

border-top: solid 2px
${({ $active, theme: { color } }) => ($active ? color.fontPrimaryForBackground : 'transparent')};
border-bottom: solid 2px transparent;
`;

export const NavItemCenter = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;

width: 100%;
height: 48px;
const move = (offset: number) => keyframes`
0% { transform: translateX(${offset}px) };
100% { transform: translateX(0) }
`;

export const NavLabel = styled.p<{ $active?: boolean }>`
font-size: 1rem;
font-weight: 700;
line-height: 1.5rem;
color: ${({ $active, theme: { color } }) =>
$active ? color.fontPrimaryForBackground : color.subLight};
export const Roof = styled.div<{ $position: number; $transitionOffset: number }>`
height: 2px;
grid-row-start: 1;
grid-column-start: ${({ $position }) => $position};
background-color: ${({ theme: { color } }) => color.fontPrimaryForBackground};
animation: ${({ $transitionOffset }) => move($transitionOffset)} 0.3s ease-out;
`;
104 changes: 59 additions & 45 deletions frontend/src/components/@common/Navbar/index.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R

전체적으로 Roof관련된 로직을 훅으로 분리하면 좀 더 깔끔할 것 같아요!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R

아까 말한 프레임 드랍도 같이 해결하면 좋을 것 같아요!

Copy link
Member Author

@WaiNaat WaiNaat Oct 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

근데 이걸 훅으로 분리한다고 관심사 분리가 잘 될지는 모르겠네요..
roof position 자체가 navbar 내용물 순서에 종속되어 있는데 이 내용들이 jsx에 하드코딩되어 있기 때문에 어디를 분리하는 게 맞는지 잘 모르겠어요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestAnimationFrame도 찾아보니 화면 주사율에 따라서 호출 횟수가 달라서 이동 시간을 모든 모니터에서 동일하게 맞출 수 있는 방법이 있을까요?

Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
import { useLocation, useNavigate } from 'react-router-dom';
import SvgStroke, { ICONS } from 'components/@common/SvgIcons/SvgStroke';
import { NavItemCenter, NavItemArea, NavLabel, NavButton, Wrapper, NavLink } from './Navbar.style';
import { useEffect } from 'react';
import { Link, matchRoutes, useLocation, useNavigate } from 'react-router-dom';
import { Button, Roof, Wrapper } from './Navbar.style';
import useAddToast from 'hooks/@common/useAddToast';
import useNavbarRoofAnimation from 'hooks/@common/useNavbarRoofAnimation';
import useCheckSessionId from 'hooks/queries/auth/useCheckSessionId';
import { URL_PATH } from 'constants/index';
import theme from 'style/theme.style';
import NavItem from './NavItem';

const NO_NAVIGATION_BAR_URLS = [
URL_PATH.petRegisterForm,
URL_PATH.dictDetail,
URL_PATH.petEdit,
URL_PATH.login,
URL_PATH.authorization,
].map((path) => ({ path }));

const Navbar = () => {
const location = useLocation();
const navigate = useNavigate();
const addToast = useAddToast();
const { pathname, state } = useLocation();

const addToast = useAddToast();
const { isSuccess: isLoggedIn } = useCheckSessionId(false);
const { navbarRef, roofPosition, transitionOffset } = useNavbarRoofAnimation(
state ? state.prevPathname ?? pathname : pathname,
pathname
);

useEffect(() => {
const resetHistoryState = () => history.replaceState(null, '');
window.addEventListener('beforeunload', resetHistoryState);
return () => window.removeEventListener('beforeunload', resetHistoryState);
}, []);

const goLogin = () => {
navigate(URL_PATH.login);
Expand All @@ -27,54 +46,49 @@ const Navbar = () => {
});
};

const NavItem = (props: { path: string; iconId: (typeof ICONS)[number]; label: string }) => {
const { path, iconId, label } = props;
const active = path === location.pathname;
const iconColor = active ? theme.color.fontPrimaryForBackground : theme.color.subLight;

return (
<NavItemArea $active={active}>
<NavItemCenter>
<SvgStroke color={iconColor} size={24} icon={iconId} />
<NavLabel $active={active}>{label}</NavLabel>
</NavItemCenter>
</NavItemArea>
);
};
const hideNavbar = matchRoutes(NO_NAVIGATION_BAR_URLS, pathname) !== null;
const newHistoryState = { prevPathname: pathname };

return (
<Wrapper>
<NavLink to={URL_PATH.main}>
<NavItem path={URL_PATH.main} iconId="home-line" label="메인" />
</NavLink>
<NavLink to={URL_PATH.garden}>
<NavItem path={URL_PATH.garden} iconId="bulletin-board-line" label="모두의 정원" />
</NavLink>
<Wrapper ref={navbarRef} $hide={hideNavbar}>
<Button as={Link} to={URL_PATH.main} state={newHistoryState}>
<NavItem isActive={roofPosition === 1} iconId="home-line" label="메인" />
</Button>
<Button as={Link} to={URL_PATH.garden} state={newHistoryState}>
<NavItem isActive={roofPosition === 2} iconId="bulletin-board-line" label="모두의 정원" />
</Button>
{isLoggedIn ? (
<>
<NavLink to={URL_PATH.reminder}>
<NavItem path={URL_PATH.reminder} iconId="reminder" label="리마인더" />
</NavLink>
<NavLink to={URL_PATH.petList}>
<NavItem path={URL_PATH.petList} iconId="leaf" label="내 식물" />
</NavLink>
<NavLink to={URL_PATH.myPage}>
<NavItem path={URL_PATH.myPage} iconId="account-circle-line" label="마이페이지" />
</NavLink>
<Button as={Link} to={URL_PATH.reminder} state={newHistoryState}>
<NavItem isActive={roofPosition === 3} iconId="reminder" label="리마인더" />
</Button>
<Button as={Link} to={URL_PATH.petList} state={newHistoryState}>
<NavItem isActive={roofPosition === 4} iconId="leaf" label="내 식물" />
</Button>
<Button as={Link} to={URL_PATH.myPage} state={newHistoryState}>
<NavItem
isActive={roofPosition === 5}
iconId="account-circle-line"
label="마이페이지"
/>
</Button>
</>
) : (
<>
<NavButton onClick={askLogin}>
<NavItem path={URL_PATH.reminder} iconId="reminder" label="리마인더" />
</NavButton>
<NavButton onClick={askLogin}>
<NavItem path={URL_PATH.petList} iconId="leaf" label="내 식물" />
</NavButton>
<NavLink to={URL_PATH.login}>
<NavItem path={URL_PATH.login} iconId="account-circle-line" label="로그인" />
</NavLink>
<Button type="button" onClick={askLogin}>
<NavItem isActive={false} iconId="reminder" label="리마인더" />
</Button>
<Button type="button" onClick={askLogin}>
<NavItem isActive={false} iconId="leaf" label="내 식물" />
</Button>
<Button as={Link} to={URL_PATH.login} state={newHistoryState}>
<NavItem isActive={false} iconId="account-circle-line" label="로그인" />
</Button>
</>
)}
{roofPosition && (
<Roof $position={roofPosition} $transitionOffset={transitionOffset} aria-hidden />
)}
</Wrapper>
);
};
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/components/@common/PageLoadingBar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useRecoilValue } from 'recoil';
import { Finish, Progressing } from './PageLoadingBar.style';
Expand All @@ -13,8 +13,6 @@ const PageLoadingBar = () => {
const { isOn: isShow, on: show, off: hide } = useToggle();
const { isOn: isShowFinish, on: showFinish, off: hideFinish } = useToggle();

const root = useMemo(() => document.getElementById('root')!, []);

useEffect(() => {
if (isShowPageLoading) {
show();
Expand All @@ -37,7 +35,7 @@ const PageLoadingBar = () => {
{isShow && <Progressing />}
{isShowFinish && <Finish $animationTime={FINISH_ANIMATION_TIME} />}
</>,
root
document.body
);
};

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/@common/Toast/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const Toast = (props: ToastItem) => {
setTimeout(() => {
setToastList((prev) => prev.filter(({ id: toastId }) => toastId !== id));
}, 200);
}, []);
}, [id, setToastList]);

const handleClickButton = () => {
onClickButton?.();
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/hooks/@common/useChildrenLeftPositions.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect, useRef } from 'react';

const useChildrenLeftPositions = <T extends HTMLElement>(domRef: React.RefObject<T>) => {
const childrenPositions = useRef<number[]>([]);

useEffect(() => {
const resizeObserver = new ResizeObserver(([entry]) => {
childrenPositions.current = Array.from(entry.target.children).map(
(child) => child.getBoundingClientRect().left
);
});

if (domRef.current) resizeObserver.observe(domRef.current);
return () => resizeObserver.disconnect();
}, [domRef]);

return childrenPositions.current;
};

export default useChildrenLeftPositions;
11 changes: 7 additions & 4 deletions frontend/src/hooks/@common/useIntersectionRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { useCallback, useMemo } from 'react';
import createObserver from 'utils/createObserver';

const useIntersectionRef = (onIntersecting: () => void) => {
const observer = useMemo(() => createObserver(onIntersecting), []);
const observer = useMemo(() => createObserver(onIntersecting), [onIntersecting]);

const intersectionRef = useCallback(<T extends Element>(instance: T | null) => {
if (instance) observer.observe(instance);
}, []);
const intersectionRef = useCallback(
<T extends Element>(instance: T | null) => {
if (instance) observer.observe(instance);
},
[observer]
Comment on lines +7 to +11
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

크~ 멋있ㅅ습니다

);

return intersectionRef;
};
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/hooks/@common/useNavbarRoofAnimation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { isShowPageLoadingState } from 'store/atoms/@common';
import { URL_PATH } from 'constants/index';
import useChildrenLeftPositions from './useChildrenLeftPositions';

const getRoofPosition = (pathname: string) => {
switch (pathname) {
case URL_PATH.main:
return 1;
case URL_PATH.garden:
return 2;
case URL_PATH.reminder:
return 3;
case URL_PATH.petList:
return 4;
case URL_PATH.myPage:
return 5;
default:
return null;
}
};

const getAnimationOffset = (prevPathname: string, currentPathname: string, positions: number[]) => {
const prevRoofPosition = getRoofPosition(prevPathname);
const roofPosition = getRoofPosition(currentPathname);

const transitionOffset =
roofPosition && prevRoofPosition
? positions[prevRoofPosition - 1] - positions[roofPosition - 1]
: 0;

return transitionOffset !== 0 ? transitionOffset + Math.random() : 0;
};

const useNavbarRoofAnimation = (prevPathname: string, currentPathname: string) => {
const navbarRef = useRef<HTMLElement>(null);
const isPageLoading = useRecoilValue(isShowPageLoadingState);

const navItemPositions = useChildrenLeftPositions(navbarRef);

const roofPosition = getRoofPosition(isPageLoading ? prevPathname : currentPathname);
const transitionOffset = !isPageLoading
? getAnimationOffset(prevPathname, currentPathname, navItemPositions)
: 0;

return { navbarRef, roofPosition, transitionOffset };
};

export default useNavbarRoofAnimation;
Loading