본문 바로가기
대외활동/멋쟁이사자처럼_프론트엔드 12기

REACT NATIVE 스터디. 2주차 5단원. 할일 관리 애플리케이션 만들기 DRACONIST

by 피스타0204 2025. 1. 23.

1. expo프로젝트 만들기

1.expo 프로젝트 생성

npx create-expo-app react-native-todo

 

글로벌 expo-cli 패키지가 더 이상 사용되지 않기 때문에 expo init react-native-todo가 아닌 위의 명령어를 사용하여야 합니다.

 

이렇게 하면 app/(tabs)/index.tsx가 홈화면으로 설정됩니다.


참고자료)

 

Create your first app

In this chapter, learn how to create a new Expo project.

docs.expo.dev

 

 

 

2. styled-components 라이브러리와 prop-types 라이브러리 설치

cd react-native-todo
npm install styled-components prop-types

 

3. src폴더를 만들고 그 안에 theme.js로 theme을 설정합니다.

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

 

4. src 폴더 아래에 App.js 폴더를 만들고 다음과 같이 적습니다.

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

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

const App = () => {
  return (
    <ThemeProvider theme={theme}>
        <Container></Container>
    </ThemeProvider>
  )
}

export default App


5. app/(tabs)/index.tsx와루트 디렉터리의 App.js와 연결합니다.
index.tsx는 expo프로젝트의 홈화면을 나타냅니다. 

index.tsx를 다음과 같이 변경하십시오.

import App from '../../src/App';  

export default App;

 

6-1. npm expo start를 하면 브라우저에서도 확인할 수 있는 개발 모드가 됩니다.
6-2. 안드로이드 애뮬레이터를 실행하고 googleplaystore에서 expogo앱을 설치하면, npm run android 로 핸드폰 화면을 확인해볼 수 있습니다.exp:// 로 시작하는 주소를 expo go에 입력하세요.

2. 타이틀 만들기

1) 가장 먼저 상단에 TODO List문구가 렌더링 되도록 만들어보겠씁니다. (src/App.js)

import React from 'react'
import styled, {ThemeProvider} from 'styled-components/native';
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;
`


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

export default App

 

ios나 안드로이드에서 실행해보았을 때 각각 다른 문제가 발생할 수 있습니다. 먼저 ios에서 발생할 수 있는 문제를 보겠습니다.

 

2) SafeAreaView 컴포넌트

스크린 일부에 센서 등을 넣어서 화면이 가려지는 노치 디자인(Notch Design)이 있는 기기(ios)는 TODO List가 뭔가 이상한 위치에 있는 것을 확인할 수 있을 것입니다. 리액트 네이티브에서는 자동으로 padding값이 적용되어 노치 디자인 문제를 해결할 수 있는 SafeAreaView 컴포넌트를 제공합니다.

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

const Container = styled.SafeAreaView`
    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;
`

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

export default App

 

react-native-safe-area-context 의 SafeAreaView를 사용하는 경우 안드로이드의 statusbar문제도 한번에 해결할 수 있습니다.

더보기
import React from 'react';
import styled, { ThemeProvider } from 'styled-components/native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { theme } from './theme';

const Container = styled(SafeAreaView)`
  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;
`;

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

export default App;

추가자료)

 

[React-Native] SafeArea에대해 알아보자!

1. 왜 필요해 ? SafeArea는 IOS에서 상단과 하단영역에 여백을 확보해줌으로써, 안전한 영역에 콘텐츠를 렌더링하게 해줍니다. 공식문서를 확인해보면 SafeAreaView중첩된 콘텐츠를 렌더링하고 탐색 모

lily-im.tistory.com

 

3) StatusBar 컴포넌트

안드로이드에서는 상태바가에 title 컴포넌트가 가려집니다.

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

const Container = styled.SafeAreaView`
    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: 20px;
`

const App = () => {
  return (
    <ThemeProvider theme={theme}>
        <Container>
         <StatusBar barStyle="light-content" backgroundColor={theme.background}/>
            <Title>TODO List</Title>
        </Container>
    </ThemeProvider>
  )
}

export default App

 

 

3. input 컴포넌트 만들기

1) input컴포넌트 기초

TextInput컴포넌트로 input 컴포넌트를 만들어보겠습니다.

import React from 'react';
import styled from 'styled-components/native';

const StyledInput = styled.TextInput`
    width: 100%;
    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 = () => {
    return <StyledInput />
}

export default Input

 

App.js

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

const Container = styled.SafeAreaView`
    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: 20px;
`

const App = () => {
  return (
    <ThemeProvider theme={theme}>
        <Container>
         <StatusBar barStyle="light-content" backgroundColor={theme.background}/>
            <Title>TODO List</Title>
            <Input/>
        </Container>
    </ThemeProvider>
  )
}

export default App

 

 

2) Dimensions

Dimensions를 이용해 양옆 공백을 만들어주자.

src/components/Input.js

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

const StyledInput = styled.TextInput`
    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 = () => {
    const width = Dimensions.get('window').width;
    return <StyledInput width={width}/>
}

export default Input

 

리액트 네이티비에서는 크기가 다양한 모바일 기기에 대응하기 위해 현재 화면 크기를 알 수 있는 Dimensions와 useWindoDimensions를 제공합니다.

Dimensions는 처음 값을 받아왔을 때 크기로 고정되기 때문에 기기회전시 화면 크기와 일치하지 않을 수 있습니다. 그렇기 때문에 Dimensions를 사용할 때는 이벤트 리스너를 등록하여 화면 크기 변화에 대응할 수 있도록 기능을 제공하고 있습니다.

 

useWindowDimensions는 알아서 자동으로 화면 크기 변경을 적용해주는 hook이므로 useWindowDimensions를 이용하는 것이 더 유리합니다.

import React from 'react';
import styled from 'styled-components/native';
import {useWindowDimensions} from 'react-native';

const StyledInput = styled.TextInput`
    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 = () => {
    const width = useWindowDimensions().width;
    return <StyledInput width={width}/>
}

export default Input

 

2) input 컴포넌트의 속성

 

//App.js

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

const Container = styled.SafeAreaView`
    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: 20px;
`

const App = () => {
  return (
    <ThemeProvider theme={theme}>
        <Container>
         <StatusBar barStyle="light-content" backgroundColor={theme.background}/>
            <Title>TODO List</Title>
            <Input placeholder="+ Add a Task"/>
        </Container>
    </ThemeProvider>
  )
}

export default App

 

//Input.js

import React from 'react';
import styled from 'styled-components/native';
import {useWindowDimensions} 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 = useWindowDimensions().width;
    return <StyledInput width={width} placeholder={placeholder} maxLength={50}/>
}

export default Input

 

<Input />로 placeholder를 전달하지 않으면, StyledInput은 TextInput 컴포넌트를 렌더링할 때 placeholder 속성에 대한 기본 값을 자동으로 처리합니다. 실제로 placeholder 텍스트를 전달하지 않으면 placeholder가 빈 값으로 처리됩니다.

이제 StyledInput에서 .attrs() 메서드를 사용해 색을 예쁘게 적용했습니다. 글자도 50자까지만 입력되는 지도 꼭 확인해보세요.

 

TextInput컴포넌트는 핸드폰 타자로 입력받을 때 첫글자를 대문자로 고정합니다. 

이제 키보드 설정을 변경해봅시다.

const Input = ({placeholder}) => {
    const width = useWindowDimensions().width;
    return <StyledInput width={width} placeholder={placeholder} maxLength={50}
    autoCapitalize="none" autoCorrect={false}  returnKeyType="done"
    />
}

 

keyboardAppearance="dark" 를 하면 키보드를 검게 바꿀 수도 있는데 이것은 ios에서만 가능합니다.

 

3) 이벤트

이제 Input 컴포넌트에 입력되는 값을 이용할 수 있도록 이벤트를 등록하겠습니다.

useState을 이용해 Input컴포넌트의 값이 변할 때마다 newTask값에 저장되도록 작성했습니다. 거기에 와뇰 버튼을 누르면 입력된 내용을 확인하고 Input 컴포넌트를 초기화하도록 만들었습니다.

//App.js

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

const Container = styled.SafeAreaView`
    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: 20px;
`;

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.js

import React from 'react';
import styled from 'styled-components/native';
import {useWindowDimensions} from 'react-native';
import PropTypes from 'prop-types';

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, value, onChangeText, onSubmitEditing}) => {
    const width = useWindowDimensions().width;
    return <StyledInput 
        width={width}
        placeholder={placeholder}
        maxLength={50}
        autoCapitalize="none"
        autoCorrect={false}
        returnKeyType="done"
        keyboardAppearance="dark" //ios only

        value={value}
        onChangeText={onChangeText} 
        onSubmitEditing={onSubmitEditing}
    />
};
Input.propTypes = {
    placeholder: PropTypes.string,
    value: PropTypes.string.isRequired,
    onChangeText: PropTypes.func.isRequired,
    onSubmitEditing: PropTypes.func.isRequired,
}

export default Input

 

 

4. 할일목록만들기

1) icon다운

https://fonts.google.com/icons

위 링크에서 checkbox_white버전을 1x,2x,3x 크기로 다운 받읍시다.

파일명@2x.png이렇게 뒤에 @2x를 붙이면 react native에서 자동으로 화면사이즈에 맞는 크기를 불러옵니다.

baseline_check_box_outline.png로 체크 안된 버전을 저장하고, baseline_check_box.png로 체크된 버전을 저장합시다.

edit_btn과 delete_btn도 저장합시다.

 

2) IconButton컴포넌트

아이콘 이미지를 관리할 images.js파일을 만듭시다. src폴더 바로 아래 만듭시다.

//  images.js

import CheckBoxOutline from '../assets/images/baseline_check_box_outline.png';
import CheckBox from '../assets/images/baseline_check_box.png';
import DeleteForever from '../assets/images/delete_btn.png';
import Edit from '../assets/images/edit_btn.png';


export const images = {
    uncompleted: CheckBoxOutline,
    completed: CheckBox,
    delete: DeleteForever,
    update: Edit,
}

 

이미지를 const로 내보냈기 때문에 import {image} from './images' 이렇게 가져와야 합니다.

 

//src/components/IconButton.js

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})=> theme.text};
    width: 30px;
    height: 30px;
    margin: 10px;
`;

const IconButton = ({ type, onPressOut})=>{
    return (
        <TouchableOpacity onPressOut={onPressOut}>
            <Icon source={type} />
        </TouchableOpacity>
    );
};

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

export default IconButton

 

 

IconButton.propTypes는 React PropTypes를 사용하여 IconButton 컴포넌트의 props에 대해 명시적으로 타입과 유효성을 정의하는 코드입니다. 이는 컴포넌트를 사용하는 개발자들에게 props를 어떻게 전달해야 하는지 명확히 알려주며, 잘못된 props가 전달되었을 때 경고를 발생시킵니다.

 

이미지 종류별로 컴포넌트를 만들지 않고 IconButton 컴포넌트를 호출할 때 원하는 이미지의 종류를ㄹ props에 type으로 전달하도록 작성했으며 아이콘의 색은 입력되는 텍스트와 동일한 색을 사용하도록 스타일을 적용했습니다.

 

책에서 추가로 작성된 대로 TouchableOpacity를 Pressable로 바꾸고, hitSlop을 추가하여 터치 영역을 확장할 수 있습니다.

더보기
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})=> theme.text};
    width: 30px;
    height: 30px;
    margin: 10px;
`;

const IconButton = ({ type, onPressOut})=>{
    return (
        <TouchableOpacity onPressOut={onPressOut}>
            <Icon source={type} />
        </TouchableOpacity>
    );
};

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

export default IconButton

 

3) Task컴포넌트

IconButton 컴포넌트를 통해 Task 컴포넌트를 만들어보겠습니다.

//Task.js

import React from 'react';
import styled from 'styled-components/native';
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.js

import React, { useState } from 'react';
import styled, { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';
import { StatusBar, useWindowDimensions } from 'react-native';
import Input from './components/Input';
import {images} from './images'
import IconButton from './components/IconButton';
import Task from './components/Task';

const Container = styled.SafeAreaView`
    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: 20px;
`;

  //할일 목록
  const List = styled.ScrollView`
    flex:1;
    width: ${({width}) => width-40}px;
  `

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

  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} />
        <List width={width}>
          <Task text='ulbbang'/>
          <Task text='React Native study'/>
          <Task text='Spring Boot study'/>
          <Task text='Update my checks'/>
        </List>
      </Container>
    </ThemeProvider>
  );
}

 

ScrollView 컴포넌트를 이용해 할일 항목의 수가 많아져 화면을 넘어가도 스크롤을 이용할 수 있도록 하였고 양쪽 공백을 유지하기 위해 useWindowDimensions를 사용했습니다.

5. 기능 구현하기

1) 할일 항목의 데이터 스타일 정하기

import React, { useState } from 'react';
import styled, { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';
import { StatusBar, useWindowDimensions } from 'react-native';
import Input from './components/Input';
import {images} from './images'
import IconButton from './components/IconButton';
import Task from './components/Task';

const Container = styled.SafeAreaView`
    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: 20px;
`;

  //할일 목록
  const List = styled.ScrollView`
    flex:1;
    width: ${({width}) => width-40}px;
  `

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

  const [tasks, setTasks] = useState({   //초기값
    '1': {id: '1', text: 'ulbbang', completed: false},
    '2': {id: '2', text: 'ulbbang2', completed: true},
    '3': {id: '3', text: 'ulbbang3', completed: false},
    '4': {id: '4', text: 'ulbbang4', completed: false},
  })

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

  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} />
        <List width={width}>
          {Object.values(tasks)
              .reverse()
              .map(item => (
                <Task text={item.text} key={item.id}/>
              ))
          }
        </List>
      </Container>
    </ThemeProvider>
  );
}

 

key는 리액트에서는 컴포넌트 배열을 렌더링했을 때 어떤 아이템이 추가 수정 삭제 되었는 지 식별하는 것을 돕는 고유값으로 리액트에서 특별하게 관리되면 자식 컴포넌트의 props로 전달되지 않습니다.

id를 key로 지정하겠습니다. key값은 컴포넌트의 속성값으로 넣습니다.key={item.id}

 

addTask함수도 수정하여 addTAsk함수가 호출되면 할일이 추가되게 해봤습니다.

 

2) 삭제 기능

//App.js

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

        <List width={width}>
          {Object.values(tasks)
              .reverse()
              .map(item => (
                <Task text={item.text} key={item.id} deleteTask={_deleteTask} />
              ))
          }


const currentTasks = Object.assign({}, tasks);
tasks 객체를 복사하여 새로운 객체 currentTasks를 생성합니다. 객체를 직접 수정하지 않고 복사본을 작업 대상으로 사용합니다

 

delete currentTasks[id];

currentTasks 객체에서 전달받은 id를 키로 가진 할 일을 삭제합니다.

 

setTasks(currentTasks);

삭제된 상태의 currentTasks 객체를 tasks 상태로 업데이트하여 리렌더링을 트리거합니다.

 

//Task.js

Task컴포넌트에 생성된 항목 삭제함수와 항목 내용 전체를 전달해 자식 컴포넌트에서도 항목의 id를 확인할 수 있도록 수정햇습니다. 이제 Task 컴포넌트의 삭제 버튼을 눌러 삭제되도록 만들어보겠습니다.

우리는 IconButton가지 수정해야 합니다.

 

import React from 'react';
import styled from 'styled-components/native';
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 = ({item, deleteTask}) => {
  return (
        <Container>
            <IconButton type={images.uncompleted} />
            <Contents>{item.text}</Contents>
            <IconButton type={images.update} />
            <IconButton type={images.delete} id={item.id} onPressOut={deleteTask}/>
        </Container>

    )
};

Task.propTypes = {
    item: PropTypes.object.isRequired,
    deleteTask: PropTypes.func.isRequired,
}


export default Task

 

//IconButton.js

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 }) => theme.text};
    width: 30px;
    height: 30px;
    margin: 10px;
`;

const IconButton = ({ type, onPressOut = () => {}, id }) => {  // 기본값을 매개변수에 직접 설정
    const _onPressOut = () => {
        onPressOut(id);
    };

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

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

export default IconButton;

 

3) 완료기능

완료시 이미지변경과 수정기능을 사용하지 않도록 수정버튼이 나타나지 않게 하겠습니다.

//App.js

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

 

 

//Task.js

import React from 'react';
import styled from 'styled-components/native';
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, completed})=> (completed? theme.done : theme.text)};
    text-decoration-line: ${({completed}) => completed ? 'line-through': 'none'};
`

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.delete} id={item.id} onPressOut={deleteTask}
                completed={item.completed}
            />
        </Container>

    )
};

Task.propTypes = {
    item: PropTypes.object.isRequired,
    deleteTask: PropTypes.func.isRequired,
    toggleTask: PropTypes.func.isRequired,
}


export default Task

 

//IconButton.js

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.propTypes = {
    type: PropTypes.oneOf(Object.values(images)).isRequired,
    onPressOut: PropTypes.func,
    id: PropTypes.string,
    completed: PropTypes.bool,
};

export default IconButton;

 

4) 수정기능

//App.js

//...
  const _updateTask = item => {
    const currentTasks = Object.assign({}, tasks);
    currentTasks[item.id] = item;
    setTasks(currentTasks);
  }

//...
          {Object.values(tasks)
              .reverse()
              .map(item => (
                <Task item={item} key={item.id} deleteTask={_deleteTask} toggleTask={_toggleTask} updateTask={_updateTask}/>
              ))
          }

 

//Task.js

import React, { useState } from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';
import IconButton from './IconButton';
import {images} from '../images';
import Input from './Input';

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, completed})=> (completed? theme.done : theme.text)};
    text-decoration-line: ${({completed}) => completed ? 'line-through': 'none'};
`

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);
    }
  }
  
  return isEditing ? (
        <Input value={text} onChangeText={text => setText(text)} onSubmitEditing={_onSubmitEditing} />)
        : (
        <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,
}


export default Task



5) 입력 취소하기

항목을 추가하거나 수정하는 도중에는 입력을 취소할 방법이 없습니다. 입력 중에 다른 영역을 클릭해서 Input컴포넌트가 포커스를 잃으면 입력중인 내용이 사라지고 취소되도록 input 컴포넌트를 수정하겠습니다.

//App.js

import React, { useState } from 'react';
import styled, { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';
import { StatusBar, useWindowDimensions } from 'react-native';
import Input from './components/Input';
import {images} from './images'
import IconButton from './components/IconButton';
import Task from './components/Task';

const Container = styled.SafeAreaView`
    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: 20px;
`;

  //할일 목록
  const List = styled.ScrollView`
    flex:1;
    width: ${({width}) => width-40}px;
  `

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

  const [tasks, setTasks] = useState({   //초기값
    '1': {id: '1', text: 'ulbbang', completed: false},
    '2': {id: '2', text: 'ulbbang2', completed: true},
    '3': {id: '3', text: 'ulbbang3', completed: false},
    '4': {id: '4', text: 'ulbbang4', completed: false},
  })

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

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

  const _toggleTask = id => {
    const currentTasks = Object.assign({}, tasks);
    currentTasks[id]['completed'] = !currentTasks[id]['completed'];
    setTasks(currentTasks);
  }

  const _updateTask = item => {
    const currentTasks = Object.assign({}, tasks);
    currentTasks[item.id] = item;
    setTasks(currentTasks);
  }

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

  //포커스 잃으면 초기화
  const _onBlur = () => {
    setNewTask('');
  }

  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}         onBlur={_onBlur}/>
        <List width={width}>
          {Object.values(tasks)
              .reverse()
              .map(item => (
                <Task item={item} key={item.id} deleteTask={_deleteTask} 
                toggleTask={_toggleTask} updateTask={_updateTask}
        
                />
              ))
          }
        </List>
      </Container>
    </ThemeProvider>
  );
}

 

//Task.js

import React, { useState } from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';
import IconButton from './IconButton';
import {images} from '../images';
import Input from './Input';

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, completed})=> (completed? theme.done : theme.text)};
    text-decoration-line: ${({completed}) => completed ? 'line-through': 'none'};
`

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,
}


export default Task

 

//Input.js

import React from 'react';
import styled from 'styled-components/native';
import {useWindowDimensions} from 'react-native';
import PropTypes from 'prop-types';

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, value, onChangeText, onSubmitEditing, onBlur,}) => {
    const width = useWindowDimensions().width;
    return <StyledInput 
        width={width}
        placeholder={placeholder}
        maxLength={50}
        autoCapitalize="none"
        autoCorrect={false}
        returnKeyType="done"
        keyboardAppearance="dark" //ios only

        value={value}
        onChangeText={onChangeText} 
        onSubmitEditing={onSubmitEditing}
       onBlur={onBlur}
    />
};
Input.propTypes = {
    placeholder: PropTypes.string,
    value: PropTypes.string.isRequired,
    onChangeText: PropTypes.func.isRequired,
    onSubmitEditing: PropTypes.func.isRequired,
    onBlur: PropTypes.func.isRequired,
}

export default Input

6. 부가기능

이번에는 데이터를 로컬저장소에 저장하고 불러오는 기능과 로딩화면을 변경하는 방법에 대해 알아봅시다.

 

1) 데이터 저장하기

리액트 네이티브에서는 AsyncStorage를 이용해 로컬에 데이터를 저장하고 불러오는 기능을 구현할 수 있습니다. key-value 형태의 데이터를 저장할 수 있습니다.

expo install @react-native-async-storage/async-storage

 

작성된 _loadTasks함수가 애플리케이션 로딩단계에서 실행되고 첫화면이 나타나기 전에 완료되엉 불러온 항목이 화면에 렌더링되는 것이 자연스러울 것입니다.

 

2) 데이터 불러오기

npm install expo-app-loading

 

//App.js

import React, { useState } from 'react';
import styled, { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';
import { StatusBar, useWindowDimensions } from 'react-native';
import Input from './components/Input';
import {images} from './images'
import IconButton from './components/IconButton';
import Task from './components/Task';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AppLoading from 'expo-app-loading';


const Container = styled.SafeAreaView`
    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: 20px;
`;

  //할일 목록
  const List = styled.ScrollView`
    flex:1;
    width: ${({width}) => width-40}px;
  `

export default function App() {
  const { width: width } = useWindowDimensions();
  //첫화면 렌더링 여부
  const [isReady, setIsReady] = useState(false); 
  const [newTask, setNewTask] = useState('');
  const [tasks, setTasks] = useState({ 
  })


  //로컬 저장소
  const _saveTasks = async tasks => {
    try{
      await AsyncStorage.setItem('tasks', JSON.stringify(tasks));
      setTasks(tasks);
    }catch (e) {
      console.error(e);
    }

  }

  const _loadTasks = async () => {
    const loadedTasks = await AsyncStorage.getItem('tasks');
    setTasks(JSON.parse(loadedTasks || '{}'));
  }

  const _addTask = () => {
    const ID = Date.now().toString();
    const newTaskObject = {
      [ID]: {id: ID, text: newTask, completed: false},
    };
    setNewTask('');
    _saveTasks({...tasks, ...newTaskObject});
    
  };

  const _deleteTask = id => {
    const currentTasks = Object.assign({}, tasks);
    delete currentTasks[id];
    _saveTasks(currentTasks);
  }

  const _toggleTask = id => {
    const currentTasks = Object.assign({}, tasks);
    currentTasks[id]['completed'] = !currentTasks[id]['completed'];
    setTasks(currentTasks);
  }

  const _updateTask = item => {
    const currentTasks = Object.assign({}, tasks);
    currentTasks[item.id] = item;
    _saveTasks(currentTasks);
  }

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

  //포커스 잃으면 초기화
  const _onBlur = () => {
    setNewTask('');
  }



  return isReady ? (
    <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}         onBlur={_onBlur}/>
        <List width={width}>
          {Object.values(tasks)
              .reverse()
              .map(item => (
                <Task item={item} key={item.id} deleteTask={_deleteTask} 
                toggleTask={_toggleTask} updateTask={_updateTask}
        
                />
              ))
          }
        </List>
      </Container>
    </ThemeProvider>)  :(
      <AppLoading startAsync={_loadTasks}
      onFinish={()=> setIsReady(true)}
      onError={console.error}/>
  );
}

 

3) 로딩화면 바꾸기

AppLoading의 내용을 바꿔봅시다.

app.json에서 1242*2436으로 저장한 이미지로 splash 이미지를 바꿉니다.

    "plugins": [
      "expo-router",
      [
        "expo-splash-screen",
        {
          "image": "./assets/images/splash.png",
          "imageWidth": 200,
          "resizeMode": "contain",
          "backgroundColor": "#ffffff"
        }
      ]

 

기기에 따라 공백이 생길 수 있는데 resizeMode난 backgroundColor값을 변경해서 공백을 제거할 수 있습니다.

{
  "expo": {
    "name": "react-native-todo",
    "slug": "react-native-todo",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/images/icon02.png", //아이콘 변경
    "scheme": "myapp",
    "userInterfaceStyle": "automatic",
    "newArchEnabled": true,
    "ios": {
      "supportsTablet": true
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/images/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      }
    },
    "web": {
      "bundler": "metro",
      "output": "static",
      "favicon": "./assets/images/favicon.png"
    },
    "plugins": [
      "expo-router",
      [
        "expo-splash-screen",
        {
          "image": "./assets/images/splash.png",  //로딩화면 변경
          "imageWidth": 200,
          "resizeMode": "contain",
          "backgroundColor": "#ffffff"
        }
      ]
    ],
    "experiments": {
      "typedRoutes": true
    }
  }
}

 

스플래시 이미지는 안바뀌는데 모르겠ㅅㄷ