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
}
}
}
스플래시 이미지는 안바뀌는데 모르겠ㅅㄷ
'대외활동 > 멋쟁이사자처럼_프론트엔드 12기' 카테고리의 다른 글
REACT NATIVE 스터디. 2주차 7단원. Context API DRACONIST (0) | 2025.01.29 |
---|---|
REACT NATIVE 스터디. 2주차 6단원. Hooks DRACONIST (0) | 2025.01.23 |
REACT NATIVE 스터디. 1주차 4단원 스타일 DRACONIST (1) | 2025.01.16 |
REACT NATIVE 스터디. 1주차 3단원. 컴포넌트 DRACONIST (0) | 2025.01.16 |
REACT NATIVE 스터디. 1주차 2단원. 리액트 네이티브 환경설정 DRACONIST (0) | 2025.01.16 |