React Native/React Native_study

[리액트 네이티브를 다루는 기술 #12] 7장 다이어리 앱 만들기2 (p .357 ~ 379)

bocoder
728x90
반응형

@ List

* 7.1 작성한 글을 WriteScreen으로 열기

** 7.1.1 FeedListItem 수정하기

** 7.1.2 WriteScreen에서 log 파라미터 인식하기

* 7.2 수정 기능 구현하기

** 7.2.1 LogContext에 onModify 함수 구현하기

** 7.2.2 WriteScreen에서 onModify 함수 사용하기

* 7.3 삭제 기능 구현하기

** 7.3.1 LogContext에 onRemove 함수 구현하기

** 7.3.2 WriteScreen에서 onRemove 함수 사용하기

** 7.3.3 WriteHeader 수정하기

* 7.4 검색 기능 구현하기

** 7.4.1 SearchHeader 컴포넌트 만들기

** 7.4.2 화면 크기 조회학

** 7.4.3 SearchHeader 컴포넌트 UI 구성하기

** 7.4.4 SearchContext 만들기

** 7.4.5 검색어 필터링 후 FeedList 재사용하기

** 7.4.6 EmptySearchResult 만들기

 

 

@ Note

1. 문법 

 - [ ?. ]옵셔널체이닝(optional chaining) 문법  : null 이거나 undefined 일 수 있는 객체의 프로퍼티를 에러 없이 접근 가능함

 - [ ?? ] nullish 병합 연산자 : 유효한 값이라면 해당값을 사용하고, 그렇지 않으면 뒤에 지정한 값 사용

 - [ !! ] NOT 연산자 두 번 사용 : 해당 값이 유효한 객체라면 값이 true가 되고, 값이 null 이나 undefined 라면 false 가 됨

// 옵셔널체이닝 + null 병합 연산자 사용
const log = route.params?.log;
const [title, setTitle] = useState(log?.title ?? '');

// 옵셔널체이닝 + null 병합 연산자 미사용
const log = route.params ? route.params.log : undefined;
const [title, setTitle] = useState(log ? log.title : '' );

// log가 유요한 값이면 true, 그렇지 않으면 false 값 전달
<WriteHeader isEditing={!!log} />

 

2. 화면 크기 조회 2가지 방법

 - Dimensions.get() 사용

  > Dimensions.get('window') : 현재 앱에서 사용할 수 있는 영역의 크기를 가져옴

  > Dimensions.get('screen')  : 전체 화면의 크기를 가져옴 (ios는 동일, android는 상단 상태 바 및 하단 메뉴 바 영역을 제외한 크기)

  > 컴포넌트 외부에서도 작동하므로 StyleSheet에서 사용 가능하나, 화면의 방향 변경 및 폴더블 디바이스 사용 시 크기 변경될 수 있음

 - useWindowDimensions() Hook 사용

  > 화면 크기가 변경되는 상황에 직접 대비할  필요 없음

  > 함수 컴포넌트 내부에서만 사용 가능하며, 전체 화면의 크기를 가져오는 기능은 없음

 

3. <TextInput /> 의 autoFocus Props 사용

 - 컴포넌트가 화면에 나타날 때 자동으로 포커스가 잡힘

 

4. 내장 함수

 - includes() 문자열 내장 함수

  > text.includes() : text에 특정 문자열이 존재하면 true 반환, 그렇지 않으면 false 반환

 - some 배열 내장함수

  > [log.title, log.body].some() : 배열 원소 중 특정 조건이 true인 원소가 하나라도 있으면 true , 모두 만족하지 않을 시 false 반환

 

@ Result

iOS/android - FeedListItem 수정

 

0123
iOS/android - FeedListItem 수정/삭제/추가 구현

 

01234
iOS/android - Search 기능 구현

 

01
iOS / android - EmptySearchResult 화면 구현

 

@ Git

 - https://github.com/eunbok-bocoder/DayLog/commits/main

 

# Source tree

# App.js

import React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import RootStack from './screens/RootStack';
import {LogContextProvider} from './contexts/LogContext';
import {SearchContextProvider} from './contexts/SearchContext';

function App() {
  return (
    <NavigationContainer>
      <SearchContextProvider>
        <LogContextProvider>
          <RootStack />
        </LogContextProvider>
      </SearchContextProvider>
    </NavigationContainer>
  );
}

export default App;

 

# components > EmptySearchResult.js

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

const messages = {
  NOT_FOUND: '검색 결과가 없습니다.',
  EMPTY_KEYWORD: '검색어를 입력하세요.',
};

function EmptySearchResult({type}) {
  return (
    <View style={styles.block}>
      <Text style={styles.text}>{messages[type]}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    color: '#9e9e9e',
    fontSize: 16,
  },
});

export default EmptySearchResult;

 

# components > FeedListItem.js

import React from 'react';
import {Platform, Pressable, StyleSheet, Text} from 'react-native';
import {format, formatDistanceToNow} from 'date-fns';
import {ko} from 'date-fns/locale';
import {useNavigation} from '@react-navigation/native';

function formatDate(date) {
  {
    const d = new Date(date);
    const now = Date.now();
    const diff = (now - d.getTime()) / 1000;
    // console.log('[log] d : ', d);
    // console.log('[log] now : ', now);
    // console.log('[log] diff : ', diff);

    if (diff < 60 * 1) {
      return '방금 전';
    }
    if (diff < 60 * 60 * 24 * 3) {
      return formatDistanceToNow(d, {addSuffix: true, locale: ko});
    }
    return format(d, 'PPP EEE p', {locale: ko});
  }
}

function truncate(text) {
  // 정규식을 사용해 모든 줄 바꿈 문자 제거
  const replaced = text.replace(/\n/g, ' ');
  if (replaced.length <= 100) {
    return replaced;
  }
  return replaced.slice(0, 100).concat('...');
}

function FeedListItem({log}) {
  const {title, body, date} = log; // 사용하기 편하게 객체 구조 분해 할당

  const navigation = useNavigation();

  const onPress = () => {
    navigation.navigate('Write', {
      log,
    });
  };

  return (
    <Pressable
      style={({pressed}) => [
        styles.block,
        Platform.OS === 'ios' && pressed && {backgroundColor: '#efefef'},
      ]}
      android_ripple={{color: '#ededed'}}
      onPress={onPress}>
      {/* <Text style={styles.date}>{new Date(date).toLocaleString()}</Text> */}
      <Text style={styles.date}>{formatDate(date)}</Text>
      <Text style={styles.title}>{title}</Text>
      <Text style={styles.body}>{truncate(body)}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  block: {
    backgroundColor: 'white',
    paddingHorizontal: 16,
    paddingVertical: 24,
  },
  date: {
    fontSize: 12,
    color: '#546e7a',
    marginBottom: 8,
  },
  title: {
    color: '#37474f',
    fontSize: 16,
    lineHeight: 21,
  },
});

export default FeedListItem;

 

# components > SearchHeader.js

import React, {useContext} from 'react';
import {
  Pressable,
  StyleSheet,
  TextInput,
  useWindowDimensions,
  View,
  Text,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import SearchContext from '../contexts/SearchContext';

function SearchHeader() {
  // return <Text style={styles.block}>Hello</Text>;
  const {width} = useWindowDimensions();
  const {keyword, onChangeText} = useContext(SearchContext);

  return (
    <View style={[styles.block, {width: width - 32}]}>
      <TextInput
        style={styles.input}
        placeholder="검색어를 입력하세요"
        value={keyword}
        onChangeText={onChangeText}
        autoFocus
      />
      <Pressable
        style={({pressed}) => [styles.button, pressed && {opacity: 0.5}]}
        onPress={() => onChangeText('')}>
        <Icon name="cancel" size={20} color="#9e9e9e" />
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  input: {
    flex: 1,
  },
  button: {
    marginLeft: 8,
  },
});

export default SearchHeader;

 

# components > WriteHeader.js

import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import TransparentCircleButton from './TransparentCircleButton';

function WriteHeader({onSave, onAskRemove, isEditing}) {
  const navigation = useNavigation();
  const onGoBack = () => {
    navigation.pop();
  };
  return (
    <View style={styles.block}>
      <View style={styles.iconButtonWrapper}>
        <TransparentCircleButton
          onPress={onGoBack}
          name="arrow-back"
          color="#424242"
        />
      </View>
      <View style={styles.buttons}>
        {isEditing && (
          <TransparentCircleButton
            name="delete-forever"
            color="#ef5350"
            hasMarginRight
            onPress={onAskRemove}
          />
        )}
        <TransparentCircleButton
          name="check"
          color="#009688"
          onPress={onSave}
        />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    height: 48,
    paddingHorizontal: 8,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
  },
  iconButtonWrapper: {
    width: 32,
    height: 32,
    borderRadius: 16,
    overflow: 'hidden',
  },
  iconButton: {
    alignItems: 'center',
    justifyContent: 'center',
    width: 32,
    height: 32,
    borderRadius: 16,
  },
  buttons: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  marginRight: {
    marginRight: 8,
  },
});

export default WriteHeader;

 

# contexts > LogContext.js

import React from 'react';
import {createContext, useState} from 'react';
import {v4 as uuidv4} from 'uuid';

// const LogContext = createContext('안녕하세요.');

const LogContext = createContext();

export function LogContextProvider({children}) {
  // 테스트 데이터 10개 생성
  const [logs, setLogs] = useState(
    Array.from({length: 10})
      .map((_, index) => ({
        id: uuidv4(),
        title: `Log ${index}`,
        body: `Log ${index}`,
        date: new Date().toISOString(),
      }))
      .reverse(),
  );

  // console.log('logs :', logs);
  const onCreate = ({title, body, date}) => {
    const log = {
      id: uuidv4(),
      title,
      body,
      date,
    };
    setLogs([log, ...logs]);
  };

  const onModify = modified => {
    // logs 배열을 순회해 id가 일치하면 log를 교체하고 그렇지 않으면 유지
    const nextLogs = logs.map(log => (log.id === modified.id ? modified : log));
    setLogs(nextLogs);
  };

  const onRemove = id => {
    const nextLogs = logs.filter(log => log.id !== id);
    setLogs(nextLogs);
  };

  return (
    <LogContext.Provider value={{logs, onCreate, onModify, onRemove}}>
      {children}
    </LogContext.Provider>
  );
}

export default LogContext;

 

# contexts > SearchContextjs

import React, {createContext, useState} from 'react';

const SearchContext = createContext();

export function SearchContextProvider({children}) {
  const [keyword, onChangeText] = useState('');
  return (
    <SearchContext.Provider value={{keyword, onChangeText}}>
      {children}
    </SearchContext.Provider>
  );
}

export default SearchContext;

 

# screens > MainTab.js

import React from 'react';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import FeedsScreen from './FeedsScreen';
import CalendarScreen from './CalendarScreen';
import SearchScreen from './SearchScreen';
import Icon from 'react-native-vector-icons/MaterialIcons';
import SearchHeader from '../components/SearchHeader';

const Tab = createBottomTabNavigator();

function MainTab() {
  return (
    <Tab.Navigator
      screenOptions={{
        tabBarShowLabel: false,
        activeTintColor: '#009688',
      }}>
      <Tab.Screen
        name="Feeds"
        component={FeedsScreen}
        options={{
          tabBarIcon: ({color, size}) => (
            <Icon name="view-stream" size={size} color={color} />
          ),
        }}
      />
      <Tab.Screen
        name="Calendar"
        component={CalendarScreen}
        options={{
          tabBarIcon: ({color, size}) => (
            <Icon name="event" size={size} color={color} />
          ),
        }}
      />
      <Tab.Screen
        name="Search"
        component={SearchScreen}
        options={{
          title: '검색',
          tabBarIcon: ({color, size}) => (
            <Icon name="search" size={size} color={color} />
          ),
          headerTitle: () => <SearchHeader />,
        }}
      />
    </Tab.Navigator>
  );
}

export default MainTab;

 

# screens > SearchScreen.js

import React, {useContext} from 'react';
import {StyleSheet, View} from 'react-native';
import EmptySearchResult from '../components/EmptySearchResult';
import FeedList from '../components/FeedList';
import LogContext from '../contexts/LogContext';
import SearchContext from '../contexts/SearchContext';

function SearchScreen() {
  const {keyword} = useContext(SearchContext);
  const {logs} = useContext(LogContext);

  const filtered =
    keyword === ''
      ? []
      : logs.filter(log =>
          [log.title, log.body].some(text => text.includes(keyword)),
        );

  if (keyword === '') {
    return <EmptySearchResult type="EMPTY_KEYWORD" />;
  }

  if (filtered.length === 0) {
    return <EmptySearchResult type="NOT_FOUND" />;
  }

  return (
    <View style={styles.block}>
      <FeedList logs={filtered} />
    </View>
  );
}

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

export default SearchScreen;

 

# screens > WriteScreen.js

import {useNavigation} from '@react-navigation/native';
import React, {useContext, useState} from 'react';
import {Alert, StyleSheet, KeyboardAvoidingView, Platform} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import WriteEditor from '../components/WriteEditor';
import WriteHeader from '../components/WriteHeader';
import LogContext from '../contexts/LogContext';

function WriteScreen({route}) {
  // 옵셔널 체이닝 문법
  const log = route.params?.log; // --> route.params ? route.params.log : undefined

  const [title, setTitle] = useState(log?.title ?? ''); // --> useState(log ? log.title : '')
  const [body, setBody] = useState(log?.body ?? '');
  const navigation = useNavigation();

  const {onCreate, onModify, onRemove} = useContext(LogContext);
  const onSave = () => {
    if (log) {
      onModify({
        id: log.id,
        date: log.date,
        title,
        body,
      });
    } else {
      onCreate({
        title,
        body,
        // 날짜를 문자열로 변환
        date: new Date().toISOString(),
      });
    }
    navigation.pop();
  };

  const onAskRemove = () => {
    Alert.alert(
      '삭제',
      '정말로 삭제하시겠어요?',
      [
        {text: '취소', style: 'cancel'},
        {
          text: '삭제',
          onPress: () => {
            onRemove(log?.id);
            navigation.pop();
          },
          style: 'destructive',
        },
      ],
      {
        cancelable: true,
      },
    );
  };

  return (
    <SafeAreaView style={styles.block}>
      <KeyboardAvoidingView
        style={styles.avoidingView}
        behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
        <WriteHeader
          onSave={onSave}
          onAskRemove={onAskRemove}
          isEditing={!!log}
        />
        <WriteEditor
          title={title}
          body={body}
          onChangeTitle={setTitle}
          onChangeBody={setBody}
        />
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
}

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

export default WriteScreen;

 

728x90
반응형