[리액트 네이티브] 할 일 관리 Todo List 애플리케이션 만들기
my code archive
article thumbnail
반응형

🤍 목표 기능

  • 등록 : 할 일 추가
  • 수정 : 완료되지 않은 할 일 항목 수정
  • 삭제 : 할 일 삭제
  • 완료 : 할 일 항목의 완료 상태 관리 기능

🤍 프로젝트 준비

프로젝트 생성

exop init react-native-todo //프로젝트 생성
cd react-native-todo
npm install styled-components prop-types //프로젝트에서 사용할 라이브러리 설치

프로젝트에서 사용할 색상 정의 - theme.js

export const theme = {
    background: '#101010',
    itemBackground: '#313131',
    main: '#778bdd',
    text: '#cfcfcf',
    done: '#616161',
}

1.타이틀 만들기

import React from 'react';
import styled, {ThemeProvider} from 'styled-components';
import { theme } from './theme';

const Container = styled.View`
    flex: 1;
    background-color: ${({theme}) => theme.background};
    align-items: center;
    justify-content: flex-start;
`;

const Title = styled.Text`
    font-size: 40px;
    font-weight: 600;
    color: ${({theme}) => theme.main};
    align-self: flex-start;
    margin: 0px 20px;
`;

export default function App() {

    return (
        <ThemeProvider theme={theme}>
            <Container>
                <Title>TODO List</Title>
            </Container>
        </ThemeProvider>
    );
};

아이폰처럼 노치 디자인이 있는 기기는 Title 컴포넌트의 일부가 가려지는 현상이 발생함. (내 핸드폰은 아이폰 12pro이다.)

리액트 네이티브에서 제공하는 SafeAreaView 컴포넌트 사용

  • SafeAreaView : 자동으로 padding값이 적용되어 노치 디자인 문제를 해결할 수 있음.

2.Input 컴포넌트 만들기

Input 컴포넌트는 할 일 항목 추가 & 수정 시 사용될 예정이다. 화면에 너무 꽉차 답답해 보이지 않도록 Dimensions 를 사용해 본다.

  • Dimensions : 리액트 네이티브에서는 크기가 다양한 모바일 기기에 대응하기 위해 현재 화면의 크기를 알 수 있는 Dimensions, useWindowDimensions를 제공함. 두 기능 모두 현재 기기의 화면 크기를 알 수 있으며 Dimensions는 처음 받아왔을 때의 크기로 고정되기 때문에 화면 회전 시에는 일치하지 않을 수 있다. 이런 상황을 위해 이벤트 리스너(event listener)를 등록하여 화면 크기 변화에 대응할 수 있는 기능을 제공한다.
  • useWindowDimensions : 리액트 네이티브에서 제공하는 Hooks 중 하나이며 화면 크기 변경 시 크기, 너비, 높이를 자동으로 업데이트함.

Dimensions를 활용해 화면 너비를 구하고, props로 전달하여 스타일 작성 시 화면 너비를 이용할 수 있도록 구현. -> 크기가 다른 기기에서도 항상 동일한 좌우 공백이 나타나게 됨.

import React from "react";
import styled from "styled-components";
import { Dimensions } from "react-native";

const StyledInput = styled.TextInput.attrs(({theme})=>({
    placeholderTextColor: theme.main,
}))`
    width: ${({width}) => width - 40}px;
    height: 60px;
    margin: 3px 0;
    padding: 15px 20px;
    border-radius: 10px;
    background-color: ${({ theme }) => theme.itemBackground};
    font-size: 25px;
    color: ${({ theme }) => theme.text};
`;

const Input = ({placeholder}) => {
    const width = Dimensions.get('window').width;

};


export default Input;

Input 컴포넌트에 다양한 속성 설정하기

placeholder에 적용할 문자열을 props로 받아 설정하고 입력 글자수는 50자로 제한

TextInput 컴포넌트에서 제공하는 속성을 이용해 키보드 설정 변경

    return (
        <StyledInput
        width={width}
        placeholder={placeholder}
        maxLength={50}
        autoCapitalize="none"   /* 자동으로 대문자 전환 off */
        autoCorrect={false} /* 자동 수정 off */
        returnKeyType="done"    /* 키보드 완료 버튼 설정 */
        keyboardAppearance="dark"   /* 아이폰 키보드 색상 어둡게 설정 */
        value={value}
        onChangeText={onChangeText}
        onSubmitEditing={onSubmitEditing}/>    
    );

💻 실행 화면 : 키보드 색상이 어둡게 잘 변경되었다.

3.이벤트

useState를 이용하여 newTask 상태 변수, 세터 함수 생성하고 Input 컴포넌트에서 값이 변경될 때마다 newTask에 저장하도록 구현.

import React, {useState} from 'react';

export default function App() {
    
    const [newTask, setNewTask] = useState('');

    const _addTask = () => {
    	alert(`Add: ${newTask}`);
        setNewTask('');
    };

    const _handleTextChange = text => {
        setNewTask(text);
    };

    return (
        <ThemeProvider theme={theme}>
            <Container>
                <StatusBar barStyle="light-content" backgroundColor={theme.background}/>
                <Title>TODO List</Title>
                <Input
                    placeholder="+ Add a Task"
                    value={newTask}
                    onChangeText={_handleTextChange}
                    onSubmitEditing={_addTask}
                />
            </Container>
        </ThemeProvider>
    );
};

Input 컴포넌트에서 props로 전달된 값을 이용하도록 수정

import PropTypes from 'prop-types';

...

const Input = ({placeholder, value, onChangeText, onSubmitEditing}) => {
    const width = Dimensions.get('window').width;

    return (
        <StyledInput
		...
        value={value}
        onChangeText={onChangeText}
        onSubmitEditing={onSubmitEditing}/>    
    );
};

Input.prototype = {
    placeholder: PropTypes.string,
    value: PropTypes.string.isRequired,
    onChangeText: PropTypes.func.isRequired,
    onSubmitEditing: PropTypes.func.isRequired,
}

export default Input;

💻 실행 화면 : Input 컴포넌트에 입력된 값이 alert창으로 나타남.

4. 할 일 목록 만들기

  • IconButton 컴포넌트 : 완료,수정,삭제 버튼으로 사용할 컴포넌트
  • Task 컴포넌트 : 목록의 각 항목으로 사용할 컴포넌트

버튼에 사용할 아이콘 이미지를 구글 폰트에서 다운 받는다.

https://fonts.google.com/icons?selected=Material+Icons 

 

Material Symbols and Icons - Google Fonts

Material Symbols are our newest icons consolidating over 2,500 glyphs in a single font file with a wide range of design variants.

fonts.google.com

다운로드 완료 후 프로젝트의 assets 폴더에 하위 폴더 icons 생성 후 복사

리액트 네이티브에서 제공하는 Image 컴포넌트는 프로젝트에 있는 이미지 파일의 경로, URL을 이용하여 원격 이미지 렌더링 가능 - images.js

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으로 전달하도록 구현함, 아이콘 색은 입력되는 텍스트와 동일한 색을 사용하도록 스타일 적용 - IconButton.js

import React from "react";
import { TouchableOpacity } from "react-native";
import styled from "styled-components";
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}) => {

    return (
        <TouchableOpacity onPressOut={_onPressOut}>
            <Icon source={type}/>
        </TouchableOpacity>
    );
};

IconButton.propTypes = {
    type: PropTypes.oneOf(Object.values(images)).isRequired,
    onPressOut: PropTypes.func,
};

export default IconButton;

💻 실행 화면 : 완성된 IconButton 컴포넌트를 App에서 적용 시 아래와 같은 화면이 출력됨

Task.js

  • 완료 여부 확인 버튼
  • 입력된 할 일 내용
  • 항목 삭제 버튼
  • 수정 버튼으로 구성

할 일 내용은 props로 전달되어 오는 값을 활용, 체크박스, 수정, 삭제 버튼은 IconButton 컴포넌트를 이용해 만듦.

import React from "react";
import styled from "styled-components";
import PropTypes from 'prop-types';
import IconButton from "./IconButton";
import { images } from "../images";

const Container = styled.View`
    flex-direction: row;
    align-items: center;
    background-color: ${({theme})=>theme.itemBackground};
    border-radius: 10px;
    padding: 5px;
    margin: 3px 0px;
`;

const Contents = styled.Text`
    flex: 1;
    font-size: 24px;
    color: ${({theme})=>theme.text};
`;

const Task = ({text})=>{
    return (
        <Container>
            <IconButton type={images.uncompleted}/>
            <Contents>{text}</Contents>
            <IconButton type={images.update} /> 
            <IconButton type={images.delete} />
        </Container>
    );
};

Task.propTypes = {
    text: PropTypes.string.isRequired,
};

export default Task;

💻 실행 화면 : App 컴포넌트에서 Task 컴포넌트를 이용해 할 일 목록을 생성하면 아래와 같은 화면이 출력됨.

🤍 프로젝트 기능 구현하기

1. 추가 기능

-useState를 이용해 할 일 목록을 저장하고 관리할 tasks 변수 생성.

-최신 항목이 가장 앞에 보이도록 tasks를 역순으로 랜더링되도록 작성.

 

=> key는 리액트에서 컴포넌트 배열 렌더링 시, 어떤 아이템이 추가, 수정, 삭제되었는지 식별하는 것을 돕는 고유값이며 자식 컴포넌트의 props로 전달되지 않음. key를 지정하지 않을 경우 경고 메시지가 나타나므로 각 항목마다 고유한 id를 key로 지정함.

-addTask 함수 호출 시 tasks에 새로운 할 일 항목이 추가됨.

export default function App() {
    
    const width = Dimensions.get('window').width;
    const [newTask, setNewTask] = useState('');
    const [tasks, setTasks] = useState({
        '1' : {id: '1',text: '리액트 공부', completed: false},
        '2' : {id: '1',text: '리액트 블로그 정리', completed: true},
        '3' : {id: '1',text: '운동', completed: false},
        '4' : {id: '1',text: '알고리즘 문제 풀기', completed: false},
    });

    //할 일 추가
    const _addTask = () => {
        const ID = Date.now().toString();
        const newTaskObject = {
            [ID] : {id: ID, text: newTask, completed: false},
        };
        setNewTask('');
        setTasks({...tasks, ...newTaskObject});
    };

    return (
        <ThemeProvider theme={theme}>
            <Container>
                <StatusBar barStyle="light-content" backgroundColor={theme.background}/>
                <Title>TODO List</Title>
                <Input
                    placeholder="+ Add a Task"
                    value={newTask}
                    onChangeText={_handleTextChange}
                    onSubmitEditing={_addTask}
                />
                <List width={width}>
                    {Object.values(tasks)
                        .reverse()
                        .map(item => (
                            <Task key={item.id} text={item.text} />
                        ))}
                </List>
                {/* <IconButton type={images.uncompleted} />
                <IconButton type={images.completed} />
                <IconButton type={images.delete} />
                <IconButton type={images.update} /> */}
            </Container>
        </ThemeProvider>
    );
};

💻 실행 화면

2. 삭제 기능

_deleteTask : 삭제 버튼 클릭 시 항목 id를 이용하여 tasks에서 해당 항목을 삭제

//할 일 삭제
    const _deleteTask = id => {
        const currentTasks = Object.assign({},tasks);
        delete currentTasks[id];
        setTasks(currentTasks);
    };

    return (
        <ThemeProvider theme={theme}>
            <Container>
                <StatusBar barStyle="light-content" backgroundColor={theme.background}/>
                <Title>TODO List</Title>
                <Input
                    placeholder="+ Add a Task"
                    value={newTask}
                    onChangeText={_handleTextChange}
                    onSubmitEditing={_addTask}
                />
                <List width={width}>
                    {Object.values(tasks)
                        .reverse()
                        .map(item => (
                            <Task key={item.id} item={item} deleteTask={_deleteTask}/>
                        ))}
                </List>
            </Container>
        </ThemeProvider>
    );
};

Task 컴포넌트를 전달받은 내용이 사용되도록 수정

//Task.js
<IconButton type={images.delete} id={item.id} onPressOut={deleteTask}/>

props로 전달된 deleteTask 함수는 삭제 버튼으로 전달, 함수에서 필요한 항목 id도 함께 전달

IconButton 컴포넌트에서 잔달된 함수를 이용하도록 수정.

=> props로 onPrssOut이 전달되지 않았을 때에도 문제가 발생하지 않도록 defaultProps를 이용해 onPressOut의 기본값 지정

const IconButton = ({ type, onPressOut, id }) => {

    const _onPressOut = () => {
        onPressOut(id);
    };

    return (
        <TouchableOpacity onPressOut={_onPressOut}>
            <Icon source={type} />
        </TouchableOpacity>
    );
};

IconButton.defaultProps = {
    onPressOut : () => {},
}

IconButton.propTypes = {
    type: PropTypes.oneOf(Object.values(images)).isRequired,
    onPressOut: PropTypes.func,
    id: PropTypes.string,
};

export default IconButton;

💻 실행 화면

3. 완료 기능

항목을 완료 상태로 만들어도 다시 미완료 상태로 돌아올 수 있도록 구현

...

const _toggleTask = id => {
        const currentTasks = Object.assign({},tasks);
        currentTasks[id]['completed']=!currentTasks[id]['completed'];
        setTasks(currentTasks);
    }
    
...
    return (
        <ThemeProvider theme={theme}>
            <Container>
                ...
                <List width={width}>
                    {Object.values(tasks)
                        .reverse()
                        .map(item => (
                            <Task key={item.id} item={item} deleteTask={_deleteTask} toggleTask={_toggleTask} />
                        ))}
                </List>
            </Container>
        </ThemeProvider>
    );

함수 호출 시마다 완료 여부를 나타내는 completed 값이 전달되는 함수 작성

const Task = ({item, deleteTask, toggleTask})=>{
    return (
        <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} />}
            {/* <IconButton type={images.update} /> */}
            <IconButton type={images.delete} id={item.id} onPressOut={deleteTask} completed={item.completed}/>
        </Container>
    );
};

Task.propTypes = {
    //text: PropTypes.string.isRequired,
    item: PropTypes.object.isRequired,
    deleteTask: PropTypes.func.isRequired,
    toggleTask: PropTypes.func.isRequired,
};

export default Task;

💻 실행 화면

4. 수정 기능

수정 버튼 클릭 시 해당 항목이 Input 컴포넌트로 변경되면서 내용 수정 가능하도록 구현

=> 수정 완료된 항목 전달 시 tasks에서 해당 항목을 변경하는 함수 작성

   //할 일 수정
    const _updateTask = item => {
        const currentTasks = Object.assign({},tasks);
        currentTasks[item.id] = item;
        _saveTasks(currentTasks);
    };
    ...
    return isReady ? (
        <ThemeProvider theme={theme}>
            <Container>
                ...
                <List width={width}>
                    {Object.values(tasks)
                        .reverse()
                        .map(item => (
                            <Task
                                key={item.id}
                                item={item}
                                deleteTask={_deleteTask}
                                toggleTask={_toggleTask}
                                updateTask={_updateTask}
                            />
                        ))}
                </List>
            </Container>
        </ThemeProvider>
    );
};

수정 상태를 관리하기 위해 isEditing 변수 생성, 수정 버튼 클릭 시 값이 변하도록 작성

import Input from "./Input";

...
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>
           ...
            {item.completed || (
            <IconButton
                type={images.update} 
                onPressOut={_handleUpdateButtonPress}
            />
            )}
           ...
        </Container>
    );
};

Task.propTypes = {
    //text: PropTypes.string.isRequired,
    item: PropTypes.object.isRequired,
    deleteTask: PropTypes.func.isRequired,
    toggleTask: PropTypes.func.isRequired,
    updateTask: PropTypes.func.isRequired
};

export default Task;

 

+ 데이터 저장, 저장 데이터 불러오기 기능 추가

 

약간의 충돌이 있었지만 git pull로 해결하고 깃허브에도 커밋 완료

반응형
profile

my code archive

@얼레벌레 개발자👩‍💻

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

반응형