@ List
* 7.1 작성한 글을 WriteScreen으로 열기
** 7.1.1 FeedListItem 수정하기
** 7.1.2 WriteScreen에서 log 파라미터 인식하기
* 7.2 수정 기능 구현하기
** 7.2.1 LogContext에 onModify 함수 구현하기
** 7.2.2 WriteScreen에서 onModify 함수 사용하기
* 7.3 삭제 기능 구현하기
** 7.3.1 LogContext에 onRemove 함수 구현하기
** 7.3.2 WriteScreen에서 onRemove 함수 사용하기
** 7.3.3 WriteHeader 수정하기
* 7.4 검색 기능 구현하기
** 7.4.1 SearchHeader 컴포넌트 만들기
** 7.4.2 화면 크기 조회학
** 7.4.3 SearchHeader 컴포넌트 UI 구성하기
** 7.4.4 SearchContext 만들기
** 7.4.5 검색어 필터링 후 FeedList 재사용하기
** 7.4.6 EmptySearchResult 만들기
@ Note
1. 문법
- [ ?. ]옵셔널체이닝(optional chaining) 문법 : null 이거나 undefined 일 수 있는 객체의 프로퍼티를 에러 없이 접근 가능함
- [ ?? ] nullish 병합 연산자 : 유효한 값이라면 해당값을 사용하고, 그렇지 않으면 뒤에 지정한 값 사용
- [ !! ] NOT 연산자 두 번 사용 : 해당 값이 유효한 객체라면 값이 true가 되고, 값이 null 이나 undefined 라면 false 가 됨
// 옵셔널체이닝 + null 병합 연산자 사용
const log = route.params?.log;
const [title, setTitle] = useState(log?.title ?? '');
// 옵셔널체이닝 + null 병합 연산자 미사용
const log = route.params ? route.params.log : undefined;
const [title, setTitle] = useState(log ? log.title : '' );
// log가 유요한 값이면 true, 그렇지 않으면 false 값 전달
<WriteHeader isEditing={!!log} />
2. 화면 크기 조회 2가지 방법
- Dimensions.get() 사용
> Dimensions.get('window') : 현재 앱에서 사용할 수 있는 영역의 크기를 가져옴
> Dimensions.get('screen') : 전체 화면의 크기를 가져옴 (ios는 동일, android는 상단 상태 바 및 하단 메뉴 바 영역을 제외한 크기)
> 컴포넌트 외부에서도 작동하므로 StyleSheet에서 사용 가능하나, 화면의 방향 변경 및 폴더블 디바이스 사용 시 크기 변경될 수 있음
- useWindowDimensions() Hook 사용
> 화면 크기가 변경되는 상황에 직접 대비할 필요 없음
> 함수 컴포넌트 내부에서만 사용 가능하며, 전체 화면의 크기를 가져오는 기능은 없음
3. <TextInput /> 의 autoFocus Props 사용
- 컴포넌트가 화면에 나타날 때 자동으로 포커스가 잡힘
4. 내장 함수
- includes() 문자열 내장 함수
> text.includes() : text에 특정 문자열이 존재하면 true 반환, 그렇지 않으면 false 반환
- some 배열 내장함수
> [log.title, log.body].some() : 배열 원소 중 특정 조건이 true인 원소가 하나라도 있으면 true , 모두 만족하지 않을 시 false 반환
@ Result
@ Git
- https://github.com/eunbok-bocoder/DayLog/commits/main
# Source tree
# App.js
import React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import RootStack from './screens/RootStack';
import {LogContextProvider} from './contexts/LogContext';
import {SearchContextProvider} from './contexts/SearchContext';
function App() {
return (
<NavigationContainer>
<SearchContextProvider>
<LogContextProvider>
<RootStack />
</LogContextProvider>
</SearchContextProvider>
</NavigationContainer>
);
}
export default App;
# components > EmptySearchResult.js
import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
const messages = {
NOT_FOUND: '검색 결과가 없습니다.',
EMPTY_KEYWORD: '검색어를 입력하세요.',
};
function EmptySearchResult({type}) {
return (
<View style={styles.block}>
<Text style={styles.text}>{messages[type]}</Text>
</View>
);
}
const styles = StyleSheet.create({
block: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
text: {
color: '#9e9e9e',
fontSize: 16,
},
});
export default EmptySearchResult;
# 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';
import {useNavigation} from '@react-navigation/native';
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; // 사용하기 편하게 객체 구조 분해 할당
const navigation = useNavigation();
const onPress = () => {
navigation.navigate('Write', {
log,
});
};
return (
<Pressable
style={({pressed}) => [
styles.block,
Platform.OS === 'ios' && pressed && {backgroundColor: '#efefef'},
]}
android_ripple={{color: '#ededed'}}
onPress={onPress}>
{/* <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 > SearchHeader.js
import React, {useContext} from 'react';
import {
Pressable,
StyleSheet,
TextInput,
useWindowDimensions,
View,
Text,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import SearchContext from '../contexts/SearchContext';
function SearchHeader() {
// return <Text style={styles.block}>Hello</Text>;
const {width} = useWindowDimensions();
const {keyword, onChangeText} = useContext(SearchContext);
return (
<View style={[styles.block, {width: width - 32}]}>
<TextInput
style={styles.input}
placeholder="검색어를 입력하세요"
value={keyword}
onChangeText={onChangeText}
autoFocus
/>
<Pressable
style={({pressed}) => [styles.button, pressed && {opacity: 0.5}]}
onPress={() => onChangeText('')}>
<Icon name="cancel" size={20} color="#9e9e9e" />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
block: {
flexDirection: 'row',
alignItems: 'center',
},
input: {
flex: 1,
},
button: {
marginLeft: 8,
},
});
export default SearchHeader;
# 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, onAskRemove, isEditing}) {
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}>
{isEditing && (
<TransparentCircleButton
name="delete-forever"
color="#ef5350"
hasMarginRight
onPress={onAskRemove}
/>
)}
<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}) {
// 테스트 데이터 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]);
};
const onModify = modified => {
// logs 배열을 순회해 id가 일치하면 log를 교체하고 그렇지 않으면 유지
const nextLogs = logs.map(log => (log.id === modified.id ? modified : log));
setLogs(nextLogs);
};
const onRemove = id => {
const nextLogs = logs.filter(log => log.id !== id);
setLogs(nextLogs);
};
return (
<LogContext.Provider value={{logs, onCreate, onModify, onRemove}}>
{children}
</LogContext.Provider>
);
}
export default LogContext;
# contexts > SearchContextjs
import React, {createContext, useState} from 'react';
const SearchContext = createContext();
export function SearchContextProvider({children}) {
const [keyword, onChangeText] = useState('');
return (
<SearchContext.Provider value={{keyword, onChangeText}}>
{children}
</SearchContext.Provider>
);
}
export default SearchContext;
# 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';
import SearchHeader from '../components/SearchHeader';
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={{
title: '검색',
tabBarIcon: ({color, size}) => (
<Icon name="search" size={size} color={color} />
),
headerTitle: () => <SearchHeader />,
}}
/>
</Tab.Navigator>
);
}
export default MainTab;
# screens > SearchScreen.js
import React, {useContext} from 'react';
import {StyleSheet, View} from 'react-native';
import EmptySearchResult from '../components/EmptySearchResult';
import FeedList from '../components/FeedList';
import LogContext from '../contexts/LogContext';
import SearchContext from '../contexts/SearchContext';
function SearchScreen() {
const {keyword} = useContext(SearchContext);
const {logs} = useContext(LogContext);
const filtered =
keyword === ''
? []
: logs.filter(log =>
[log.title, log.body].some(text => text.includes(keyword)),
);
if (keyword === '') {
return <EmptySearchResult type="EMPTY_KEYWORD" />;
}
if (filtered.length === 0) {
return <EmptySearchResult type="NOT_FOUND" />;
}
return (
<View style={styles.block}>
<FeedList logs={filtered} />
</View>
);
}
const styles = StyleSheet.create({
block: {
flex: 1,
},
});
export default SearchScreen;
# screens > WriteScreen.js
import {useNavigation} from '@react-navigation/native';
import React, {useContext, useState} from 'react';
import {Alert, 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({route}) {
// 옵셔널 체이닝 문법
const log = route.params?.log; // --> route.params ? route.params.log : undefined
const [title, setTitle] = useState(log?.title ?? ''); // --> useState(log ? log.title : '')
const [body, setBody] = useState(log?.body ?? '');
const navigation = useNavigation();
const {onCreate, onModify, onRemove} = useContext(LogContext);
const onSave = () => {
if (log) {
onModify({
id: log.id,
date: log.date,
title,
body,
});
} else {
onCreate({
title,
body,
// 날짜를 문자열로 변환
date: new Date().toISOString(),
});
}
navigation.pop();
};
const onAskRemove = () => {
Alert.alert(
'삭제',
'정말로 삭제하시겠어요?',
[
{text: '취소', style: 'cancel'},
{
text: '삭제',
onPress: () => {
onRemove(log?.id);
navigation.pop();
},
style: 'destructive',
},
],
{
cancelable: true,
},
);
};
return (
<SafeAreaView style={styles.block}>
<KeyboardAvoidingView
style={styles.avoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<WriteHeader
onSave={onSave}
onAskRemove={onAskRemove}
isEditing={!!log}
/>
<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;
'React Native > React Native_study' 카테고리의 다른 글
[리액트 네이티브를 다루는 기술 #14] 7장 다이어리 앱 만들기2 (p .399 ~ 406) (0) | 2022.01.17 |
---|---|
[리액트 네이티브를 다루는 기술 #13] 7장 다이어리 앱 만들기2 (p .380 ~ 398) (0) | 2022.01.14 |
[리액트 네이티브를 다루는 기술 #11] 6장 다이어리 앱 만들기1 (p.330 ~ 356) (0) | 2022.01.07 |
[리액트 네이티브를 다루는 기술 #10] 6장 다이어리 앱 만들기1 (p.306 ~ 329) (0) | 2022.01.03 |
[리액트 네이티브를 다루는 기술 #9] 6장 다이어리 앱 만들기1 (p.285 ~ 305) (0) | 2021.12.31 |