[Leafday #2] 화면 설계부터 데이터 저장까지 — 하루 만에 MVP 뼈대 완성
오늘 목표
지난 포스팅에서 Expo 환경 세팅 + Hello World까지 했다. 오늘은 실제 Leafday 앱의 뼈대를 만드는 날.
목표:
- 화면 4개 설계 및 구현
- 화면 간 네비게이션 연결
- 핵심 애니메이션 (북플립 + 카드플립)
- AsyncStorage로 실제 데이터 저장
화면 구조 설계
먼저 앱에 필요한 화면을 정리했다.
1
2
3
4
5
6
7
8
📚 책장 화면 (BookshelfScreen)
└── 내 책들이 꽂혀있는 책장
🌿 오늘 화면 (TodayScreen)
└── 오늘/어제 사진 + 느낀점 작성
📖 새 책 화면 (NewBookScreen)
└── 기간, 색상, 제목 설정
📄 책 보기 화면 (BookViewScreen)
└── 페이지 넘기기 + 카드 뒤집기
Step 1. 네비게이션 설치
화면 간 이동을 위해 React Navigation을 설치했다.
1
2
3
npx expo install @react-navigation/native @react-navigation/bottom-tabs
npx expo install @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-context
네비게이션 구조:
1
2
3
4
5
6
Tab Navigator (하단 탭)
├── 책장 탭 → Stack Navigator
│ ├── BookshelfScreen (메인)
│ ├── NewBookScreen (모달)
│ └── BookViewScreen (책 열기)
└── 오늘 탭 → TodayScreen
App.tsx에서 전체 구조를 잡았다:
1
2
3
4
5
6
7
8
9
10
11
12
function BookshelfStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="BookshelfMain" component={BookshelfScreen} />
<Stack.Screen name="NewBook" component={NewBookScreen}
options={{ presentation: 'modal' }} />
<Stack.Screen name="BookView" component={BookViewScreen} />
</Stack.Navigator>
);
}
presentation: 'modal'을 주면 새 책 화면이 아래에서 올라오는 느낌으로 뜬다.
Step 2. 책장 UI
책장 화면의 핵심은 책이 4권씩 선반에 꽂히고, 꽉 차면 새 선반이 추가되는 구조다.
1
2
3
4
5
6
7
const BOOKS_PER_SHELF = 4;
// 책을 4개씩 나눠서 선반 배열 생성
const shelves: BookItem[][] = [];
for (let i = 0; i < books.length; i += BOOKS_PER_SHELF) {
shelves.push(books.slice(i, i + BOOKS_PER_SHELF));
}
책등(Spine) 컴포넌트:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function BookSpine({ book, onPress }) {
return (
<TouchableOpacity
style={[styles.spine, { backgroundColor: book.color }]}
onPress={onPress}
>
<View style={styles.spineGloss} /> {/* 광택 효과 */}
<Text style={styles.spineYear}>{book.year}</Text>
<View style={styles.spineProgressTrack}>
<View style={[styles.spineProgressFill, {
height: `${(book.pages / book.total) * 100}%`
}]} />
</View>
</TouchableOpacity>
);
}
책등 아래쪽에 작은 진행 바를 넣어서 책이 얼마나 채워졌는지 한눈에 보이게 했다.
Step 3. 오늘 화면
사진 선택/촬영 기능은 expo-image-picker를 썼다:
1
npx expo install expo-image-picker
1
2
3
4
5
6
7
8
9
10
11
12
const pickImage = async () => {
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) return;
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
aspect: [3, 4],
quality: 0.8,
});
if (!result.canceled) setPhoto(result.assets[0].uri);
};
날짜 정책 결정: 오늘만 쓸 수 있게 할지, 과거도 허용할지 고민했다. 결론은 오늘 + 어제(하루 전)까지만 허용. 야간 근무 끝나고 다음날 아침에 어제 것 올리는 상황을 고려했다.
1
2
3
4
5
6
7
8
9
function getAvailableDates() {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
return [
{ label: '오늘', date: formatDate(today) },
{ label: '어제', date: formatDate(yesterday) },
];
}
Step 4. 책 보기 — 핵심 애니메이션
두 가지 애니메이션을 분리해서 구현했다.
북플립 (페이지 넘기기)
스와이프 제스처로 페이지를 넘긴다. PanResponder로 손가락 속도를 추적하고, 빠르게 스와이프하면 여러 장이 연속으로 넘어가는 모멘텀 효과를 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const panResponder = PanResponder.create({
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 10,
onPanResponderMove: (e) => {
// 드래그 중 실시간 미리보기
const ratio = Math.min(Math.abs(dx) / (W * 0.7), 0.95);
bookFlip.setValue(dx < 0 ? ratio : -ratio);
},
onPanResponderRelease: (_, g) => {
if (Math.abs(vel) > 0.25 || Math.abs(g.dx) > W * 0.25) {
momentumFlip(vel * 1000); // 빠른 스와이프 → 여러 장
} else {
// 취소 → 원위치
Animated.spring(bookFlip, { toValue: 0, useNativeDriver: true }).start();
}
},
});
모멘텀 플립 — 속도에 비례해서 넘어가는 페이지 수를 계산한다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const momentumFlip = (vel: number) => {
const speed = Math.abs(vel);
const pages = speed > 1.5 ? Math.min(Math.floor(speed * 5), 25)
: speed > 0.6 ? Math.min(Math.floor(speed * 3), 10) : 1;
let count = 0;
const next = () => {
if (count >= pages) return;
count++;
doBookFlip(dir, speed);
setTimeout(next, Math.max(60, 220 / speed)); // 속도에 따라 딜레이 조절
};
next();
};
페이지 전환 애니메이션은 perspective + rotateY + translateX 조합:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const bookRotate = bookFlip.interpolate({
inputRange: [-1, 0, 1],
outputRange: ['180deg', '0deg', '-180deg'],
});
const bookTranslate = bookFlip.interpolate({
inputRange: [-1, 0, 1],
outputRange: [W, 0, -W],
});
<Animated.View style={{
transform: [
{ perspective: 1400 },
{ translateX: bookTranslate },
{ rotateY: bookRotate },
],
}}>
카드플립 (앞/뒷면 전환)
페이지 사진을 탭하면 카드가 뒤집혀서 느낀점이 나온다. Animated.spring으로 자연스러운 탄성을 줬다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const flipCard = () => {
Animated.spring(cardFlip, {
toValue: isCardFlipped ? 0 : 1,
friction: 7,
tension: 45,
useNativeDriver: true,
}).start(() => setIsCardFlipped(!isCardFlipped));
};
// 앞면이 사라지고 뒷면이 나타나는 타이밍
const frontOpacity = cardFlip.interpolate({
inputRange: [0, 0.499, 0.5, 1],
outputRange: [1, 1, 0, 0], // 절반 지점에서 전환
});
const backOpacity = cardFlip.interpolate({
inputRange: [0, 0.499, 0.5, 1],
outputRange: [0, 0, 1, 1],
});
backfaceVisibility: 'hidden'을 양쪽 면에 설정해야 앞/뒤가 동시에 보이는 현상이 안 생긴다.
Step 5. 데이터 저장 (AsyncStorage)
1
npx expo install @react-native-async-storage/async-storage
데이터 구조를 먼저 설계했다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Page = {
id: string;
bookId: string;
date: string; // 'YYYY.MM.DD'
photoUri: string | null;
note: string;
createdAt: number;
};
type Book = {
id: string;
title: string;
color: string;
startDate: string;
endDate: string;
totalDays: number;
createdAt: number;
};
저장/조회 함수를 src/storage/storage.ts에 분리해서 관리했다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const KEYS = {
BOOKS: 'leafday:books',
PAGES: (bookId: string) => `leafday:pages:${bookId}`,
};
export async function savePage(page: Page): Promise<void> {
const pages = await getPages(page.bookId);
const existing = pages.findIndex(p => p.id === page.id);
if (existing >= 0) {
pages[existing] = page; // 기존 페이지 수정
} else {
pages.push(page);
}
pages.sort((a, b) => a.date.localeCompare(b.date)); // 날짜순 정렬
await AsyncStorage.setItem(KEYS.PAGES(page.bookId), JSON.stringify(pages));
}
화면 포커스될 때마다 최신 데이터를 불러오기 위해 useFocusEffect를 썼다:
1
2
3
4
5
useFocusEffect(
useCallback(() => {
loadBooks();
}, [])
);
오늘의 결과
1
2
3
4
5
6
7
8
✅ 화면 4개 구현
✅ 스택 + 탭 네비게이션 연결
✅ 책장 → 새 책 → 책 보기 플로우
✅ 북플립 (모멘텀 + 드래그 미리보기)
✅ 카드플립 (사진 ↔ 느낀점)
✅ 오늘/어제 날짜 선택
✅ AsyncStorage 데이터 저장
✅ GitHub 레포 생성 (jongvvon/leafday)
코드 히스토리
오늘 가장 많이 바뀐 것들:
네비게이션 구조 변경 처음엔 탭에 “새 책” 탭을 따로 만들었다가 → 책장 내부 스택으로 이동. 책 만들기는 흐름상 책장에서 시작해야 자연스럽다.
책장 데이터 처음엔 하드코딩된 SAMPLE_BOOKS 배열 → AsyncStorage에서 실제 저장된 책 불러오는 방식으로 교체. useFocusEffect로 화면 돌아올 때마다 갱신.
BookViewScreen 3번 갈아엎음
- 1차: FlatList 방식 (촤르르 스크롤) → 페이지 넘기는 느낌 없음
- 2차:
rotateY슬라이드 → 여전히 북플립 느낌 약함 - 3차:
perspective + PanResponder + 모멘텀조합으로 완성
실제로 써봐야 뭐가 어색한지 알고, 써봐야 뭘 고쳐야 할지 안다.
다음 편
- 사진 실제 표시 완성 ✅ (이미 반영)
- Git CI/CD — TestFlight 자동 배포
- 테마 선택 기능
- App Store 제출 준비