프로젝트를 진행하면서 온보딩 화면을 만들게 되었고 검색 중 어렵지 않다는 이야기가 생각보다 많았기 때문에 라이브러리 없이 만들어보기로 결정했고 참고한 글에서 pageWidth, gap, offset 을 정의 후 사용했는데 margin으로만 조절할 때와 다르게 확실히 해당 아이템이 중앙에 위치할 수 있었습니다.
주요 기능
nowStep으로 현재 페이지가 몇 번째인지 상태를 관리하며, 마지막 페이지에서 start 버튼을 애니메이션으로 나타나게 구현
-> 이 애니메이션은 useRef를 사용해 Animated.Value(0)으로 시작하고, 마지막 페이지에 도달하면 toValue: 1로 변경해 버튼이 자연스럽게 나타나도록 구성
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 라이브러리를 사용하기로 결정했다..
비록 많은 문제점이 있었지만, 직접 구현하는 과정에서 많은 것을 배웠고, 실력을 더 쌓은 후 다시 도전해 보고 싶다.
구현하고 싶으신 분은 제 코드 보다 이 글을 꼭 참고하시기 바랍니다!
+실패한 마지막 페이지(이전 페이지로 돌아가기 불가)
