5장 할 일 관리 애플리케이션 만들기
이번 장에서는 4장까지 배운 내용을 바탕으로 TODO List 어플리케이션을 만들어보겠다.
5.0 애플리케이션 개요
5.0.1 파일 디렉토리 구조
├── src
│ ├── components
│ │ ├── IconButton.js
│ │ ├── Input.js
│ │ └── Task.js
│ ├── App.js
│ ├── images.js
│ └── theme.js
│
├── assets
│ └── 이미지 파일들
└── App.js
5.0.2 세부 기능
- 등록: 할 일 항목을 추가하는 기능
- 수정: 완료되지 않은 할 일 항목을 수정하는 기능
- 삭제: 할 일 항목을 삭제하는 기능
- 완료: 할 일 항목의 완료 상태를 기록하는 기능
5.0.3 실행 화면
다음은 미리 맛보기로 이 장에서 완성할 앱의 실행 화면이다!
5.1 프로젝트 만들기
프로젝트를 생성하고, 필요한 라이브러리를 설치해준다.
expo init react-native-todo //여기서 blank로 빈 프로젝트 선택
cd react-native-todo
npm install styled-components prop-types
expo install @react-native-community/async-storage
5.2 타이틀 만들기
5.2.1 `SafeAreaView` 컴포넌트
iOS에서 노치 디자인이 있는 기기는 `Title` 컴포넌트의 일부가 가려지는 문제가 발생한다.
이를 해결하기 위해 패딩을 직접 줘도 되지만, 자동으로 padding 값이 적용되어 노치 디자인 문제를 해결할 수 있는 `SafeAreaView` 컴포넌트를 제공한다.
5.2.2 `StatusBar` 컴포넌트
이번에는 안드로이드에서 문제가 발생한다. `Title` 컴포넌트가 상태 바(status bar)에 가려지는 문제가 생긴다. 배경색을 어두운 색으로 설정하면서 상태 바의 내용도 눈에 잘 들어오지 않는다.
이때 `StatusBar` 컴포넌트를 사용해서 상태 바의 스타일을 변경함으로써 상태 바가 컴포넌트를 가리는 문제를 해결할 수 있다.
5.3 Input 컴포넌트 만들기
5.3.1 Dimensions
Input\
컴포넌트의 양 옆에 20px씩 공백을 주기 위해 리액트 네이티브가 제공하는 현재 화면의 크기를 알 수 있는 `Dimensions`와 `useWindowDimensions`를 이용
5.3.2 Input 컴포넌트의 다양한 속성
- `Input` 컴포넌트의 placeholder에 적용할 문자열은 props로 받아 설정하고, placeholder의 색은 타이틀과 같은 색상으로 설정
- 입력 가능한 글자의 수를 50자로 제한
- `TextInput` 컴포넌트에서 제공하는 속성을 이용해 키보드의 설정을 변경
- 자동으로 대문자로 전환하는 `autoCapitalize` 속성을 none으로 지정
- 자동 수정 기능의 `autoCorrect` 속성을 false로 지정
- 키보드의 완료 버튼을 설정하는 `returnKeyType`을 done으로 지정
- 아이폰의 키보드 색상을 변경하는 `keyboardAppearance`를 dark로 지정
<StyledInput
placeholder={placeholder}
maxLength={50}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
keyboardAppearance="dark"
/>
5.3.3 이벤트
- `Input` 컴포넌트에서 값이 변할 때마다 useState 변수 `newTask`에 저장
- 완료 버튼을 누르면 입력된 내용을 확인하고, `Input` 컴포넌트를 초기화
- props로 전달되는 값들을 설정하고 `PropTypes` 를 이용해 전달되는 값들의 타입과 필수 여부 지정
const Input = ({ value, onChangeText, onSubmitEditing }) => {
const width = Dimensions.get('window').width;
return (
<StyledInput
value={value}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
/>
);
};
Input.PropTypes = {
placeholder: PropTypes.string,
value: PropTypes.string.isRequired,
onChangeText: PropTypes.func.isRequired,
onSubmitEditing: PropTypes.func.isRequired,
};
5.4 할 일 목록 만들기
5.4.1 이미지 준비
`IconButton` 컴포넌트를 만들기 전에 프로젝트에서 사용할 아이콘 이미지를 다운로드한다. Google material design에서 iOS용 흰색 png 파일로 아이콘을 다운로드해서 `assets` 폴더 밑에 넣어줬다.
5.4.2 `IconButton` 컴포넌트
- 위에서 다운 받은 아이콘의 경로를 이용해 `Image` 컴포넌트를 사용
import CheckBoxOutline from '../assets/icons/check\_box\_outline.png';
import CheckBox from '../assets/icons/check\_box.png';
import DeleteForever from '../assets/icons/delete\_forever.png';
import Edit from '../assets/icons/edit.png';
export const images = {
uncompleted: CheckBoxOutline,
completed: CheckBox,
delete: DeleteForever,
update: Edit,
};
- `IconButton` 컴포넌트를 호출할 대 원하는 이미지의 종류를 props에 type으로 전달
- 아이콘의 색은 입력되는 텍스트와 동일한 색을 사용하도록 스타일 적용
- 사용자의 편의를 위해 버튼 주변을 클릭해도 인식하도록 margin을 줘서 여유공간 확보
import React from 'react';
import { TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';
import { images } from '../images';
const Icon = styled.Image`
tint-color: ${({ theme, completed }) =>
completed ? theme.done : theme.text};
width: 30px;
height: 30px;
margin: 10px;
\`;
const IconButton = ({ type, onPressOut, id, completed }) => {
const \_onPressOut = () => {
onPressOut(id);
};
return (
<TouchableOpacity onPressOut={\_onPressOut}>
<Icon source={type} completed={completed} />
</TouchableOpacity>
);
};
IconButton.defaultProps = {
onPressOut: () => {},
};
IconButton.propTypes = {
type: PropTypes.oneOf(Object.values(images)).isRequired,
onPressOut: PropTypes.func,
id: PropTypes.string,
completed: PropTypes.bool,
};
export default IconButton;
5.4.3 Task 컴포넌트
- `Task` 컴포넌트는 완료 여부를 확인하는 버튼과 입력된 할 일 내용, 항목 삭제 버튼, 수정 버튼으로 구성됨
const Task = ({ item, deleteTask, toggleTask, updateTask }) => {
const \[isEditing, setIsEditing\] = useState(false);
const \[text, setText\] = useState(item.text);
const \_handleUpdateButtonPress = () => {
setIsEditing(true);
};
const \_onSubmitEditing = () => {
if (isEditing) {
const editedTask = Object.assign({}, item, { text });
setIsEditing(false);
updateTask(editedTask);
}
};
const \_onBlur = () => {
if (isEditing) {
setIsEditing(false);
setText(item.text);
}
};
return isEditing ? (
<Input
value={text}
onChangeText={text => setText(text)}
onSubmitEditing={\_onSubmitEditing}
onBlur={\_onBlur}
/>
) : (
<Container>
<IconButton
type={item.completed ? images.completed : images.uncompleted}
id={item.id}
onPressOut={toggleTask}
completed={item.completed}
/>
<Contents completed={item.completed}>{item.text}</Contents>
{item.completed || (
<IconButton
type={images.update}
onPressOut={\_handleUpdateButtonPress}
/>
)}
<IconButton
type={images.delete}
id={item.id}
onPressOut={deleteTask}
completed={item.completed}
/>
</Container>
);
};
Task.propTypes = {
item: PropTypes.object.isRequired,
deleteTask: PropTypes.func.isRequired,
toggleTask: PropTypes.func.isRequired,
updateTask: PropTypes.func.isRequired,
};
5.5 기능 구현하기
다음과 같이 추가 기능, 삭제 기능, 완료 기능, 수정 기능을 구현했다.
5.5.1 추가 기능
- useState를 이용해 할 일 목록을 저장하고 관리할 `tasks` 변수를 생성
- 최신 항목이 가장 앞에 보이도록 `tasks` 역순으로 렌더링되도록 설정
<List width={width}>
{Object.values(tasks)
.reverse()
.map(item => (
<Task
key={item.id}
item={item}
/>
))}
</List>
const [newTask, setNewTask] = useState('');
const _addTask = () => {
const ID = Date.now().toString();
const newTaskObject = {
[ID]: { id: ID, text: newTask, completed: false },
};
setNewTask('');
_saveTasks({ ...tasks, ...newTaskObject });
};
const _saveTasks = async tasks => {
try {
await AsyncStorage.setItem('tasks', JSON.stringify(tasks));
setTasks(tasks);
} catch (e) {
console.error(e);
}
};
5.5.2 삭제 기능
- 삭제 버튼을 클릭했을 때 항목의 id를 이용하여 `tasks` 에서 해당 항목을 삭제
<List width={width}>
{Object.values(tasks)
.reverse()
.map(item => (
<Task
key={item.id}
item={item}
deleteTask={\_deleteTask}
/>
))}
</List>
const \_deleteTask = id => {
const currentTasks = Object.assign({}, tasks);
delete currentTasks\[id\];
\_saveTasks(currentTasks);
};
5.5.3 완료 기능
- 완료 여부를 선택하는 버튼
- 항목을 완료 상태로 만들어도 다시 미완료 상태로 돌아올 수 있도록 설정
const \_toggleTask = id => {
const currentTasks = Object.assign({}, tasks);
currentTasks\[id\]\['completed'\] = !currentTasks\[id\]\['completed'\];
\_saveTasks(currentTasks);
};
<List width={width}>
{Object.values(tasks)
.reverse()
.map(item => (
<Task
key={item.id}
item={item}
deleteTask={\_deleteTask}
toggleTask={\_toggleTask}
/>
))}
</List>
const Task = ({ item, deleteTask, toggleTask, updateTask }) => {}
return (
<Container>
<IconButton
type={item.completed ? images.completed : images.uncompleted}
id={item.id}
onPressOut={toggleTask}
/>
);
5.5.4 수정 기능
- 수정 버튼을 클릭하면 해당 항목이 `Input` 컴포넌트로 변경되면서 내용을 수정할 수 있음
- 수정 버튼을 클릭하면 항목의 현재 내용을 가진 `Input` 컴포넌트가 렌더링되어 사용자가 수정할 수 있도록
- 수정 상태를 관리하기 위한 `isEditing` 변수 생성
const _updateTask = item => {
const currentTasks = Object.assign({}, tasks);
currentTasks\[item.id] = item;
_saveTasks(currentTasks);
};
<List width={width}>
{Object.values(tasks)
.reverse()
.map(item => (
<Task
key={item.id}
item={item}
deleteTask={\_deleteTask}
toggleTask={\_toggleTask}
updateTask={\_updateTask}
/>
))}
</List>
const Task = ({ item, deleteTask, toggleTask, updateTask }) => {
const \[isEditing, setIsEditing\] = useState(false);
const \[text, setText\] = useState(item.text);
const \_handleUpdateButtonPress = () => {
setIsEditing(true);
};
const \_onSubmitEditing = () => {
if (isEditing) {
const editedTask = Object.assign({}, item, { text });
setIsEditing(false);
updateTask(editedTask);
}
};
const \_onBlur = () => {
if (isEditing) {
setIsEditing(false);
setText(item.text);
}
};
return isEditing ? (
<Input
value={text}
onChangeText={text => setText(text)}
onSubmitEditing={\_onSubmitEditing}
onBlur={\_onBlur}
/>
) : (
<Container>
<IconButton
type={item.completed ? images.completed : images.uncompleted}
id={item.id}
onPressOut={toggleTask}
completed={item.completed}
/>
<Contents completed={item.completed}>{item.text}</Contents>
{item.completed || (
<IconButton
type={images.update}
onPressOut={\_handleUpdateButtonPress}
/>
)}
<IconButton
type={images.delete}
id={item.id}
onPressOut={deleteTask}
completed={item.completed}
/>
</Container>
);
};
5.5.5 입력 취소하기
- 입력 중에 다른 영역을 클릭해서 `Input` 컴포넌트가 포커스를 잃으면 입력 중인 내용이 사라지고 취소되도록
const Input = ({ placeholder, value, onChangeText, onSubmitEditing }) => {
const width = Dimensions.get('window').width;
return (
<StyledInput
width={width}
placeholder={placeholder}
maxLength={50}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
keyboardAppearance="dark"
value={value}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
/>
);
};
Input.PropTypes = {
placeholder: PropTypes.string,
value: PropTypes.string.isRequired,
onChangeText: PropTypes.func.isRequired,
onSubmitEditing: PropTypes.func.isRequired,
};
5.6 부가 기능
5.6.1 데이터 저장하기
리액트 네이티브에서는 `AsyncStorage` 를 이용해 로컬에 데이터를 저장하고 불러오는 기능을 구현할 수 있다.
- `AsyncStorage`는 비동기로 동작하며 문자열로 된 key-value 형태의 데이터를 기기에 저장하고 불러올 수 있는 기능을 제공
- 공식 문서에는 deprecated라고 적혀있어, 잘 사용하지 않고 대신 `async-storage` 를 사용
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function App() {
const [tasks, setTasks] = useState({});
const _saveTasks = async tasks => {
try {
await AsyncStorage.setItem('tasks', JSON.stringify(tasks));
setTasks(tasks);
} catch (e) {
console.error(e);
}
};
5.6.2 데이터 불러오기
항목을 저장할 때 사용했던 키와 동일한 키로 데이터를 불러오고 객채로 변환하여 `tasks` 에 입력한다. 여기서 expo에서 제공하는 `AppLoading` 컴포넌트를 이용한다.
- `AppLoading` 컴포넌트는 특정 조건에서 로딩 화면이 유지되도록 하는 기능으로, 렌더링하기 전에 처리해야 하는 작업을 수행하는 데 유용하게 사용됨
export default function App() {
const \_loadTasks = async () => {
const loadedTasks = await AsyncStorage.getItem('tasks');
setTasks(JSON.parse(loadedTasks || '{}'));
};
};
import AppLoading from 'expo-app-loading';
export default function App() {
const \[isReady, setIsReady\] = useState(false);
return isReady ? (
<ThemeProvider theme={theme}>
...
</ThemeProvider>
) : (
<AppLoading
startAsync={\_loadTasks}
onFinish={() => setIsReady(true)}
onError={console.error}
/>
);
}
추후에 소스코드를 올려 놓도록 하겠다!