RN 달력 만들기

JunHo Lee·2023년 12월 3일
0

React Native

목록 보기
4/5
post-thumbnail

목표 : 리액트 네이티브로 달력을 만들어보자.

  • 평일 날짜만 표시한다.
  • 할 일을 표시한다.

예시


간략한 구현 순서

  1. 주간 달력 만들기
    1-1. 특정 연도와 주차를 가지고 날짜를 구한다.

    // 특정 달의 주 번호 목록을 반환하는 함수
    getWeekNumListInMonth = (month) => {
      let weekNumList = [];
      let firstDayOfMonth = new Date(currentYear, month - 1, 1);
      let firstMonday = getFirstDayOfWeek(firstDayOfMonth);
    
      // 첫 번째 월요일의 주 번호
      let date = moment(firstMonday);
      let weekNum = date.week();
      let weekStartDate = new Date(firstMonday);
      let nextMonth = month % 12;
    
      while (weekStartDate.getMonth() !== nextMonth) {
        weekNumList.push(weekNum);
        weekStartDate.setDate(weekStartDate.getDate() + 7);
        weekNum += 1;
      }
      return weekNumList;
    };
    
    // 특정 연도와 주차를 가지고 날짜를 구하는 함수
    const getWeekdayDatesOfYearWeek = (year, weekNum) => {
    const getFirstDayOfFirstISOWeek = (yr) => {
      const simple = new Date(
        yr,
        0,
        1 + ((4 - new Date(yr, 0, 1).getDay() + 7) % 7)
      );
      return simple;
    };
    
    let startDate = getFirstDayOfFirstISOWeek(year);
    let weekStartDate = new Date(startDate);
    weekStartDate.setDate(startDate.getDate() + (weekNum - 1) * 7 - 2);
    
    let weekdayDates = [];
    for (let i = 0; i < 5; i++) {
      let date = new Date(weekStartDate);
      date.setDate(weekStartDate.getDate() + i);
      weekdayDates.push(date.toISOString().split("T")[0]);
    }
    
    return weekdayDates;
    };

    1-2. 구한 날짜를 표시한다. (TouchableOpacity를 이용하여 선택할 수 있도록 만든다.)

    const renderWeekDays = (index, key) => {
    	const weekdays = getWeekdayDatesOfYearWeek(currentYear, index);
    
        return weekdays.slice(0, 5).map((date, i) => {
    
          return (
            <TouchableOpacity
              key={i}
              style={styles.weekDay}
              onPress={() => selectDate(date)}
            >
              <Text>{date.split("-")[2]}</Text>
            </TouchableOpacity>
          );
        });
    };
  2. 할 일을 표시한다.
    2-1. 할 일을 생성한다.

    // 예시 할 일
    [
    	{
           id: 0,
           company: "test1",
           start_date: "2023-11-20",
           end_date: "2023-11-22",
           line: 0,
         },
         {
           id: 1,
           company: "test2",
           start_date: "2023-11-21",
           end_date: "2023-11-23",
           line: 0,
         },
         {
           id: 2,
           company: "test3",
           start_date: "2023-11-23",
           end_date: "2023-11-27",
           line: 0,
         },
       ]

    2-2. 날짜 밑에 할 일을 표시한다.

    // 주별 날짜를 렌더링하는 함수
    const renderWeekDays = (index, key) => {
      const weekdays = getWeekdayDatesOfYearWeek(currentYear, index);
    
      return weekdays.slice(0, 5).map((date, i) => {
        const dayIssues = issues
          .filter((issue) => issue.start_date <= date && issue.end_date >= date)
          .sort((a, b) => a.line - b.line);
    
        const renderIssue = (item) => {
          const commonStyle = { backgroundColor: "red", textAlign: "center" };
          let issueStyle = {};
    
          if (item.start_date === date && item.end_date === date) {
            issueStyle = styles.issueSingle;
          } else if (item.start_date === date) {
            issueStyle = styles.issueStart;
          } else if (item.end_date === date) {
            issueStyle = styles.issueEnd;
          } else {
            issueStyle = styles.issueMid;
          }
          const unique_key = String(index) + String(key) + String(i) + item.id;
    
          const isSingleDay = item.start_date === item.end_date;
          const isMiddleDay = item.start_date !== date && item.end_date !== date;
    
          return (
            <View key={unique_key} style={[issueStyle, commonStyle]}>
              {(isSingleDay || isMiddleDay) && (
                <Text style={{ textAlign: "center" }}>{item.company}</Text>
              )}
            </View>
          );
        };
    
        return (
          <TouchableOpacity
            key={i}
            style={styles.weekDay}
            onPress={() => selectDate(date)}
          >
            <Text>{date.split("-")[2]}</Text>
            <View
              style={{
                flex: 1,
                flexDirection: "col",
                padding: 0,
                width: "100%",
                textAlign: "center",
              }}
            >
              {dayIssues.map(renderIssue)}
            </View>
          </TouchableOpacity>
        );
      });
    };
  3. 주간 달력을 합쳐서 달력을 만들기
    3-1. 한 달에 해당하는 4개의 주를 구하여 합쳐서 출력하면 달력 완성.


  • 전체 코드
import React, { useState, useEffect } from "react";
import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
  ScrollView,
  Animated,
} from "react-native";
import { PanGestureHandler, State } from "react-native-gesture-handler";
import moment from "moment";

const Calendar = () => {
  const DAY = ["월", "화", "수", "목", "금"];

  const today = moment();
  const [currentMonth, setCurrentMonth] = useState(today.month() + 1);
  const [currentYear, setCurrentYear] = useState(today.year());
  const [issues, setIssues] = useState([
    {
      id: 0,
      company: "test1",
      start_date: "2023-11-20",
      end_date: "2023-11-22",
      line: 0,
    },
    {
      id: 1,
      company: "test2",
      start_date: "2023-11-21",
      end_date: "2023-11-23",
      line: 0,
    },
    {
      id: 2,
      company: "test3",
      start_date: "2023-11-23",
      end_date: "2023-11-27",
      line: 0,
    },
  ]);

  const [selectedDate, setSelectedDate] = useState();
  const [selectedIssue, setSelectedIssue] = useState();

  // 스와이프 액션을 한 번만 취할 수 있도록 하는 state
  const [swipeActionTaken, setSwipeActionTaken] = useState(false);
  // 스와이프 애니메이션을 위한 state
  const translateX = new Animated.Value(0);
  // 스와이프 애니메이션을 위한 함수
  const onGestureEvent = Animated.event(
    [{ nativeEvent: { translationX: translateX } }],
    { useNativeDriver: true }
  );
  // 스와이프 액션을 한 번만 취할 수 있도록 하는 state
  const onHandlerStateChange = ({ nativeEvent }) => {
    if (nativeEvent.state === State.END) {
      setSwipeActionTaken(false);
    }
  };
  // 스와이프 액션 기능을 구현한 함수
  translateX.addListener(({ value }) => {
    if (!swipeActionTaken) {
      if (value > 10) {
        // 왼쪽에서 오른쪽으로 스와이프하면 이전 달로 이동
        prevMonth();
        setSwipeActionTaken(true);
      } else if (value < -10) {
        // 오른쪽에서 왼쪽으로 스와이프하면 다음 달로 이동
        nextMonth();
        setSwipeActionTaken(true);
      }
    }
  });

  // 이슈가 겹치는지 확인하는 함수
  const datesOverlap = (issue1, issue2) => {
    return (
      issue1.start_date <= issue2.end_date &&
      issue2.start_date <= issue1.end_date
    );
  };

  useEffect(() => {
    // 이슈가 겹치는 경우, 겹치는 이슈의 줄 번호를 증가시킴
    const updatedIssues = issues.map((issue, index) => {
      issues.forEach((otherIssue, otherIndex) => {
        if (
          index !== otherIndex &&
          datesOverlap(issue, otherIssue) &&
          issue.line === otherIssue.line
        ) {
          // 중복되는 이슈의 줄 번호 증가
          otherIssue.line += 1;
        }
      });
      return issue;
    });

    setIssues(updatedIssues);
  }, []);

  const nextMonth = () => {
    let nextMonth = currentMonth + 1;
    if (nextMonth === 13) nextMonth = 1;
    setCurrentMonth(nextMonth);
    if (currentMonth === 12) setCurrentYear(currentYear + 1);
  };

  const prevMonth = () => {
    let beforeMonth = currentMonth - 1;
    if (beforeMonth === 0) beforeMonth = 12;
    setCurrentMonth(beforeMonth);
    if (currentMonth === 1) setCurrentYear(currentYear - 1);
  };

  const renderDay = () => {
    return DAY.map((day, i) => (
      <View key={i} style={styles.weekDayOfWeek}>
        <Text>{day}</Text>
      </View>
    ));
  };

  // 날짜를 선택했을 때, 선택한 날짜에 해당하는 이슈를 state에 저장
  const selectDate = (date) => {
    setSelectedDate(date);

    // 선택한 날짜에 해당하는 이슈를 찾아서 state에 저장
    let selected_issue = issues.filter(
      (issue) => issue.start_date <= date && issue.end_date >= date
    );
    selected_issue.sort((a, b) => a.start_date - b.start_date);
    console.log("selected_issue :", selected_issue);
    setSelectedIssue(selected_issue);
  };

  // 특정 날짜의 주의 첫 번째 날짜를 구하는 함수
  const getFirstDayOfWeek = (date) => {
    const day = date.getDay();
    const diff = date.getDate() - day + (day === 0 ? -6 : 1);
    return new Date(date.setDate(diff + 1));
  };

  // 특정 달의 주 번호 목록을 반환하는 함수
  getWeekNumListInMonth = (month) => {
    let weekNumList = [];
    let firstDayOfMonth = new Date(currentYear, month - 1, 1);
    let firstMonday = getFirstDayOfWeek(firstDayOfMonth);

    // 첫 번째 월요일의 주 번호
    let date = moment(firstMonday);
    let weekNum = date.week();
    let weekStartDate = new Date(firstMonday);
    let nextMonth = month % 12;

    while (weekStartDate.getMonth() !== nextMonth) {
      weekNumList.push(weekNum);
      weekStartDate.setDate(weekStartDate.getDate() + 7);
      weekNum += 1;
    }
    return weekNumList;
  };

  // 특정 연도와 주차를 가지고 날짜를 구하는 함수
  const getWeekdayDatesOfYearWeek = (year, weekNum) => {
    const getFirstDayOfFirstISOWeek = (yr) => {
      const simple = new Date(
        yr,
        0,
        1 + ((4 - new Date(yr, 0, 1).getDay() + 7) % 7)
      );
      return simple;
    };

    let startDate = getFirstDayOfFirstISOWeek(year);
    let weekStartDate = new Date(startDate);
    weekStartDate.setDate(startDate.getDate() + (weekNum - 1) * 7 - 2);

    let weekdayDates = [];
    for (let i = 0; i < 5; i++) {
      let date = new Date(weekStartDate);
      date.setDate(weekStartDate.getDate() + i);
      weekdayDates.push(date.toISOString().split("T")[0]);
    }

    return weekdayDates;
  };

  // 주별 날짜를 렌더링하는 함수
  const renderWeekDays = (index, key) => {
    const weekdays = getWeekdayDatesOfYearWeek(currentYear, index);

    return weekdays.slice(0, 5).map((date, i) => {
      const dayIssues = issues
        .filter((issue) => issue.start_date <= date && issue.end_date >= date)
        .sort((a, b) => a.line - b.line);

      const renderIssue = (item) => {
        const commonStyle = { backgroundColor: "red", textAlign: "center" };
        let issueStyle = {};

        if (item.start_date === date && item.end_date === date) {
          issueStyle = styles.issueSingle;
        } else if (item.start_date === date) {
          issueStyle = styles.issueStart;
        } else if (item.end_date === date) {
          issueStyle = styles.issueEnd;
        } else {
          issueStyle = styles.issueMid;
        }
        const unique_key = String(index) + String(key) + String(i) + item.id;

        const isSingleDay = item.start_date === item.end_date;
        const isMiddleDay = item.start_date !== date && item.end_date !== date;

        return (
          <View key={unique_key} style={[issueStyle, commonStyle]}>
            {(isSingleDay || isMiddleDay) && (
              <Text style={{ textAlign: "center" }}>{item.company}</Text>
            )}
          </View>
        );
      };

      return (
        <TouchableOpacity
          key={i}
          style={styles.weekDay}
          onPress={() => selectDate(date)}
        >
          <Text>{date.split("-")[2]}</Text>
          <View
            style={{
              flex: 1,
              flexDirection: "col",
              padding: 0,
              width: "100%",
              textAlign: "center",
            }}
          >
            {dayIssues.map(renderIssue)}
          </View>
        </TouchableOpacity>
      );
    });
  };

  return (
    <View style={styles.container}>
      <PanGestureHandler
        onGestureEvent={onGestureEvent}
        onHandlerStateChange={onHandlerStateChange}
      >
        <Animated.View
          style={{ ...styles.container, transform: [{ translateX }] }}
        >
          <ScrollView style={{ ...styles.container, padding: 20 }}>
            <View style={styles.header}>
              <TouchableOpacity onPress={prevMonth} style={styles.navButton}>
                <Text>Previous</Text>
              </TouchableOpacity>
              <Text>
                {currentYear} {currentMonth}
              </Text>
              <TouchableOpacity onPress={nextMonth} style={styles.navButton}>
                <Text>Next</Text>
              </TouchableOpacity>
            </View>
            <View style={{ flexDirection: "row" }}>{renderDay()}</View>

            {getWeekNumListInMonth(currentMonth).map((item, index) => {
              return (
                <View
                  key={index}
                  style={{
                    flexDirection: "row",
                  }}
                >
                  {renderWeekDays(item, index)}
                </View>
              );
            })}
            <View style={styles.selectedItem}>
              <Text>{selectedDate}</Text>
              {selectedIssue &&
                selectedIssue.map((item, index) => {
                  return (
                    <View key={index}>
                      <Text>{item.company}</Text>
                    </View>
                  );
                })}
            </View>
          </ScrollView>
        </Animated.View>
      </PanGestureHandler>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 0,
  },
  header: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "center",
    marginBottom: 10,
  },
  weekDay: {
    flex: 1,
    alignItems: "center",
    textAlign: "center",
    padding: 10,
    borderColor: "grey",
    borderWidth: 1,
    paddingHorizontal: 0,
    marginHorizontal: 0,
  },
  weekDayOfWeek: {
    flex: 1,
    alignItems: "center",
    textAlign: "center",
    padding: 10,
    paddingHorizontal: 0,
  },
  navButton: {
    padding: 10,
    backgroundColor: "lightgrey",
    marginHorizontal: 10,
  },
  weekInfo: {
    flexDirection: "column",
    alignItems: "center",
  },
  weekText: {
    fontSize: 16,
  },
  issueStart: {
    borderRadius: 10,
    borderTopRightRadius: 0,
    borderBottomRightRadius: 0,
    height: 20,
    marginVertical: 3,
    marginLeft: 10,
    zIndex: 5,
  },
  issueMid: {
    textAlign: "center",
    borderLeftWidth: 0,
    borderRightWidth: 0,
    borderLeftWidth: 1,
    borderRightWidth: 1,
    borderColor: "red",
    height: 20,
    marginVertical: 3,
    zIndex: 5,
  },
  issueEnd: {
    borderRadius: 10,
    borderTopLeftRadius: 0,
    borderBottomLeftRadius: 0,
    height: 20,
    marginVertical: 3,
    marginRight: 10,
  },
  issueSingle: {
    borderWidth1: 1,
    borderRadius: 10,
    height: 20,
    marginVertical: 3,
    marginHorizontal: 10,
    textAlign: "center",
  },
  selectedItem: {
    padding: 10,
    margin: 10,
    backgroundColor: "lightgrey",
    borderRadius: 10,
  },
});

export default Calendar;

0개의 댓글