@ List
* 6.4 글 목록 보여주기
** 6.4.1 FeedListItem 컴포넌트 만들기
** 6.4.2 FeedList 컴포넌트 만들기
** 6.4.3 date-fns로 날짜 포맷팅하기
* 6.5 Animated로 애니메이션 적용하기
** 6.5.1 애니메이션 연습하기
** 6.5.2 스크롤을 내렸을 때 글쓰기 버튼 숨기기
** 6.5.3 spring 사용하기
** 6.5.4 예외 처리하기
* 6.6 정리
@ Note
1. 일정 텍스트 길이를 초과할 경우, 정규식 활용 줄임표(...) 만들기
function truncate(text) {
// 정규식을 사용해 모든 줄 바꿈 문자 제거
const replaced = text.replace(/\n/g, ' ');
if (replaced.length <= 100) {
return replaced;
}
return replaced.slice(0, 100).concat('...');
}
2. 작성한 시간에 따른 시간 표현 수정
- 날짜/시간 관련 다양한 기능 제공 : date-fns 라이브러리
> reference : https://date-fns.org/docs/Getting-Started
- diff 는 현재 시간과 파라미터로 받아온 시간의 차이를 초 단위로 계산
- d.getTime 의 단위는 밀리세컨드라 1000으로 미리 나눠줌
- formatDistanceToNow 사용 시 '1시간 전', '2일 전' 등 으로 표시
> addSuffix는 포맷팅된 무자열 뒤에 '전' 또는 '후' 접미사 붙이는 옵션
- format 함수의 PPP는 날짜, EEE는 요일, p는 시간을 나타냄
> reference : https://date-fns.org/v2.16.1/docs/format
function formatDate(date) {
{
const d = new Date(date);
const now = Date.now();
const diff = (now - d.getTime()) / 1000;
// 글 작성 후 1분 미만일 때
if (diff < 60 * 1) {
return '방금 전';
}
// 글 작성 후 3일 미만일 때
if (diff < 60 * 60 * 24 * 3) {
return formatDistanceToNow(d, {addSuffix: true, locale: ko});
}
// 글 작성 후 3일 이상일 때
return format(d, 'PPP EEE p', {locale: ko});
}
}
3. Animated.timing 함수 사용
- useNativeDriver는 애니메이션 처리 작업을 네이티브 레벨에서 진행하는 옵션
> transform, opacity 처럼 레이아웃과 관련 없는 스타일에만 적용 가능
> left, width, paddingLeft,margineLeft 와 같은 스타일에는 반드시 useNativeDriver를 false로 지정해야 함
Animated.timing(animation, {
//필수 항목
toValue: 0 // 어떤 값으로 변경할지
useNativeDriver: true, // 네이티브 드라이버 사용 여부
}).start(() => {
// 애니메이션 처리 완료 후 실행할 작업
})
4. <Animated.View> 컴포넌트의 사용
- 컴포넌트를 움직일 때는 꼭 필요한 사항이 아니라면 left, top 대신 transform 사용하는게 성능면에서 좋음
- interpolate 로 여러 스타일 적용 가능
5. <FlatList> 의 스크롤 위치 관련 Props
- onEndReached, onEndReachdThreshold 는 무한 스크롤링 구현할 때 유용하나 멀어졌을 때를 구분 못함
- onScroll 이벤트로 콘테츠의 저체 크기, 스크롤의 위치를 확인 가능
> ScrollView의 Props와 동일함
> reference : https://reactnative.dev/docs/scrollview#onscroll
const onScroll = e => {
if (!onScrolledToBottom) {
return;
}
const {contentSize, layoutMeasurement, contentOffset} = e.nativeEvent;
const distanceFromBottom =
contentSize.height - layoutMeasurement.height - contentOffset.y;
if (
contentSize.height > layoutMeasurement.height &&
distanceFromBottom < 72
) {
console.log('바닥과 가까워요.');
onScrolledToBottom(true);
} else {
console.log('바닥과 멀어졌어요.');
onScrolledToBottom(false);
}
};
5. Animated.spring 함수 사용
- toValue로 지정한 값으로 서서히 변하는 것이 아니라, 스프링처럼 통통 튀는 효과 적용
> reference : https://reactnative.dev/docs/animated#spring
Animated.spring(animation, {
toValue: hidden ? 1 : 0,
useNativeDriver: true,
tension: 45,
friction: 5,
}).start();
@ Result
@ Git
- https://github.com/eunbok-bocoder/DayLog/commits/main
# Source tree
# index.js
/**
* @format
*/
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';
// uuid 사용을 위한 라이브러리
import 'react-native-get-random-values';
AppRegistry.registerComponent(appName, () => App);
# App.js
import React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import RootStack from './screens/RootStack';
// import LogContext from './contexts/LogContext';
import {LogContextProvider} from './contexts/LogContext';
function App() {
return (
<NavigationContainer>
{/* <LogContext.Provider value="안녕하세요"> */}
<LogContextProvider>
<RootStack />
</LogContextProvider>
{/* </LogContext.Provider> */}
</NavigationContainer>
);
}
export default App;
# screens > CalendarScreen.js
import React, {useRef, useState, useEffect} from 'react';
import {Animated, Button, StyleSheet, View} from 'react-native';
function FadeInAndOut() {
const animation = useRef(new Animated.Value(1)).current;
const [hidden, setHidden] = useState(false);
useEffect(() => {
Animated.timing(animation, {
toValue: hidden ? 0 : 1,
useNativeDriver: true,
}).start();
}, [hidden, animation]);
return (
<View>
<Animated.View
style={[
styles.rectangle,
{
opacity: animation,
},
]}
/>
<Button
title="Toggle"
onPress={() => {
setHidden(!hidden);
}}
/>
</View>
);
}
function SlideLeftAndRight() {
const animation = useRef(new Animated.Value(1)).current;
const [enabled, setEnabled] = useState(false);
useEffect(() => {
Animated.timing(animation, {
toValue: enabled ? 150 : 1,
useNativeDriver: true,
}).start();
}, [enabled, animation]);
return (
<View>
<Animated.View
style={[
styles.rectangle,
{
transform: [{translateX: animation}],
},
]}
/>
<Button
title="Toggle"
onPress={() => {
setEnabled(!enabled);
}}
/>
</View>
);
}
function SlideLeftAndRight_Interpolate() {
const animation = useRef(new Animated.Value(1)).current;
const [enabled, setEnabled] = useState(false);
useEffect(() => {
Animated.timing(animation, {
toValue: enabled ? 1 : 0,
useNativeDriver: true,
}).start();
}, [enabled, animation]);
return (
<View>
<Animated.View
style={[
styles.rectangle,
{
transform: [
{
translateX: animation.interpolate({
inputRange: [0, 1],
outputRange: [0, 150],
}),
},
],
opacity: animation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
}),
},
]}
/>
<Button
title="Toggle"
onPress={() => {
setEnabled(!enabled);
}}
/>
</View>
);
}
function CalendarScreen() {
return (
<View style={styles.block}>
<FadeInAndOut />
<SlideLeftAndRight />
<SlideLeftAndRight_Interpolate />
</View>
);
}
const styles = StyleSheet.create({
block: {},
rectangle: {width: 100, height: 100, backgroundColor: 'black'},
});
export default CalendarScreen;
# screens > FeedsScreen.js
import React, {useContext, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import FeedList from '../components/FeedList';
import FloatingWriteButton from '../components/FloatingWriteButton';
import LogContext from '../contexts/LogContext';
function FeedsScreen() {
const {logs} = useContext(LogContext);
// console.log(JSON.stringify(logs, null, 2));
const [hidden, setHidden] = useState(false);
// console.log(hidden);
const onScrolledToBottom = isBottom => {
if (hidden !== isBottom) {
setHidden(isBottom);
}
};
return (
<View style={styles.block}>
<FeedList logs={logs} onScrolledToBottom={onScrolledToBottom} />
<FloatingWriteButton hidden={hidden} />
</View>
);
}
const styles = StyleSheet.create({
block: {
flex: 1,
},
});
export default FeedsScreen;
# screens > MainTab.js
import React from 'react';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import FeedsScreen from './FeedsScreen';
import CalendarScreen from './CalendarScreen';
import SearchScreen from './SearchScreen';
import Icon from 'react-native-vector-icons/MaterialIcons';
const Tab = createBottomTabNavigator();
function MainTab() {
return (
<Tab.Navigator
screenOptions={{
tabBarShowLabel: false,
activeTintColor: '#009688',
}}>
<Tab.Screen
name="Feeds"
component={FeedsScreen}
options={{
tabBarIcon: ({color, size}) => (
<Icon name="view-stream" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Calendar"
component={CalendarScreen}
options={{
tabBarIcon: ({color, size}) => (
<Icon name="event" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Search"
component={SearchScreen}
options={{
tabBarIcon: ({color, size}) => (
<Icon name="search" size={size} color={color} />
),
}}
/>
</Tab.Navigator>
);
}
export default MainTab;
# screens > RootStack.js
import React from 'react';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
import MainTab from './MainTab';
import WriteScreen from './WriteScreen';
const Stack = createNativeStackNavigator();
function RootStack() {
return (
<Stack.Navigator>
<Stack.Screen
name="MainTab"
component={MainTab}
options={{headerShown: false}}
/>
<Stack.Screen
name="Write"
component={WriteScreen}
options={{headerShown: false}}
/>
</Stack.Navigator>
);
}
export default RootStack;
# screens > SearchScreen.js
import React from 'react';
import {StyleSheet, View} from 'react-native';
function SearchScreen() {
return <View style={styles.block} />;
}
const styles = StyleSheet.create({
block: {},
});
export default SearchScreen;
# screens > WriteScreen.js
import {useNavigation} from '@react-navigation/native';
import React, {useContext, useState} from 'react';
import {StyleSheet, KeyboardAvoidingView, Platform} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import WriteEditor from '../components/WriteEditor';
import WriteHeader from '../components/WriteHeader';
import LogContext from '../contexts/LogContext';
function WriteScreen() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const navigation = useNavigation();
const {onCreate} = useContext(LogContext);
const onSave = () => {
onCreate({
title,
body,
// 날짜를 문자열로 변환
date: new Date().toISOString(),
});
navigation.pop();
};
return (
<SafeAreaView style={styles.block}>
<KeyboardAvoidingView
style={styles.avoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<WriteHeader onSave={onSave} />
<WriteEditor
title={title}
body={body}
onChangeTitle={setTitle}
onChangeBody={setBody}
/>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
block: {
flex: 1,
backgroundColor: 'white',
},
avoidingView: {
flex: 1,
},
});
export default WriteScreen;
# components > FeedList.js
import React from 'react';
import {FlatList, StyleSheet, View} from 'react-native';
import FeedListItem from './FeedListItem';
function FeedList({logs, onScrolledToBottom}) {
const onScroll = e => {
if (!onScrolledToBottom) {
return;
}
const {contentSize, layoutMeasurement, contentOffset} = e.nativeEvent;
// console.log({contentSize, layoutMeasurement, contentOffset});
const distanceFromBottom =
contentSize.height - layoutMeasurement.height - contentOffset.y;
if (
contentSize.height > layoutMeasurement.height &&
distanceFromBottom < 72
) {
console.log('바닥과 가까워요.');
onScrolledToBottom(true);
} else {
console.log('바닥과 멀어졌어요.');
onScrolledToBottom(false);
}
};
return (
<FlatList
data={logs}
style={styles.block}
renderItem={({item}) => <FeedListItem log={item} />}
keyExtractor={log => log.id}
ItemSeparatorComponent={() => <View style={styles.separator} />}
onScroll={onScroll}
/>
);
}
const styles = StyleSheet.create({
block: {flex: 1},
separator: {
backgroundColor: '#e0e0e0',
height: 1,
width: '100%',
},
});
export default FeedList;
# components > FeedListItem.js
import React from 'react';
import {Platform, Pressable, StyleSheet, Text} from 'react-native';
import {format, formatDistanceToNow} from 'date-fns';
import {ko} from 'date-fns/locale';
function formatDate(date) {
{
const d = new Date(date);
const now = Date.now();
const diff = (now - d.getTime()) / 1000;
// console.log('[log] d : ', d);
// console.log('[log] now : ', now);
// console.log('[log] diff : ', diff);
if (diff < 60 * 1) {
return '방금 전';
}
if (diff < 60 * 60 * 24 * 3) {
return formatDistanceToNow(d, {addSuffix: true, locale: ko});
}
return format(d, 'PPP EEE p', {locale: ko});
}
}
function truncate(text) {
// 정규식을 사용해 모든 줄 바꿈 문자 제거
const replaced = text.replace(/\n/g, ' ');
if (replaced.length <= 100) {
return replaced;
}
return replaced.slice(0, 100).concat('...');
}
function FeedListItem({log}) {
const {title, body, date} = log; // 사용하기 편하게 객체 구조 분해 할당
return (
<Pressable
style={({pressed}) => [
styles.block,
Platform.OS === 'ios' && pressed && {backgroundColor: '#efefef'},
]}
android_ripple={{color: '#ededed'}}>
{/* <Text style={styles.date}>{new Date(date).toLocaleString()}</Text> */}
<Text style={styles.date}>{formatDate(date)}</Text>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{truncate(body)}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
block: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 24,
},
date: {
fontSize: 12,
color: '#546e7a',
marginBottom: 8,
},
title: {
color: '#37474f',
fontSize: 16,
lineHeight: 21,
},
});
export default FeedListItem;
# components > FloatingWriteButton.js
import {useNavigation} from '@react-navigation/native';
import React, {useEffect, useRef} from 'react';
import {Animated, Platform, Pressable, StyleSheet, View} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
function FloatingWriteButton({hidden}) {
const navigation = useNavigation();
const onPress = () => {
navigation.navigate('Write');
};
const animation = useRef(new Animated.Value(0)).current;
// useEffect(() => {
// Animated.timing(animation, {
// toValue: hidden ? 1 : 0,
// useNativeDriver: true,
// }).start();
// }, [animation, hidden]);
useEffect(() => {
Animated.spring(animation, {
toValue: hidden ? 1 : 0,
useNativeDriver: true,
tension: 45,
friction: 5,
}).start();
}, [animation, hidden]);
return (
<Animated.View
style={[
styles.wrapper,
{
transform: [
{
translateY: animation.interpolate({
inputRange: [0, 1],
outputRange: [0, 88],
}),
},
],
opacity: animation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
}),
},
]}>
<Pressable
style={({pressed}) => [
styles.button,
Platform.OS === 'ios' && {
opacity: pressed ? 0.6 : 1,
},
]}
android_ripple={{color: 'white'}}
onPress={onPress}>
<Icon name="add" size={24} style={styles.icon} />
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
bottom: 16,
right: 16,
width: 56,
borderRadius: 28,
// iOS 전용 그림자 설정
shadowColor: '#4d4d4d',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.3,
shadowRadius: 4,
// 안드로이드 전용 그림자 설정
elevation: 5,
// 안드로이드에서 물결 효과가 영역 밖으로 나가지 않도록 설정
// iOS에서는 overflow가 hidden일 경우 그림자가 보여지지 않음
overflow: Platform.select({android: 'hidden'}),
},
button: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#009688',
justifyContent: 'center',
alignItems: 'center',
},
icon: {
color: 'white',
},
});
export default FloatingWriteButton;
# components > TransparentCircleButton.js
import React from 'react';
import {Platform, Pressable, StyleSheet, View} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
function TransparentCircleButton({name, color, hasMarginRight, onPress}) {
return (
<View
style={[styles.iconButtonWrapper, hasMarginRight && styles.rightMargin]}>
<Pressable
style={({pressed}) => [
styles.iconButton,
Platform.OS === 'ios' && pressed && {backgroundColor: '#efefef'},
]}
onPress={onPress}
android_ripple={{color: '#ededed'}}>
<Icon name={name} size={24} color={color} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
iconButtonWrapper: {
width: 32,
height: 32,
borderRadius: 16,
overflow: 'hidden',
},
iconButton: {
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 16,
},
rightMargin: {
marginRight: 8,
},
});
export default TransparentCircleButton;
# components > WriteEditor.js
import React, {useRef} from 'react';
import {View, StyleSheet, TextInput} from 'react-native';
function WriteEditor({title, body, onChangeTitle, onChangeBody}) {
const bodyRef = useRef();
return (
<View style={styles.block}>
<TextInput
placeholder="제목을 입력하세요"
style={styles.titleInput}
returnKeyType="next"
onChangeText={onChangeTitle}
value={title}
onSubmitEditing={() => {
bodyRef.current.focus();
}}
/>
<TextInput
placeholder="당신의 오늘을 기록해보세요"
style={styles.bodyInput}
multiline
textAlignVertical="top"
onChangeText={onChangeBody}
returnKeyType="next"
value={body}
ref={bodyRef}
/>
</View>
);
}
const styles = StyleSheet.create({
block: {flex: 1, padding: 16},
titleInput: {
paddingVertical: 0,
fontSize: 18,
marginBottom: 16,
color: '#263238',
fontWeight: 'bold',
},
bodyInput: {
flex: 1,
fontSize: 16,
paddingVertical: 0,
color: '#263238',
},
});
export default WriteEditor;
# components > WriteHeader.js
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import TransparentCircleButton from './TransparentCircleButton';
function WriteHeader({onSave}) {
const navigation = useNavigation();
const onGoBack = () => {
navigation.pop();
};
return (
<View style={styles.block}>
<View style={styles.iconButtonWrapper}>
<TransparentCircleButton
onPress={onGoBack}
name="arrow-back"
color="#424242"
/>
</View>
<View style={styles.buttons}>
<TransparentCircleButton
name="delete-forever"
color="#ef5350"
hasMarginRight
/>
<TransparentCircleButton
name="check"
color="#009688"
onPress={onSave}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
block: {
height: 48,
paddingHorizontal: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
iconButtonWrapper: {
width: 32,
height: 32,
borderRadius: 16,
overflow: 'hidden',
},
iconButton: {
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 16,
},
buttons: {
flexDirection: 'row',
alignItems: 'center',
},
marginRight: {
marginRight: 8,
},
});
export default WriteHeader;
# contexts > LogContext.js
import React from 'react';
import {createContext, useState} from 'react';
import {v4 as uuidv4} from 'uuid';
// const LogContext = createContext('안녕하세요.');
const LogContext = createContext();
export function LogContextProvider({children}) {
// const [text, setText] = useState('');
// return (
// <LogContext.Provider value={{text, setText}}>
// {children}
// </LogContext.Provider>
// );
// 테스트 데이터
// const [logs, setLogs] = useState([
// {
// id: uuidv4(),
// title: 'Log 03',
// body: 'Log 03',
// date: new Date().toISOString(),
// },
// {
// id: uuidv4(),
// title: 'Log 02',
// body: 'Log 02',
// date: new Date(Date.now() - 1000 * 60 * 3).toISOString(),
// },
// {
// id: uuidv4(),
// title: 'Log 01',
// body: 'Log 01',
// date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
// },
// ]);
// 테스트 데이터 10개 생성
const [logs, setLogs] = useState(
Array.from({length: 10})
.map((_, index) => ({
id: uuidv4(),
title: `Log ${index}`,
body: `Log ${index}`,
date: new Date().toISOString(),
}))
.reverse(),
);
// console.log('logs :', logs);
const onCreate = ({title, body, date}) => {
const log = {
id: uuidv4(),
title,
body,
date,
};
setLogs([log, ...logs]);
};
return (
<LogContext.Provider value={{logs, onCreate}}>
{children}
</LogContext.Provider>
);
}
export default LogContext;
'React Native > React Native_study' 카테고리의 다른 글
[리액트 네이티브를 다루는 기술 #13] 7장 다이어리 앱 만들기2 (p .380 ~ 398) (0) | 2022.01.14 |
---|---|
[리액트 네이티브를 다루는 기술 #12] 7장 다이어리 앱 만들기2 (p .357 ~ 379) (2) | 2022.01.11 |
[리액트 네이티브를 다루는 기술 #10] 6장 다이어리 앱 만들기1 (p.306 ~ 329) (0) | 2022.01.03 |
[리액트 네이티브를 다루는 기술 #9] 6장 다이어리 앱 만들기1 (p.285 ~ 305) (0) | 2021.12.31 |
[리액트 네이티브를 다루는 기술 #8] 5장 리액트 내비게이션으로 여러 화면 관리하기 (p.244 ~ 284) (0) | 2021.12.30 |