AndChill 개발일지(5) - 홈 화면(영화 목록, carousel 구현)

dali·2024년 8월 14일
0

And Chill 개발 기록

목록 보기
5/9
post-thumbnail

홈 화면에서는 다음 항목들을 보여줍니다.

  • 선택한 국가의 개봉 예정 영화 목록
  • 이번 주 글로벌 트렌드 영화 목록
  • 랜덤 장르 영화 목록들

개봉 예정 영화 목록


개봉 예정 영화 목록을 볼 때, 사람들이 가장 알고 싶은 정보가 뭘까요? 아마 개봉 예정 일자와 이 영화가 얼마나 기대작인지를 알고 싶을 겁니다. 다행히 제가 사용하는 api는 커뮤니티 기반 api이기 때문에 사람들이 특정 영화를 얼마나 기대하고 있는지 여부도 수치화를 해서 제공해주고 있었습니다!

홈 화면 최상단에 위치할 중요한 section이기 때문에 포스터 형식의 다른 리스트들과는 차별되게 보여줄 필요가 있었습니다. 개봉 예정 영화인만큼 기대감을 불러일으키기 위해 포스터 보다는 가로가 긴 백드롭(배경) 이미지를 사용했습니다.

그리고 popularity 수치는 label처럼 우측 상단에 위치시켰고, 개봉까지 남은 일자를 보여주는 디데이 바는 도화선이 타는 것처럼 표현했습니다. 디데이 바에서는 개봉까지 남은 일자를 영화 리스트 아이템에서 prop으로 받은 후 width 퍼센트 값과 애니메이션에 사용했습니다.

개봉 예정 영화 리스트 아이템

const UpcomingMovieItem = ({ data }: TUpcomingMovieItemProps) => {
  const navigate = useNavigate();
  const daysLeft = calculateLeftDays(data.release_date);
  const backDropImageUrl = data.backdrop_path
    ? getImage(IMAGE_SIZE.backdrop_sizes.size01, data.backdrop_path)
    : '/andchill-logo.png';

  return (
    <li onClick={() => navigate(`/${data.id}`)}>
      <S.Container>
        <S.MovieImage src={backDropImageUrl} className="scale-on-hover" />
        <S.UpcomingLabel>{Math.round(data.popularity)} 만큼 기대중</S.UpcomingLabel>
        <S.MovieReleaseDate>{data.release_date}</S.MovieReleaseDate>
        <S.MovieTitle>{data.title}</S.MovieTitle>
        // 디데이 바
        {daysLeft <= 105 && daysLeft >= 0 && <ReleaseDateBar daysLeft={daysLeft} />}
        {daysLeft < 0 && <S.RereleaseLabel>재개봉작</S.RereleaseLabel>}
      </S.Container>
    </li>
  );
};
export default UpcomingMovieItem;

디데이 진행바(도화선)

const ReleaseDateBar = ({ daysLeft }: TReleaseDateBarProps) => {
  return (
    <React.Fragment>
      <S.ReleaseDateBar></S.ReleaseDateBar>
      <S.ProgressBar $percentage={100 - daysLeft}>
        <S.FireIcon src={fireIcon} alt="불 아이콘" />
        <S.DdayLabel>D-{daysLeft}</S.DdayLabel>
      </S.ProgressBar>
    </React.Fragment>
  );
};

const animateWidth = keyframes`
  from {
    width: 0;
  }
  to {
    width: var(--percentage);
  }
`;

const S = {
  ProgressBar: styled.div<{ $percentage: number }>`
    --percentage: ${({ $percentage }) => `${$percentage - 5}%`};
    position: absolute;
    width: var(--percentage);
    animation: ${animateWidth} 2s cubic-bezier(0.3, 0, 0.2, 1);
    background: linear-gradient(...) no-repeat;
    ...
  `,
    ...

선택된 국가 store를 사용해서 업데이트 해주면 각 국가의 개봉 예정 영화들을 볼 수 있습니다!

  // 개봉 예정 영화 리스트 component
  const region = useRegionStore((state) => state.region);
  const language = 'ko';
  const { data: upcomingMovieData, isLoading } 
  			= useUpcomingMovieListQuery(region, language);
  ...

트렌드, 랜덤 장르 영화 목록


해당 리스트는 일반적인 포스터를 나열하는 형식의 리스트로 보여줍니다.

트렌드 영화 목록과 랜덤 장르 영화 목록은 디자인이 똑같고 타이틀과 데이터만 다르기 때문에 공통 component에서 분기했습니다.

트렌드 영화 목록은 부모 컴포넌트에서 데이터 요청 후 prop으로 전달했고, 랜덤 장르 영화들은 component안에서 랜덤으로 장르 선택 후 데이터를 요청했습니다.

  return (
    <S.Container>
      //개봉 예정
      <UpcomingMovieList />
      
      //트렌트
      <MovieList
        title="이번 주 🌎 트렌드"
        trendingMovieData={trendingMovieData}
        isTrendingMovieLoading={isTrendingLoading}
      />
      
      //랜덤 장르
      <MovieList /> 
      <MovieList />
      <MovieList />
    </S.Container>
  );

영화 리스트 아이템

처음 기획 때부터 각 영화 리스트 아이템에 마우스 hover시 영화 정보를 간략하게 보여주면 좋겠다는 생각이 있었습니다. 영화의 정보를 확인하려고 매번 클릭해서 세부 정보 페이지로 들어가기가 귀찮았던 기억이 있었거든요.

무슨 장르인지, 영화 제목, 그리고 시놉시스 정도만 있으면 될 것 같아서 이 3개 정보를 보여주기로 하였습니다. 시놉시스는 대부분 길이가 어느정도 있어서 이 조그만 div에 집어넣기는 무리입니다.

그래서 영화의 엔딩 크레딧이 올라가는 장면처럼 ui를 디자인하였습니다.

CSS 코드의 일부분 입니다.

// 너무 끝까지 올라가서 하단 공백이 생길 필요가 없기 때문에 적절하게 조절
const scrollCredits = keyframes`
  0% {
    top: 40%;
  }
  100% {
    top: -100%;
  }
`;

...

// hover 시 표시될 div
MovieLightInfo: styled.div`
    ...
    opacity: 0;
    transition: 0.4s ease-in-out;

    &:hover {
      background-color: rgba(0, 0, 0, 0.8);
      opacity: 1;
      div {
        padding: 15px;
        position: absolute;
        animation: ${scrollCredits} 10s linear infinite;
      }
    }
`,

랜덤 장르 선택

처음에는 하나의 장르만 랜덤으로 선택해서 보여주었습니다. 장르는 15개 정도 있었지만 이 중 한 개의 장르만 뽑고 3개 리스트에서 랜덤으로 돌리다 보니 겹치는 빈도수도 많아졌고, 페이지 새로고침 시 이미 보았던 장르가 나오는 경우도 많았습니다.

그래서 2개의 장르를 선택하는 방식으로 바꾸었습니다

export const getRandomGenre = (genres: TGenre[] | undefined): TGenre[] | undefined => {
  if (genres) {
    const firstIdx = Math.floor(Math.random() * genres.length);

    let secondIdx: number;
    do {
      secondIdx = Math.floor(Math.random() * genres.length);
    } while (secondIdx === firstIdx);

    return [genres[firstIdx], genres[secondIdx]];
  }
};

훨씬 다양한 경우의 수의 조합을 볼 수 있습니다!


carousel 전체 코드 보기

각 영화 리스트 슬라이드를 스와이프 할 수 있는 carousel을 라이브러리를 사용하지 않고 구현해 보았습니다. 리스트 양 옆 버튼은 첫 더 이상 스와이프 할 수 없는 index에서는 보이지 않도록 했고, 버튼 클릭 시 리스트의 현재 index를 업데이트 시켜 해당 아이템의 길이만큼 x축으로 이동을 시켜주었습니다.

transform: ${({ $curIndex }) => `translateX(-${$curIndex * 238}px)`};

다음은 MovieList component의 carousel구현 부분 코드입니다.

const PER_SLIDE = 2; // 한 번에 슬라이드할 때 이동할 영화 항목의 개수를 설정하는 상수

const MovieList = ({ title, trendingMovieData, isTrendingMovieLoading }: TMovieListProps) => {
   // 현재 슬라이드의 인덱스를 관리하는 state
  const [currentIndex, setCurrentIndex] = useState(0);
  
  ... // 데이터 처리 코드
  
  // 영화 리스트의 총 길이를 계산
  const length = movieData?.results.length || 0;

  // 버튼 클릭 시 현재 인덱스를 증가시켜 슬라이드를 다음으로 이동
  const handleNext = () => {
    if (currentIndex < length - PER_SLIDE) {
      setCurrentIndex((prev) => prev + PER_SLIDE);
    }
  };

  // 버튼 클릭 시 현재 인덱스를 감소시켜 슬라이드를 이전으로 이동
  const handlePrev = () => {
    if (currentIndex > 0) {
      setCurrentIndex((prev) => prev - PER_SLIDE);
    }
  };

  // 영화 리스트를 렌더링하는 함수, 각 영화 항목을 순회하며 컴포넌트로 렌더링
  const renderMovieList = () => (
    <S.MovieList $curIndex={currentIndex}>
      {movieData?.results.map((movie: TMovieListsItem) => (
        <li key={movie.id}>
          <MovieItem data={movie} />
        </li>
      ))}
    </S.MovieList>
  );

  return (
    <S.Container>
       ...
    
      <S.MovieListWrapper>{renderMovieList()}</S.MovieListWrapper>
                           
      // 이전 슬라이드로 이동하는 버튼                     
      <S.PrevButton 
        onClick={handlePrev} 
        $curIndex={currentIndex}
      >
        <img src={leftRight} />
      </S.PrevButton>

      // 다음 슬라이드로 이동하는 버튼    
      <S.NextButton 
		onClick={handleNext} 
		$curIndex={currentIndex} 
		$totalLength={length} 
		$perSlide={PER_SLIDE}
      >
        <img src={arrowRight} />
      </S.NextButton>
    </S.Container>
  );
};

export default MovieList;

// 기본 버튼 스타일
const Button = styled.button`
  opacity: 0; // hover 전에는 투명하게 설정
  position: absolute;
  ...
`;

const S = {
    ...
   // 영화 리스트의 스타일, 현재 인덱스에 따라 슬라이드가 이동됨
   MovieList: styled.ul<{ $curIndex: number }>`
    ...
    // 현재 인덱스에 따라 X축 방향으로 슬라이드
    transform: ${({ $curIndex }) => `translateX(-${$curIndex * 238}px)`}; 
     
    // hover 시 버튼이 나타남
    &:hover ${Button} {
      opacity: 0.8;
    }
  `,
  
  // 이전 버튼의 스타일, 슬라이드 인덱스에 따라 숨김 여부를 결정
  PrevButton: styled(Button)<{ $curIndex: number }>`
    ...
    // 첫 슬라이드일 때 버튼 숨김
    visibility: ${(props) => props.$curIndex <= 0 && 'hidden'};
  `,
  
  // 다음 버튼의 스타일, 슬라이드 인덱스 및 총 길이에 따라 숨김 여부를 결정
  NextButton: styled(Button)<{ $curIndex: number; $totalLength: number | undefined; $perSlide: number }>`
    ...
    // 마지막 슬라이드일 때 버튼 숨김
    visibility: ${(props) => props.$totalLength && props.$curIndex >= props.$totalLength - props.$perSlide && 'hidden'};  
  `,
};

0개의 댓글

관련 채용 정보