Develop/React Native

[React Native] 리액트 네이티브로 ✅TODO List 만들기

마크투비 2021. 11. 14. 00:18

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}  
    />  
  );  
}  

추후에 소스코드를 올려 놓도록 하겠다!