React Native/React Native_study

[리액트 네이티브를 다루는 기술 #6] 4장 할일 목록 만들기2 (p.179 ~ 214)

bocoder
728x90
반응형

@ List

* 4.3 새 항목 등록하기

* 4.4 할일 완료 상태 토글하기

* 4.5 항목 삭제하기

** 4.5.1 벡터 아이콘 사용하기

** 4.5.2 항목 삭제 함수 만들기

** 4.5.3 항목을 삭제하기 전에 한번 물어보기

* 4.6 AsyncStorage로 앱이 꺼져도 데이터 유지하기

** 4.6.1 Promise가 무엇인가요?

** 4.6.2 AsyncStorage 설치하기

** 4.6.3 AsyncSotrage의 기본 사용법

** AsyncStorage 적용하기

* 4.7 정리

 

@ Note

1. 최대값 반환 내장함수 : Math.max() 

 - todos.length > 0 ? Math.max(...todos.map(todo => todo.id)) + 1 : 1;

 

2. react-native-vector-icons 적용 시 네이티브 소스 수정 필요

 - ios/TodoApp/Info.plist 수정 후, pod install 설치 후, 재실행

 - android/app/build.gradle 수정 후, 재실행

 

3.  removePlaceholder 적용의 중요성

 - 아이콘이 보이지 않을 때도 삭제 아이콘이 보일 영역을 미리 차지해 두기 위함

 - 해당 작업을 하지 않으면 항목의 내용이 긴 경우 토글할 때마다 텍스트가 보이는 영역이 변경됨 

 

4. Promise 객체의 중요성

 - setTimeout( , ) 에서 두번째 인자에 0을 넣으면 자바스크립스 런타임 환경에 따라 4ms~10ms 이후 함수가 실행됨 (Node.js는 1ms 이후 실행)

 - new Promise ((resove, reject) => { }) 과 .then( ) 의 관계

 - async 와 await 의 관계

 - 오류에 대한 예외 처리 시, try/catch 구문 사용

 - console.error()는 콘솔에 결과를 출력 시 빨간색으로 강조해 출력함

 

5. useEffect(a,[b])

 - 컴포넌트 마운트 또는 언마운트 시점에 따른 구현 방법 확인

 - 여러개 사용 시 등록된 순서대로 작동함

 - 컴포넌트가 가장처음 렌더링됐을 때, b가 변경될 때 호출됨

  > a : 주시하고 싶은 값이 바뀌었을 때 호출하고 싶은 함수

  > b : 주시하고 싶은 값을 배열안에 넣음

 

6. AsyncStorage

 - 값 저장 시 문자열 타입이어야 하고, 객체 및 배열 타입 저장 시 JSON.stringify() 사용 (불러올때 JSON.parse() 사용)

 - Android는 기본적으로 최대용량이 6MB로 설정 되어있고, 용량을 늘리려면 android/gradle.properties 파일에 코드 추가

 - iOS는 최대용량이 지정되어 있지 않음

 - AsyncStorage는 소규모 데이터를 다룰 때 사용하는 것이 좋으며, 규모가 커졌을 경우 react-native-sqlite-storage 가 있음

 - API : AsyncStorage.setItem(), AsyncStorage.getItem(), AsyncStorage.clear() 등

 > referece : https://react-native-async-storage.github.io/async-storage/docs/api

 

@ Result

01
ios / android - 새 항목 등록 및 상태 토글

 

012
ios / android - 벡터 아이콘 사용 및 removePlaceholder 설정

 

012
ios / android - 삭제 전 Alert 띄우기

 

ios / android - reload 후 데이터 보존 확인

 

@ Git

https://github.com/eunbok-bocoder/TodoApp

 

# Source tree

 

# App.js

import React, {useState, useEffect, useReducer} from 'react';
import {StyleSheet, KeyboardAvoidingView, Platform} from 'react-native';
// for IOS StatusBar
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';

// import AsyncStorage from '@react-native-community/async-storage';

import DateHead from './components/DateHead';
import AddTodo from './components/AddTodo';
import Empty from './components/Empty';
import TodoList from './components/TodoList';
import todosStorage from './storages/todosStorage';

function App() {
  const today = new Date();

  const [todos, setTodos] = useState([
    {id: 1, text: '작업환경 설정', done: true},
    {id: 2, text: '리액트 네이티브 기초 공부', done: false},
    {id: 3, text: '투두리스트 만들어보기', done: false},
  ]);

  // // 데이터 불러오기 (데이터 저장보다 위에 있어야 함)
  // useEffect(() => {
  //   async function load() {
  //     try {
  //       const rawTodos = await AsyncStorage.getItem('todos');
  //       const savedTodos = JSON.parse(rawTodos);
  //       setTodos(savedTodos);
  //     } catch (e) {
  //       console.log('Failed to load todos');
  //     }
  //   }
  //   load();
  // }, []); // 배열이 비어있으면 마운트될 때 딱 한 번만 함수가 호출됨

  // // 데이터 저장
  // useEffect(() => {
  //   // console.log(todos);
  //   async function save() {
  //     try {
  //       await AsyncStorage.setItem('todos', JSON.stringify(todos));
  //     } catch (e) {
  //       console.log('Failed to load todos');
  //     }
  //   }
  //   save();
  // }, [todos]);

  //storage > todosStorage.js 사용
  useEffect(() => {
    todosStorage.get().then(setTodos).catch(console.error);
  }, []);

  useEffect(() => {
    todosStorage.set(todos).catch(console.error);
  }, [todos]);

  const onInsert = text => {
    // 새로 등록할 항목의 id를 구함
    // 등록된 항목 중에서 가장 큰 id를 구하고, 그 값에 1을 더함
    // 만약 리스트가 비어있다면 1을 id로 사용함

    const nextId =
      todos.length > 0 ? Math.max(...todos.map(todo => todo.id)) + 1 : 1;

    const todo = {
      id: nextId,
      text,
      done: false,
    };

    setTodos(todos.concat(todo));

    console.log('Math.max() : ' + Math.max(...todos.map(todo => todo.id)));
    console.log('nextId : ' + nextId);
  };

  const onToggle = id => {
    const nextTodos = todos.map(todo =>
      todo.id === id ? {...todo, done: !todo.done} : todo,
    );
    setTodos(nextTodos);
  };

  const onRemove = id => {
    const nextTodos = todos.filter(todo => todo.id !== id);
    setTodos(nextTodos);
  };

  return (
    <SafeAreaProvider>
      <SafeAreaView edges={['bottom']} style={styles.block}>
        <KeyboardAvoidingView
          // behavior={Platform.OS === 'ios' ? 'padding' : undefined}  // 삼항연산자 이용 시
          behavior={Platform.select({ios: 'padding', android: undefined})}
          style={styles.avoid}>
          <DateHead date={today} />
          {todos.length === 0 ? (
            <Empty />
          ) : (
            <TodoList todos={todos} onToggle={onToggle} onRemove={onRemove} />
          )}
          <AddTodo onInsert={onInsert} />
        </KeyboardAvoidingView>
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
    backgroundColor: 'white',
  },
  avoid: {
    flex: 1,
  },
});

export default App;

 

# components > AddTodo.js

import React, {useState} from 'react';
import {
  View,
  StyleSheet,
  TextInput,
  Image,
  Platform,
  TouchableOpacity,
  TouchableNativeFeedback,
  Keyboard,
} from 'react-native';

function AddTodo({onInsert}) {
  const [text, setText] = useState('');

  const onPress = () => {
    onInsert(text);
    setText('');
    Keyboard.dismiss(); // button 눌렀을 시 키보드 사라짐
  };

  const button = (
    <View style={styles.buttonStyle}>
      <Image source={require('../assets/icons/add_white/add_white.png')} />
    </View>
  );

  return (
    <View style={styles.block}>
      <TextInput
        placeholder="할일을 입력하세요."
        style={styles.input}
        value={text}
        onChangeText={setText}
        onSubmitEditing={onPress} // Enter 눌렀을 시 키보드 사라짐
        returnKeyType="done" // Enter 타입 지정
      />
      {Platform.select({
        ios: <TouchableOpacity onPress={onPress}>{button}</TouchableOpacity>,
        android: (
          <View style={styles.circleWrapper}>
            <TouchableNativeFeedback onPress={onPress}>
              {button}
            </TouchableNativeFeedback>
          </View>
        ),
      })}
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    backgroundcolor: 'white',
    height: 64,
    paddingHorizontal: 16, // 좌우 여백
    bordercolor: '#bdbdbd',
    borderTopWidth: 1,
    borderBottomWidth: 1,
    alignItems: 'center', //상하 정렬
    flexDirection: 'row',
  },
  input: {
    flex: 1, // TextInput 란 확장
    fontSize: 16,
    paddingVertical: 8, // 상하 터치영역 확장
  },
  buttonStyle: {
    alignItems: 'center',
    justifyContent: 'center',
    width: 48,
    height: 48,
    backgroundColor: '#26a69a',
    borderRadius: 24,
  },
  circleWrapper: {
    overflow: 'hidden', // 지정한 영역 외 바깥 영역 숨김
    borderRadius: 24,
  },
});

export default AddTodo;

 

# components > DataHead.js

import React from 'react';
import {View, Text, StyleSheet, StatusBar} from 'react-native';

// for IOS StatusBar
import {useSafeAreaInsets} from 'react-native-safe-area-context';

function DateHead() {
  const d = new Date();
  const year = d.getFullYear();
  const month = d.getMonth() + 1; // getMonth 범위 : 0 ~ 11 까지
  const day = d.getDate();

  const formatted = `${year}년 ${month}월 ${day}일`;

  const {top} = useSafeAreaInsets();

  return (
    <>
      <View style={[styles.statusBarPlaceholder, {height: top}]} />
      <StatusBar backgroundColor="#26a69a" barStyle="light-content" />
      <View style={styles.block}>
        <Text style={styles.dateText}>
          {year}년 {month}월 {day}일
        </Text>
      </View>
    </>
  );
}

const styles = StyleSheet.create({
  statusBarPlaceholder: {
    backgroundColor: '#26a69a',
  },
  block: {
    padding: 16,
    backgroundColor: '#26a69a',
  },
  dateText: {
    fontSize: 24,
    color: 'white',
  },
});

export default DateHead;

 

# Empty.js

import React from 'react';
import {View, Text, Image, StyleSheet} from 'react-native';

function Empty() {
  return (
    <View style={styles.block}>
      <Image
        source={require('../assets/images/young_and_happy.png')}
        style={styles.image}
        resizeMode="cover"
      />

      <Text style={styles.description}>할일이 없습니다.</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  image: {
    width: 240,
    height: 179,
    marginBottom: 16,
  },
  description: {
    fontSize: 24,
    color: '#9e9e9e',
  },
});

export default Empty;

 

# components > TodoItem.js

import React, {useEffect} from 'react';
import {
  View,
  Text,
  StyleSheet,
  Image,
  Touchable,
  TouchableOpacity,
  Platform,
  Alert,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';

function TodoItem({id, text, done, onToggle, onRemove}) {
  // useEffect(() => {
  //   console.log('컴포넌트가 마운트될 때 출력됨');
  //   return () => {
  //     console.log('컴포넌트가 언마운트될 때 출력됨');
  //   };
  // }, []);

  const remove = () => {
    // 제목, 내용
    Alert.alert('삭제', '정말로 삭제하시겠어요?', [
      // 왼쪽 버튼
      {text: '취소', onPress: () => {}, style: 'cancel'},
      // 오른쪽 버튼
      {
        text: '삭제',
        onPress: () => {
          onRemove(id);
        },
        style: 'destuctive',
      },
    ]);
  };

  return (
    <View style={styles.item}>
      <TouchableOpacity
        onPress={() => {
          onToggle(id);
          // console.log('id : ' + Platform.OS + ' / ' + id);
        }}>
        <View style={[styles.circle, done && styles.filled]}>
          {done && (
            <Image
              source={require('../assets/icons/check_white/check_white.png')}
            />
          )}
        </View>
      </TouchableOpacity>
      <Text style={[styles.text, done && styles.lineThrough]}>{text}</Text>
      {done ? (
        // <TouchableOpacity
        //   onPress={() => {
        //     onRemove(id);
        //   }}>
        //   <Icon name="delete" size={32} color="red" />
        // </TouchableOpacity>

        <TouchableOpacity onPress={remove}>
          <Icon name="delete" size={32} color="red" />
        </TouchableOpacity>
      ) : (
        <View style={styles.removePlaceholder} />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  item: {
    flexDirection: 'row',
    padding: 16,
    alignItems: 'center',
  },
  circle: {
    width: 24,
    height: 24,
    borderRadius: 12,
    borderWidth: 1,
    borderColor: '#26a69a',
    marginRight: 16,
  },
  filled: {
    alignItems: 'center',
    backgroundColor: '#26a69a',
  },
  text: {
    flex: 1,
    fontSize: 16,
    color: '#212121',
  },
  lineThrough: {
    color: '#9e9e9e',
    textDecorationLine: 'line-through',
  },

  // 내용이 긴 경우 텍스트 영역이 달라지는 것을 막기 위함
  removePlaceholder: {
    width: 32,
    height: 32,
  },
});

export default TodoItem;

 

# components > TodoList.js

import React from 'react';
import {FlatList, View, Text, StyleSheet} from 'react-native';
import TodoItem from './TodoItem';

function TodoList({todos, onToggle, onRemove}) {
  return (
    <FlatList
      ItemSeparatorComponent={() => <View style={styles.separator} />}
      style={styles.list}
      data={todos}
      renderItem={({item}) => (
        <TodoItem
          id={item.id}
          text={item.text}
          done={item.done}
          onToggle={onToggle}
          onRemove={onRemove}
        />
      )}
      keyExtractor={item => item.id.toString()}
    />
  );
}

const styles = StyleSheet.create({
  list: {
    flex: 1,
  },
  separator: {
    backgroundColor: '#e0e0e0',
    height: 1,
  },
});

export default TodoList;

 

# storages > todosStorage.js

import AsyncStorage from '@react-native-community/async-storage';
import {get} from 'lodash';

const key = 'todos';

const todosStorage = {
  async get() {
    try {
      const rawTodos = await AsyncStorage.getItem(key);

      if (!rawTodos) {
        //저장된 데이터가 없으면 사용하지 않음
        throw new Error('No saved todos');
      }

      const savedTodos = JSON.parse(rawTodos);
      return savedTodos;
    } catch (e) {
      throw new Error('failed to load todos');
    }
  },

  async set(date) {
    try {
      await AsyncStorage.setItem(key, JSON.stringify(date));
    } catch (e) {
      throw new Error('Failed to save todos');
    }
  },
};

export default todosStorage;

 

# ios > TodoApp > Info.plist

(...)

	<!-- iOS 벡터 아이콘 적용 -->
	<key>UIAppFonts</key>
	<array>
		<string>MaterialIcons.ttf</string>
	</array>
</dict>
</plist>

 

# android > app > build.gradle

(...)

// Android 벡터 아이콘 적용
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")

 

# android > gradle.properties

(...)

#AsyncStorage 최대 용량 변경 (default : 5MB)
AsyncStorage_db_size_in_MB=10

 

728x90
반응형