ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Portfolly] 메인 페이지 UI/UX를 개선해보자(feat. mobbin)
    Studying/Proj 과정 2023. 12. 24. 01:24

    문제 상황

    기존 Portfolly 홈페이지의 UI를 조금 더 사용자 친화적으로 개선시키는 과정을 기록합니다.

     

    아래는 기존 Portfolly의 메인 화면입니다.

    앱/웹/일러스트/사진/비디오 5개 카테고리 별 포트폴리오 목록을 구경할 수 있습니다. 

     

    기존 Portfolly 메인 페이지 UI

    그런데 급하게 설계한 UX/UI라 아쉬운 점이 몇가지 있습니다.

     

    가장 큰 문제는 홈페이지에 가장 처음 들어왔을 때 홈페이지의 용도가 잘 보이지 않는 것 같았습니다.

     

    따라서 기존에 존재하는 mobbin 이라는 홈페이지를 참고해 UX/UI를 개선시키려고 합니다.

     

     

     

     

    개선 과정

    포트폴리오 캐러셀 썸네일

    첫 번째는 포트폴리오 썸네일 입니다. 

     

    기존 Portfolly 썸네일도 mobbin을 참고한 거라,

    앱과 웹 포트폴리오 썸네일이 각각 가로세로 비율이 다릅니다.

     

    mobbin - ios 썸네일

    그런데 이왕 개선할 거 mobbin의 캐러셀 썸네일까지 따라해보자! 싶어 한 번 만들어보려고 합니다.

     

     

    react-slick을 사용해 캐러셀 만들기

    저는 캐러셀 라이브러리로 react-slick을 선택했습니다.

     

    react-slick과 swiper 비교

    다운로드 현황 그래프를 보면 swiper가 훨씬 사용률이 높으나, react 외 환경에서도 공급되는 걸 생각하면 비슷한 수요라고 생각됩니다.

     

    저의 경우 swiper가 반응형도 간편하고 좋아 보여서 사용하려 했으나, 특정 버튼을 슬라이더랑 분리시키고 싶었는데 잘 안 되어서 react-slick으로 변경했습니다.

     

    다만 react-slick은 딱 정해진 css가 있어서 그걸 바꾸는 게 조금 귀찮았습니다.

     

     

    일단 다음과 같이 Slider 컴포넌트를 불러옵니다.

    import Slider from "react-slick";
    import 'slick-carousel/slick/slick.css';
    import 'slick-carousel/slick/slick-theme.css';

     

     

    react-slick은 제공하는 옵션에 맞춰 설정한 뒤 컴포넌트에 전달합니다.

    저는 다음과 같이 옵션을 설정했습니다.

    export const sliderSettings: Settings = {
    	dots: true, // 슬라이더 하단에 점 버튼
    	infinite: false, // 무한 슬라이더인지
    	speed: 200, // 슬라이딩 속도
    	slidesToShow: 1, // 한 페이지 당 보여줄 슬라이드
    	slidesToScroll: 1, // 버튼 클릭 한 번에 넘어가는 슬라이드 수
    	draggable: false, // 마우스 휠로 넘길 수 있는지 여부
    	fade: false, // 슬라이드 넘길 때 슬라이드 아이템이 fade되는 여부
    	arrows: false, // 화살표 버튼 여부
    	vertical: false, // 슬라이드 방향
    	initialSlide: 0, // 첫 번째로 보여줄 슬라이드 인덱스
    	responsive: [ // 반응형 옵션
            {
                breakpoint: 960, // 화면 사이즈 960px일 때
                settings: {
                    dots: false,
                    arrows: false,
                }
            }
    	]
    };

     

     

     

    옵션을 설정한 뒤 다음과 같이 <Slider />를 불러와 <SliderItem />을 채웠습니다.

    <Slider {...sliderSettings} ref={sliderRef}>
        { section !== 'Video' && portfolio.images.length > 0 &&
            portfolio.images.map((url: string, index: number)=>{
                if(index > 2) return;
                return (
                    <S.SliderItem key={index}>
                            <Image src={url} size='100%' alt='slider image' />
                    </S.SliderItem>
                );
        })}
        { section === 'Video' &&
            <S.Video src={portfolio.videos[0]} />
        }
    </Slider>

     

    포트폴리오 썸네일은 크게 이미지/비디오 2개 종류로 나뉘어서, 이미지일 땐 최대 3장의 이미지 썸네일을 슬라이드로 보여주고, 비디오인 경우 비디오 1개를 슬라이드로 보여줍니다.

     

     

    커스텀 화살표 버튼

    저는 react-slick에서 기본 제공하는 화살표 버튼을 사용하지 않고 커스텀 했습니다.

     

    react-slick 옵션에서 arrow: false로 지정해주고, 다음과 같이 직접 버튼 컴포넌트를 생성합니다.

    <S.ArrowBox onClick={eventStopPropagation}>
        <S.PrevArrow
            color='gray'
            onClick={handlePrev}
            $showPrevArrow={showPrevArrow}
        >
            <LeftArrowIcon size={16} />
        </S.PrevArrow>
        <S.NextArrow
            color='gray'
            onClick={handleNext}
            $showNextArrow={showNextArrow}
        >
            <RightArrowIcon size={16} />
        </S.NextArrow>
    </S.ArrowBox>

     

    handleNext, handlePrev 핸들러는 useHandleSlider 훅을 별도로 분리한 뒤 그곳에서 가져왔습니다.

     

    그런데 여기서 문제가 발생했습니다.

     

    mobbin의 경우 버튼을 빠르게 더블 클릭하면 슬라이드가 빠르게 넘어가는데,

    react-slick의 경우 슬라이드가 넘어가는 speed가 지정되어있어 버튼이 작동하는 타이밍과 슬라이드 움직임이 동일하지 않았습니다.

    speed를 0으로 바꾸는 방법도 있지만, 부드럽고 빠르게 넘어가는 디테일을 살리고 싶었습니다.

     

    그에 대한 해결 과정을 아래 이슈에 정리해두었습니다.

     

    PortfolioItem 캐러셀 Prev, Next Arrow 버튼 더블 클릭 버그 · Issue #9 · Kim-DaHam/Portfolly

    Bug Report 개요 PortfolioItem.tsx 컴포넌트의 캐러셀에서 Prev/Next 버튼에 더블 클릭이 적용되지 않는 문제가 발생했습니다. 여러번 빠르게 클릭한 만큼 캐러셀이 빠르게 넘어가지 않습니다. 사용한 캐

    github.com

     

     

    결론적으로 다음과 같은 PortfolioSlider를 완성했습니다 :)

     

     

     

     

    마우스 hover 북마크 버튼

    썸네일을 만들었으니 사용자 프로필, 포트폴리오 제목 등을 덧붙인 PortfolioCard를 만들어야 합니다.

     

    PortfolioList > PortfolioCard(슬라이더 썸네일 + 포트폴리오 제목) > PortfolioSlider(슬라이더 썸네일)

     

    과 같은 구조입니다.

     

     

    mobbin의 경우 다음과 같이 마우스를 hover하면 북마크 버튼과 기타 메뉴 버튼이 나타납니다.

     

     

    여기서 그대로 살리고 싶은 디테일한 2가지가 있었습니다.

     

    1. 마우스를 hover 해서 북마크 버튼이 나타났을 때, 게시물 제목/설명 글자가 ... ellipsis 처리 된다.

     

    2. ... 아이콘 버튼을 클릭했을 때 나오는 Popper의 경우, 하단에 공간이 있으면 버튼 아래 나타나고, 공간이 없으면 위에 나타난다.

     

    PortfolioCard.tsx는 위에서 만든 PortfolioSlider.tsx + 마우스 hover 메뉴가 추가된 Profile.tsx로 이루어집니다.

     

    <S.Wrapper {...props}>
        <PortfolioSlider
            section={currentSection}
            portfolio={portfolio}
        />
    
        <S.ProfileBox>
            <Profile
                type='portfolio-card'
                portfolio={portfolio}
                user={portfolio.user}
            />
    
            {/* 마우스 hover시 나오는 버튼 그룹 */}
            <S.ButtonGroup className='button-group' ref={buttonGroupRef}>
                <ToggleButton
                    type='bookmark'
                    isToggled={portfolio.isBookmarked}
                    portfolioId={portfolio.id}
                />
    
                <Button onClick={popUp} color='gray'>
                    <MoreIcon/>
                </Button>
            </S.ButtonGroup>
    
            {/* ... 버튼 클릭 시 나오는 Popper */}
            <Popper
                $popperState={isPopUp}
                coordinate={coordinate}
                popOut={popOut}
            >
                <Group>
                    <Link to='' onClick={handleCopy}>
                        URL 복사하기
                    </Link>
                </Group>
            </Popper>
        </S.ProfileBox>
        </S.Wrapper>

     

    까다로운 부분은 바로 ButtonGroup이었습니다.

     

    그냥 CSS를 잘 적용하면 되는데, 그게 또 생각대로 바로바로 만들어지지 않았습니다😅;;

     

    그냥 ButtonGroup의 visibillity: hidden 으로 했다가 hover 시 visible로 바꾸면 된다고 단순하게 생각했습니다.

     

    하지만 이때 간과해서는 안 되는 점이 있습니다.

     

    ellipsis 되는 모습

    바로 북마크 버튼이 나타났을 때 좌측 제목/설명 글자가 차지하는 공간이 줄어들어 ellipsis 처리가 되어야 한다는 점 입니다.

     

    visibillity 속성은 '요소를 보여줄지 말지'에 대한 속성이기 때문에, DOM이 생성되어 레이아웃에 영향을 줍니다.

     

    마우스가 hover되지 않은 상태에서는 제목/설명란이 full width를 가져야 하는데, 잘못된 접근 방법이었던 겁니다.

     

    따라서 display 속성을 사용해 처음엔 DOM으로 생성되지 않다가, hover되면 생성되게 해야합니다.

     

    ButtonGroup은 flex로 가로 정렬 되어야 하므로 다음과 같이 hover 시 display:flex를 설정해줬습니다.

    &:hover {
        & .button-group {
            display: flex;
        }
    
        & span:first-child { // 포트폴리오 제목
            text-decoration: underline;
        }
    }

     

     

    그런데 또 여기서 끝이 아닙니다.

     

    위와 같이 설정하고 그냥 <Profile />의 span에 text-overflow: ellipsis를 추가한다고 글자가 요약되지 않습니다.

     

    PortfolioCard의 ProfileBox 구조

    현재 ProfileBox의 구조는 위와 같이 display:flex 상태이고,

    그 안에 또 ButtonGroup이 비집고 들어와 외부 컴포넌트인 <Profile />이 flexible하게 줄어드는 방식입니다.

     

    ellipsis가 적용되는 기준은 요약할 글자 요소가 display: block이어야 하고,

    "글자가 width를 넘어가는가?" 라는 조건을 충족했을 때인데,

    보통 display: flex 속성은 고정된 width를 잘 설정하지 않는 편입니다.

     

    게다가 <Profile />은 북마크 버튼이 나타나지 않았을 땐 100%를 차지해야 하고, 북마크 버튼이 나타났을 땐 줄어들어야 합니다.

     

    저는 다음과 같이 문제를 해결했습니다.

    // PortfolioCard의 <Profile /> 구조
    <S.PortfolioCardProfileWrapper>
        <Image
            size='2.6rem'
            src={user?.profileImage}
            alt='user profile'
            shape='circle'
        />
        <S.SpanBox onClick={()=>navigate(`/portfolios/${portfolio?.id}`)}>
            <Text size='label'>{portfolio?.title}</Text>
            <Text size='bodySmall' color='gray'>{user?.nickname}</Text>
        </S.SpanBox>
    </S.PortfolioCardProfileWrapper>
    export const SpanBox = styled.div`
    	width: 100%;
    	min-width: 0;
    	height: 100%;
    
    	${mixins.flexColumn}
    	justify-content: space-around;
    	flex-shrink: 1;
    
    	& span {
    		width: 99%;
    		display: block;
    
    		overflow: hidden;
    		text-overflow: ellipsis;
    		white-space: nowrap;
    	}
    `;

     

    기본 width가 100%이되 min-width를 0으로 해서, 이만큼 줄어들 수도 있다는 걸 명시합니다.

     

    그리고 flex-shrink: 1 은 기본값이라 딱히 지정하지 않아도 되지만, CSS 파일만 봤을 때 이 스타일드 컴포넌트는 flex 자식 요소인데, 차지하는 공간에 따라 줄어들 수도 있구나 라는 걸 파악하기 위해 적어두었습니다.

     

    중요한 건 min-width: 0display: none이 되겠습니다.

     

     

    결론적으로 다음과 같은 결과물이 완성되었습니다.

     

     

     

     

    공용 Popper 컴포넌트

    ... 버튼을 클릭했을 때 나오는 Popper

     

    이제 북마크 버튼 옆에 있는 ... 버튼을 클릭했을 때 나오는 Popper 입니다.

     

    Popper는 ... 버튼 뿐만 아니라 다른 메뉴 버튼에도 자주 쓰이기 때문에, 재사용 컴포넌트로 만들었습니다.

     

    Popper에는 다음과 같은 포인트가 있습니다.

     

    1. Popper를 오픈하는 버튼 하단에 우측 정렬되어 나타난다.

     

    2. Popper가 나타나면 스크롤이 금지된다.

     

    3. Popper가 오픈되고, 바탕 영역을 눌러 제거될 때 fade-in fade-out 효과가 나타난다.

     

    처음부터 차근차근 정리해보겠습니다.

     

     

    버튼 하단에 우측 정렬되어 나타나게 하기

    Popper.tsx 컴포넌트는 다음과 같은 구조를 띄고 있습니다.

    return createPortal(
        <S.Wrapper
            onClick={popOut}
            $popperState={$popperState}
        >
            <S.PopperBox
                $top={coordinate.bottom}
                $right={coordinate.right}
                onClick={eventStopPropagation}
            >
                {children}
            </S.PopperBox>
        </S.Wrapper>,
        document.getElementById('modal')!
    )

     

    여기서 createPortal이란, 컴포넌트를 기본 부모 요소인 root가 아닌, 다른 위치에 렌더링 하게 해주는 React  API입니다.

     

    아래 elements 구조를 보면 Popper를 화면에 띄웠을 때 id=root 부모의 자식에 포함되는 게 아닌,

    id=modal 이라는 제가 별도로 추가한 부모 요소 하위에 Popper 엘리먼트가 생성됩니다.

     

    Portal 컴포넌트 생성 위치 확인

    자세한 설명은 아래 공식 문서를 참고할 수 있습니다.

     

     

    Portals – React

    A JavaScript library for building user interfaces

    ko.legacy.reactjs.org

     

     

    저는 Popper가 '뷰포트 전체를 덮는다' 라는 특성을 가지기 때문에 부모의 자식의 자식의 자식 컴포넌트... 안에 줄줄이 생성하는 건 의미에 부합하지 않다고 판단했습니다.

     

    컴포넌트 위치는 부모 저 깊숙히 있는데, z-index를 사용해 부모 컴포넌트를 다 덮는 건 뭔가 컴포넌트가 가지는 특징에서 벗어나는 구조 같았습니다.

     

    하여튼 그래서 Portals를 활용했습니다.

     

    Popper를 사용하는 방법은 usePopup이라는 커스텀 훅을 만들어 간단하게 호출하도록 했습니다.

     

    Popper를 불러오는 컴포넌트에서 다음과 같이 usePopup을 호출해 Popper를 오픈하고 닫는 데 필요한 함수와 값들을 받아옵니다.

    const { isPopUp, coordinate, popUp, popOut } = usePopup();

     

    차례대로 의미는 다음과 같습니다.

     

    isPopup - Popper 오픈 여부 boolean 값

    coordinate - Popper가 위치할 좌표

    popUp - Popper 열기 핸들러

    popOut - Popper 닫기 핸들러

     

    여기서 coordinate가 Popper를 원하는 위치에 생성하는 데 가장 중요한 역할을 합니다.

     

    Popper의 위치는 다음과 같이 구할 수 있습니다.

    const getCoordinates = (button: HTMLElement)=> {
        const clientHeight = document.body.clientHeight; // 뷰포트 높이
        const coordinates = { // 클릭한 버튼의 right, bottom 좌표
            right: button.getBoundingClientRect().right,
            bottom: button.getBoundingClientRect().bottom,
        }
    
    	// Popper가 버튼 아래 나타날 공간이 있는지 확인
        const isThereNoUnderPlaceToPopUp = screen.height - coordinates.bottom < 200;
    
    	// 버튼 아래 공간이 없다면 버튼 위에 생성
        const popUpAtUpperPlace = (coordinates: {right:number, bottom:number})=>{
            if(clientHeight !== 0)
                coordinates.bottom = button.getBoundingClientRect().top - 80;
        }
    
        if(isThereNoUnderPlaceToPopUp){
            popUpAtUpperPlace(coordinates);
        }
    
    	// 최종 좌표 반환
        return {
            right: coordinates.right,
            bottom: coordinates.bottom,
        }
    }

    클릭한 버튼의 right, bottom을 구한 뒤 Popper가 위치할 자리를 정해줍니다.

     

    기본적으로 Pooper는 버튼 하단에 생성되는데, 이때 하단에 Popper를 띄울 공간이 없을 수도 있습니다.

     

    이런 상태에서 Popper를 띄우면 사용자는 하단에 잘린 Popper를 보기 위해 스크롤을 내려야 하는 불편이 생깁니다.

     

    따라서 뷰포트에 Popper를 그릴 적당한 공간이 없으면 버튼 상단에 띄우도록 만들었습니다.

     

    코드를 한번에 보여주기 위해 popUpAtUpperPlace 함수를 getCordinates 함수 내부에 정의했는데, 후에 바깥으로 빼는 게 좋겠습니다.

     

     

    Popper 오픈 시 스크롤 막기

    Popper가 오픈되면 스크롤을 막고, Popper가 닫히면 다시 스크롤을 활성화해야 합니다.

     

    이는 usePopup 커스텀 훅이 반환하는 popUp, popOut 함수에서 동작합니다.

     

    먼저 popUp 함수입니다.

    const popUp = (event:React.MouseEvent<HTMLButtonElement, MouseEvent> | MouseEvent) => {
        const menuButton = event.currentTarget as HTMLElement;
    
        setCoordinate(prev => ({
            right: getCoordinates(menuButton).right,
            bottom: getCoordinates(menuButton).bottom,
        }))
    
        stopScrollY();
        setIsPopUp(prev=>!prev);
    }

    클릭한 버튼 엘리먼트를 getCoordinates에 넘겨준 다음 setCoordinate 합니다.

     

    그리고 stopScrollY() 함수를 호출해 스크롤을 막습니다.

    export const stopScrollY = () => {
    	document.body.style.cssText = `
    		width: 100%;
    		position: fixed;
    		top: -${window.scrollY}px;
    		overflow-y: scroll;
    		overflow: hidden;
    	`;
    };

     

    popOut 함수는 반대로 moveScrollY() 함수를 호출합니다.

    export const moveScrollY = () => {
    	const scrollY = document.body.style.top;
    	document.body.style.cssText = '';
    	window.scrollTo(0, parseInt(scrollY || '0', 10) * -1);
    };

     

     

     

    Popper 열고 닫을 때 애니메이션 추가하기

    이제 Popper 애니메이션만 추가하면 어디서든 사용할 수 있는 Popper 컴포넌트가 완성됩니다.

     

    그런데 이 애니메이션 적용에도 신경 쓸 문제가 있었습니다.

     

    처음에 저는 Popper를 호출할 때 다음과 같이 부모 컴포넌트에서 isPopup boolean 값에 따라 조건부 렌더링을 했습니다.

    {isPopup &&
    	<Popper />
    }

     

    그리고 Popper 컴포넌트에 다음과 같이 애니메이션을 적용했습니다.

    ${(props) => props.$popperState ?
        css`animation: ${fadeIn} 0.1s 0s 1 normal forwards;`
    :
        css`animation: ${fadeOut} 0.1s 0s 1 normal forwards;`
    };

     

    그랬더니 Popper가 오픈될 때는 애니메이션이 적용되어 부드럽게 fadeIn되는데,

     

    Popper를 닫을 땐 눈 깜짝할 새 없이 단번에 사라졌습니다.

     

    당연한 일이었습니다. isPopupfalse가 되었을 때 Popper는 렌더링되지 않기 때문이었습니다.

    애니메이션을 적용할 새도 없었던 겁니다.

     

    따라서 저는 Popper를 부모 컴포넌트에서 조건부 렌더링으로 표현하지 않고,

    Popper 컴포넌트 자체에서  열림과 닫힘을 관리하게 만들었습니다.

     

    바로 다음과 같이 구현했습니다.

    export default function Popper({ children, coordinate, $popperState, popOut}: Props) {
    	const [isPopUp, setIsPopUp] = useState($popperState);
    
    	useEffect(() => {
        if($popperState) {
          setIsPopUp(true);
    			return;
        }
    
    		const popperTimer = setTimeout(() => {
    			setIsPopUp(false);
    		}, 200);
        return () => {
    			clearTimeout(popperTimer);
        };
      }, [$popperState]);
    
    	if(!isPopUp) return null;
    
    	return createPortal(
    		<S.Wrapper
    			onClick={popOut}
    			$popperState={$popperState}
    		>
    			<S.PopperBox
    				$top={coordinate.bottom}
    				$right={coordinate.right}
    				onClick={eventStopPropagation}
    			>
    				{children}
    			</S.PopperBox>
    		</S.Wrapper>,
    		document.getElementById('modal')!
    	)
    }

     

    Popper를 호출하는 부모 컴포넌트로부터 popperState(popper의 열림 닫힘을 파악하는 boolean값)를 받습니다.

     

    마치 부모 컴포넌트에서 popperState라는 매개변수를 통해 "Popper를 닫아!" "Popper를 열어!"라고 명령하면,

     

    Popper 컴포넌트가 열고 닫힘을 직접 관리하는 겁니다.

     

    이때 포인트는 부모 컴포넌트가 false인 popperState를 보내 Popper를 닫으라고 전달 했을 때, 

    Popper는 자신을 바로 닫지 않고, popperTimer를 작동시켜 fadeOut 애니메이션이 작동할 여유시간을 제공하고 닫히게 됩니다.

     

    따라서 부드럽게 열고 닫히는 이벤트가 모두 작동하게 됩니다! :D

     

    결과 모습입니다.

     

     

     

     

    스켈레톤 UI 적용하기

    이제 마지막 단계로 스켈레톤 UI를 적용해줍니다.

     

    MainPage.tsx에서 다음과 같이 비동기 데이터인 포트폴리오 목록을 불러오는 PortfolioCardList 지연 로딩하고,

    const PortfolioCardList = lazy(() => import('@/components/organisms/portfolio-list/PortfolioList'));

     

    Suspense fallback으로 PortfolioListSkeleton을 불러오면 됩니다!

    <Suspense fallback={<PortfolioListSkeleton type='portfolio-card' />}>
        <PortfolioCardList filter={filter} />
    </Suspense>

     

    이때 유의할 점은 저와 같이 reac-query를 쓸 경우 일반 useQuery 훅으로 데이터를 불러오는 게 아닌, useSuspenseQuery 훅을 사용해야 한다는 점 입니다.

     

    저의 경우 무한스크롤까지 추가해서 useSuspenseInfiniteQuery 훅을 호출합니다.

     

    아래 react-query 버전 5 공식 문서를 살펴 바뀐 문법을 잘 적용하면 좋겠습니다.

     

    Migrating to TanStack Query v5 | TanStack Query Docs

     

    tanstack.com

     

     

    결과 모습입니다.

     

     

     

     

    개선 결과

    최종적으로 반응형 미디어 쿼리까지 등록해서 PC, 태블릿, 앱 3가지 환경에서 메인 페이지를 확인할 수 있도록 만들었습니다!

     

     

Designed by Tistory.