React Native/React Native_study

[리액트 네이티브를 다루는 기술 #11] 6장 다이어리 앱 만들기1 (p.330 ~ 356)

bocoder
728x90
반응형

@ List

* 6.4 글 목록 보여주기

** 6.4.1 FeedListItem 컴포넌트 만들기

** 6.4.2 FeedList 컴포넌트 만들기

** 6.4.3 date-fns로 날짜 포맷팅하기

* 6.5 Animated로 애니메이션 적용하기

** 6.5.1 애니메이션 연습하기

** 6.5.2 스크롤을 내렸을 때 글쓰기 버튼 숨기기

** 6.5.3 spring 사용하기

** 6.5.4 예외 처리하기

* 6.6 정리

 

@ Note

1. 일정 텍스트 길이를 초과할 경우, 정규식 활용 줄임표(...) 만들기

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

 

2. 작성한 시간에 따른 시간 표현 수정

 - 날짜/시간 관련 다양한 기능 제공 : date-fns 라이브러리 

  > reference : https://date-fns.org/docs/Getting-Started

 - diff 는 현재 시간과 파라미터로 받아온 시간의 차이를 초 단위로 계산

 - d.getTime 의 단위는 밀리세컨드라 1000으로 미리 나눠줌

 - formatDistanceToNow 사용 시 '1시간 전', '2일 전' 등 으로 표시

  > addSuffix는 포맷팅된 무자열 뒤에 '전' 또는 '후' 접미사 붙이는 옵션

- format 함수의 PPP는 날짜, EEE는 요일, p는 시간을 나타냄

  > reference : https://date-fns.org/v2.16.1/docs/format

function formatDate(date) {
  {
    const d = new Date(date);
    const now = Date.now();
    const diff = (now - d.getTime()) / 1000;
    
    // 글 작성 후 1분 미만일 때
    if (diff < 60 * 1) {
      return '방금 전';
    }

    // 글 작성 후 3일 미만일 때
    if (diff < 60 * 60 * 24 * 3) {
      return formatDistanceToNow(d, {addSuffix: true, locale: ko});
    }
    
    // 글 작성 후 3일 이상일 때
    return format(d, 'PPP EEE p', {locale: ko});
  }
}

 

3. Animated.timing 함수 사용

 - useNativeDriver는 애니메이션 처리 작업을 네이티브 레벨에서 진행하는 옵션

  > transform, opacity 처럼 레이아웃과 관련 없는 스타일에만 적용 가능

  > left, width, paddingLeft,margineLeft 와 같은 스타일에는 반드시 useNativeDriver를 false로 지정해야 함

    Animated.timing(animation, {
      //필수 항목
      toValue: 0             // 어떤 값으로 변경할지
      useNativeDriver: true, // 네이티브 드라이버 사용 여부
    }).start(() => {
     // 애니메이션 처리 완료 후 실행할 작업
    })

 

4. <Animated.View> 컴포넌트의 사용

 - 컴포넌트를 움직일 때는 꼭 필요한 사항이 아니라면 left, top 대신 transform 사용하는게 성능면에서 좋음

 - interpolate 로 여러 스타일 적용 가능

 

5. <FlatList> 의 스크롤 위치 관련 Props

  - onEndReached, onEndReachdThreshold 는 무한 스크롤링 구현할 때 유용하나 멀어졌을 때를 구분 못함

 - onScroll 이벤트로 콘테츠의 저체 크기, 스크롤의 위치를 확인 가능

  > ScrollView의 Props와 동일함

  > reference : https://reactnative.dev/docs/scrollview#onscroll

  const onScroll = e => {
    if (!onScrolledToBottom) {
      return;
    }
    const {contentSize, layoutMeasurement, contentOffset} = e.nativeEvent;
    const distanceFromBottom =
      contentSize.height - layoutMeasurement.height - contentOffset.y;

    if (
      contentSize.height > layoutMeasurement.height &&
      distanceFromBottom < 72
    ) {
      console.log('바닥과 가까워요.');
      onScrolledToBottom(true);
    } else {
      console.log('바닥과 멀어졌어요.');
      onScrolledToBottom(false);
    }
  };

 

5. Animated.spring 함수 사용

 - toValue로 지정한 값으로 서서히 변하는 것이 아니라, 스프링처럼 통통 튀는 효과 적용

  > reference : https://reactnative.dev/docs/animated#spring

    Animated.spring(animation, {
      toValue: hidden ? 1 : 0,
      useNativeDriver: true,
      tension: 45,
      friction: 5,
    }).start();

 

@ Result

iOS / android - 줄임표(...) 테스트
0
iOS / android - 날짜 표현 변경 테스트

 

iOS / android - 토글에 따른 애니메이션 테스트

 

iOS / android - 스크롤 위치에 따른 Animated.timing 적용 애니메이션

 

iOS / android - 스크롤 위치에 따른 Animated.spring 적용 애니메이션

 

@ Git

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

 

# Source tree

 

# index.js

/**
 * @format
 */

import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';

// uuid 사용을 위한 라이브러리
import 'react-native-get-random-values';

AppRegistry.registerComponent(appName, () => App);

 

# App.js

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

function App() {
  return (
    <NavigationContainer>
      {/* <LogContext.Provider value="안녕하세요"> */}
      <LogContextProvider>
        <RootStack />
      </LogContextProvider>
      {/* </LogContext.Provider> */}
    </NavigationContainer>
  );
}

export default App;

 

# screens > CalendarScreen.js

import React, {useRef, useState, useEffect} from 'react';
import {Animated, Button, StyleSheet, View} from 'react-native';

function FadeInAndOut() {
  const animation = useRef(new Animated.Value(1)).current;
  const [hidden, setHidden] = useState(false);

  useEffect(() => {
    Animated.timing(animation, {
      toValue: hidden ? 0 : 1,
      useNativeDriver: true,
    }).start();
  }, [hidden, animation]);

  return (
    <View>
      <Animated.View
        style={[
          styles.rectangle,
          {
            opacity: animation,
          },
        ]}
      />
      <Button
        title="Toggle"
        onPress={() => {
          setHidden(!hidden);
        }}
      />
    </View>
  );
}

function SlideLeftAndRight() {
  const animation = useRef(new Animated.Value(1)).current;
  const [enabled, setEnabled] = useState(false);

  useEffect(() => {
    Animated.timing(animation, {
      toValue: enabled ? 150 : 1,
      useNativeDriver: true,
    }).start();
  }, [enabled, animation]);

  return (
    <View>
      <Animated.View
        style={[
          styles.rectangle,
          {
            transform: [{translateX: animation}],
          },
        ]}
      />
      <Button
        title="Toggle"
        onPress={() => {
          setEnabled(!enabled);
        }}
      />
    </View>
  );
}

function SlideLeftAndRight_Interpolate() {
  const animation = useRef(new Animated.Value(1)).current;
  const [enabled, setEnabled] = useState(false);

  useEffect(() => {
    Animated.timing(animation, {
      toValue: enabled ? 1 : 0,
      useNativeDriver: true,
    }).start();
  }, [enabled, animation]);

  return (
    <View>
      <Animated.View
        style={[
          styles.rectangle,
          {
            transform: [
              {
                translateX: animation.interpolate({
                  inputRange: [0, 1],
                  outputRange: [0, 150],
                }),
              },
            ],

            opacity: animation.interpolate({
              inputRange: [0, 1],
              outputRange: [1, 0],
            }),
          },
        ]}
      />
      <Button
        title="Toggle"
        onPress={() => {
          setEnabled(!enabled);
        }}
      />
    </View>
  );
}

function CalendarScreen() {
  return (
    <View style={styles.block}>
      <FadeInAndOut />
      <SlideLeftAndRight />
      <SlideLeftAndRight_Interpolate />
    </View>
  );
}

const styles = StyleSheet.create({
  block: {},
  rectangle: {width: 100, height: 100, backgroundColor: 'black'},
});

export default CalendarScreen;

 

# screens > FeedsScreen.js

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

function FeedsScreen() {
  const {logs} = useContext(LogContext);
  // console.log(JSON.stringify(logs, null, 2));

  const [hidden, setHidden] = useState(false);
  // console.log(hidden);

  const onScrolledToBottom = isBottom => {
    if (hidden !== isBottom) {
      setHidden(isBottom);
    }
  };

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

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

export default FeedsScreen;

 

# 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';

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={{
          tabBarIcon: ({color, size}) => (
            <Icon name="search" size={size} color={color} />
          ),
        }}
      />
    </Tab.Navigator>
  );
}

export default MainTab;

 

# screens > RootStack.js

import React from 'react';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
import MainTab from './MainTab';
import WriteScreen from './WriteScreen';

const Stack = createNativeStackNavigator();
function RootStack() {
  return (
    <Stack.Navigator>
      <Stack.Screen
        name="MainTab"
        component={MainTab}
        options={{headerShown: false}}
      />
      <Stack.Screen
        name="Write"
        component={WriteScreen}
        options={{headerShown: false}}
      />
    </Stack.Navigator>
  );
}

export default RootStack;

 

# screens > SearchScreen.js

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

function SearchScreen() {
  return <View style={styles.block} />;
}

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

export default SearchScreen;

 

# screens > WriteScreen.js

import {useNavigation} from '@react-navigation/native';
import React, {useContext, useState} from 'react';
import {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() {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  const navigation = useNavigation();

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

  return (
    <SafeAreaView style={styles.block}>
      <KeyboardAvoidingView
        style={styles.avoidingView}
        behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
        <WriteHeader onSave={onSave} />
        <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;

 

# components > FeedList.js

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

function FeedList({logs, onScrolledToBottom}) {
  const onScroll = e => {
    if (!onScrolledToBottom) {
      return;
    }

    const {contentSize, layoutMeasurement, contentOffset} = e.nativeEvent;
    // console.log({contentSize, layoutMeasurement, contentOffset});
    const distanceFromBottom =
      contentSize.height - layoutMeasurement.height - contentOffset.y;

    if (
      contentSize.height > layoutMeasurement.height &&
      distanceFromBottom < 72
    ) {
      console.log('바닥과 가까워요.');
      onScrolledToBottom(true);
    } else {
      console.log('바닥과 멀어졌어요.');
      onScrolledToBottom(false);
    }
  };

  return (
    <FlatList
      data={logs}
      style={styles.block}
      renderItem={({item}) => <FeedListItem log={item} />}
      keyExtractor={log => log.id}
      ItemSeparatorComponent={() => <View style={styles.separator} />}
      onScroll={onScroll}
    />
  );
}

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

export default FeedList;

 

# 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';

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; // 사용하기 편하게 객체 구조 분해 할당

  return (
    <Pressable
      style={({pressed}) => [
        styles.block,
        Platform.OS === 'ios' && pressed && {backgroundColor: '#efefef'},
      ]}
      android_ripple={{color: '#ededed'}}>
      {/* <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 > FloatingWriteButton.js

import {useNavigation} from '@react-navigation/native';
import React, {useEffect, useRef} from 'react';
import {Animated, Platform, Pressable, StyleSheet, View} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';

function FloatingWriteButton({hidden}) {
  const navigation = useNavigation();
  const onPress = () => {
    navigation.navigate('Write');
  };

  const animation = useRef(new Animated.Value(0)).current;

  // useEffect(() => {
  //   Animated.timing(animation, {
  //     toValue: hidden ? 1 : 0,
  //     useNativeDriver: true,
  //   }).start();
  // }, [animation, hidden]);

  useEffect(() => {
    Animated.spring(animation, {
      toValue: hidden ? 1 : 0,
      useNativeDriver: true,
      tension: 45,
      friction: 5,
    }).start();
  }, [animation, hidden]);

  return (
    <Animated.View
      style={[
        styles.wrapper,
        {
          transform: [
            {
              translateY: animation.interpolate({
                inputRange: [0, 1],
                outputRange: [0, 88],
              }),
            },
          ],
          opacity: animation.interpolate({
            inputRange: [0, 1],
            outputRange: [1, 0],
          }),
        },
      ]}>
      <Pressable
        style={({pressed}) => [
          styles.button,
          Platform.OS === 'ios' && {
            opacity: pressed ? 0.6 : 1,
          },
        ]}
        android_ripple={{color: 'white'}}
        onPress={onPress}>
        <Icon name="add" size={24} style={styles.icon} />
      </Pressable>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  wrapper: {
    position: 'absolute',
    bottom: 16,
    right: 16,
    width: 56,
    borderRadius: 28,
    // iOS 전용 그림자 설정
    shadowColor: '#4d4d4d',
    shadowOffset: {width: 0, height: 4},
    shadowOpacity: 0.3,
    shadowRadius: 4,
    // 안드로이드 전용 그림자 설정
    elevation: 5,
    // 안드로이드에서 물결 효과가 영역 밖으로 나가지 않도록 설정
    // iOS에서는 overflow가 hidden일 경우 그림자가 보여지지 않음
    overflow: Platform.select({android: 'hidden'}),
  },
  button: {
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: '#009688',
    justifyContent: 'center',
    alignItems: 'center',
  },
  icon: {
    color: 'white',
  },
});

export default FloatingWriteButton;

 

# components > TransparentCircleButton.js

import React from 'react';
import {Platform, Pressable, StyleSheet, View} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';

function TransparentCircleButton({name, color, hasMarginRight, onPress}) {
  return (
    <View
      style={[styles.iconButtonWrapper, hasMarginRight && styles.rightMargin]}>
      <Pressable
        style={({pressed}) => [
          styles.iconButton,
          Platform.OS === 'ios' && pressed && {backgroundColor: '#efefef'},
        ]}
        onPress={onPress}
        android_ripple={{color: '#ededed'}}>
        <Icon name={name} size={24} color={color} />
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  iconButtonWrapper: {
    width: 32,
    height: 32,
    borderRadius: 16,
    overflow: 'hidden',
  },
  iconButton: {
    alignItems: 'center',
    justifyContent: 'center',
    width: 32,
    height: 32,
    borderRadius: 16,
  },
  rightMargin: {
    marginRight: 8,
  },
});

export default TransparentCircleButton;

 

# components > WriteEditor.js

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

function WriteEditor({title, body, onChangeTitle, onChangeBody}) {
  const bodyRef = useRef();

  return (
    <View style={styles.block}>
      <TextInput
        placeholder="제목을 입력하세요"
        style={styles.titleInput}
        returnKeyType="next"
        onChangeText={onChangeTitle}
        value={title}
        onSubmitEditing={() => {
          bodyRef.current.focus();
        }}
      />
      <TextInput
        placeholder="당신의 오늘을 기록해보세요"
        style={styles.bodyInput}
        multiline
        textAlignVertical="top"
        onChangeText={onChangeBody}
        returnKeyType="next"
        value={body}
        ref={bodyRef}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  block: {flex: 1, padding: 16},
  titleInput: {
    paddingVertical: 0,
    fontSize: 18,
    marginBottom: 16,
    color: '#263238',
    fontWeight: 'bold',
  },
  bodyInput: {
    flex: 1,
    fontSize: 16,
    paddingVertical: 0,
    color: '#263238',
  },
});

export default WriteEditor;

 

# 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}) {
  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}>
        <TransparentCircleButton
          name="delete-forever"
          color="#ef5350"
          hasMarginRight
        />
        <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}) {
  // const [text, setText] = useState('');
  // return (
  //   <LogContext.Provider value={{text, setText}}>
  //     {children}
  //   </LogContext.Provider>
  // );

  // 테스트 데이터
  // const [logs, setLogs] = useState([
  //   {
  //     id: uuidv4(),
  //     title: 'Log 03',
  //     body: 'Log 03',
  //     date: new Date().toISOString(),
  //   },
  //   {
  //     id: uuidv4(),
  //     title: 'Log 02',
  //     body: 'Log 02',
  //     date: new Date(Date.now() - 1000 * 60 * 3).toISOString(),
  //   },
  //   {
  //     id: uuidv4(),
  //     title: 'Log 01',
  //     body: 'Log 01',
  //     date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
  //   },
  // ]);

  // 테스트 데이터 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]);
  };

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

export default LogContext;

 

 

728x90
반응형