[ReactNative] Carousel 구현 - 1

Ollin·2024년 9월 7일

React Native

목록 보기
1/10

프로젝트를 진행하면서 온보딩 화면을 만들게 되었고 검색 중 어렵지 않다는 이야기가 생각보다 많았기 때문에 라이브러리 없이 만들어보기로 결정했고 참고한 글에서 pageWidth, gap, offset 을 정의 후 사용했는데 margin으로만 조절할 때와 다르게 확실히 해당 아이템이 중앙에 위치할 수 있었습니다.

주요 기능

nowStep으로 현재 페이지가 몇 번째인지 상태를 관리하며, 마지막 페이지에서 start 버튼을 애니메이션으로 나타나게 구현
-> 이 애니메이션은 useRef를 사용해 Animated.Value(0)으로 시작하고, 마지막 페이지에 도달하면 toValue: 1로 변경해 버튼이 자연스럽게 나타나도록 구성

Intro

import React, {useState, useRef, useEffect} from 'react';
import {
  View,
  Text,
  StyleSheet,
  Dimensions,
  TouchableOpacity,
  Image,
  Animated,
} from 'react-native';
import {useNavigation} from '@react-navigation/native';
import {NativeStackNavigationProp} from '@react-navigation/native-stack';
import {RootStackParamList} from '@/types/Router';
import Carousel from './Carousel';
import {carouselData} from '@/types/carousel';
import {commonStyle} from '@/styles/common';

import RightArrowSbg from '@/assets/icons/rightArrow.svg';
const startImg = require('@/assets/images/piggyStart.png');

const {width: screenWidth, height: screenHeight} = Dimensions.get('window');

const Intro = () => {
  const [nowStep, setNowStep] = useState(0);
  const navigation =
    useNavigation<NativeStackNavigationProp<RootStackParamList>>();

  const buttonAnimation = useRef(new Animated.Value(0)).current;

  const handleStart = () => {
    navigation.replace('Main', {screen: 'Home'});
  };

  useEffect(() => {
    Animated.timing(buttonAnimation, {
      toValue: nowStep === carouselData.length - 1 ? 1 : 0,
      duration: 500,
      useNativeDriver: true,
    }).start();
  }, [nowStep]);

  const buttonOpacity = buttonAnimation.interpolate({
    inputRange: [0, 1],
    outputRange: [0, 1],
  });

  const buttonScale = buttonAnimation.interpolate({
    inputRange: [0, 1],
    outputRange: [0.8, 1],
  });

  const handleSkipPress = () => {
    navigation.replace('Main', {screen: 'Home'});
  };

  return (
    <View>
      <View>
        <Carousel
          data={carouselData}
          currentPage={nowStep}
          onPageChange={page => setNowStep(page)}
        />
      </View>

      {/* skip 버튼 */}
      <TouchableOpacity
        onPress={handleSkipPress}
        activeOpacity={0.6}>
        <Text style={[commonStyle.REGULAR, styles.skipText]}>skip</Text>
        <RightArrowSbg
          width={screenWidth * 0.05}
          height={screenWidth * 0.05}
          color={'#333'}
        />
      </TouchableOpacity>

      {/* 시작하기 버튼 */}
      {nowStep === carouselData.length - 1 && (
        <Animated.View
          style={[
            styles.buttonWrapper,
            {
              opacity: buttonOpacity,
              transform: [{scale: buttonScale}],
            },
          ]}>
          <TouchableOpacity onPress={handleStart}>
            <Image source={startImg} style={styles.button} />
          </TouchableOpacity>
        </Animated.View>
      )}
    </View>
  );
};

export default Intro;

import React, {useState, useRef, useEffect} from 'react';
import {View, Image, FlatList, StyleSheet, Dimensions} from 'react-native';
import {CarouselItem, CarouselProps} from '@/types/carousel';
import PiggyIconSvg from '@/assets/icons/piggyIcon.svg';

const {width: screenWidth, height: screenHeight} = Dimensions.get('window');

// 페이지의 크기 및 간격 설정
const PAGE_WIDTH = screenWidth * 0.8;
const PAGE_HEIGHT = screenHeight * 0.8;
const GAP = 20;
const OFFSET = (screenWidth - PAGE_WIDTH) / 2;

const Carousel = ({data, currentPage, onPageChange}: CarouselProps) => {
  const [page, setPage] = useState(0);
  const flatListRef = useRef<FlatList<CarouselItem>>(null);

  useEffect(() => {
    if (flatListRef.current) {
      flatListRef.current.scrollToIndex({index: currentPage, animated: true});
    }
  }, [currentPage]);

  const onScroll = (e: any) => {
    const newPage = Math.round(
      e.nativeEvent.contentOffset.x / (PAGE_WIDTH + GAP),
    );
    if (newPage !== page) {
      setPage(newPage);
      onPageChange(newPage);
    }
  };

  const renderItem = ({item}: {item: CarouselItem}) => {
    return (
      <View style={[styles.page, {backgroundColor: item.backgroundColor}]}>
        <PiggyIconSvg
          style={styles.icon}
          width={screenWidth * 0.2}
          height={screenWidth * 0.2}
        />
        <Image source={item.image} style={styles.image} />
      </View>
    );
  };

  return (
    <FlatList
      ref={flatListRef}
      automaticallyAdjustContentInsets={false}
      data={data}
      renderItem={renderItem}
      keyExtractor={(item, index) => index.toString()}
      horizontal
      showsHorizontalScrollIndicator={false}
      onScroll={onScroll}
      pagingEnabled
      snapToAlignment="start"
      snapToInterval={PAGE_WIDTH + GAP}
      decelerationRate="fast"
      contentContainerStyle={styles.contentContainer}
      ItemSeparatorComponent={() => <View style={styles.separator} />}
    />
  );
};

const styles = StyleSheet.create({
  container: {
    justifyContent: 'center',
    alignItems: 'center',
  },
  contentContainer: {
    paddingHorizontal: OFFSET + GAP / 2,
  },
  page: {
    width: PAGE_WIDTH,
    height: screenHeight * 0.8,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 20,
    overflow: 'hidden',
  },
  image: {
    width: '100%',
    height: PAGE_HEIGHT,
    resizeMode: 'cover',
  },
  separator: {
    width: GAP,
  },
  indicatorWrapper: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  indicator: {
    width: 10,
    height: 10,
    borderRadius: 20,
    backgroundColor: '#888',
    margin: 5,
  },
  indicatorActive: {
    backgroundColor: '#ED423F',
    width: 12,
    height: 12,
  },
  skipWrapper: {
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    position: 'absolute',
    zIndex: 3,
    top: screenHeight * 0.02,
    right: screenWidth * 0.04,
    width: 48,
    height: 48,
  },
  skipText: {
    color: '#555',
    fontSize: screenWidth * 0.04,
  },
});

export default Carousel;

문제점 및 한계

전체 코드를 위와 같이 작성했지만 문제점이 있었다.

1. 중앙 정렬 문제
스와이프 강도에 따라 Carousel 아이템이 정확히 중앙에 위치하지 않고, 좌측으로 치우치거나 흔들리는 현상이 발생
-> 이는 사용자 경험에 큰 영향을 미치기 때문에 개선이 필요하다.

2. 마지막 페이지에서의 충돌
마지막 페이지에서 start 버튼 애니메이션과의 충돌로 이전 페이지로 돌아갈 수 없는 현상 발생

3. 실제 기기 테스트 문제
에뮬레이터가 아닌 실제 기기로 테스트 해본 결과 강하게 스와이프할 경우 서로가 nowStep이 되겠다고 싸우는 바람에 멀미가 날 뻔 했다.

결론 및 향후 계획

이러한 문제들로 인해 결국 Carousel 라이브러리를 사용하기로 결정했다..
비록 많은 문제점이 있었지만, 직접 구현하는 과정에서 많은 것을 배웠고, 실력을 더 쌓은 후 다시 도전해 보고 싶다.

구현하고 싶으신 분은 제 코드 보다 이 글을 꼭 참고하시기 바랍니다!

+실패한 마지막 페이지(이전 페이지로 돌아가기 불가)

실패한 마지막 페이지

0개의 댓글