본문 바로가기
  • GDG on campus Ewha Tech Blog
3-1기 스터디/React Native

[3주차] 리액트 네이티브로 ✅TODO List 만들기

by 마크투비 2021. 11. 8.

이번 주 리액트 네이티브 스터디 3주차 내용은 ✅투두 리스트 만들기입니다!

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씩 공백을 주기 위해 리액트 네이티브가 제공하는 현재 화면의 크기를 알 수 있는 DimensionsuseWindowDimensions를 이용

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

댓글