開源一個ReactNative日曆控制元件

juejin周輝發表於2019-03-04

專案地址: react-native-slideable-calendar-strip

開源一個ReactNative日曆控制元件

演示地址: Calendar-Strip.mp4

為何要再實現一個日曆控制元件

已經有了react-native-calendar-strip為何還需要我這個日曆控制元件?

一般的甲方都會在一個頁面上拖動拖動, 看到一個日曆, 就想滑動切換上下週, 由於react-native-calendar-strip沒有滑動特性, 並且在這個issue上討論了好久, 並沒有可行的方案. 於是就萌發自己寫一個日曆外掛的衝動.

控制元件需要有何特性

  • 左右滑動
  • 農曆展示
  • 選中日期
  • 事件標識
  • 下滑手勢
  • 回到今日

開發過程

要開發一個日曆控制元件, 最大的問題就是日期的轉換, 雖然Moment.js被很多人使用, 但是Moment使用大量的物件導向的API, 嚴重影響效能, 這也是在我嘗試了Moment之後發現的, 於是就換上了datefns, 輕量級js日期控制元件, 完全的函式式風格, 在日曆控制元件中只需儲存Date資料, 其他的日期比較/轉換等操作都交給datefns.

其次最頭疼的問題是使用FlatList展示資料時候, 如何動態生成新的資料.

在日曆控制元件首次載入時候, 會生成5個周的日期, 將FlatList滾動到中間一頁(今天所在的周, 第2頁, 從0開始). 當使用者滑動到最後一頁, 就需要再次生成2個周的資料拼接到尾部, 當使用者滑動到第一頁, 就需要生成2個周的資料拼接到陣列首部, 並且這時候今天所在的頁數也會變化, 所以要將今天所在的周的頁數+2, 拼接到首部會影響FlatList資料展示, 會展示第一頁資料, 此時的第一頁資料是最新生成的日期, 所以要滾動到第二頁(從0頁開始).

  loadPreviousTwoWeek(originalDates) {
    const originalFirstDate = originalDates[0];
    const originalLastDate = originalDates[originalDates.length-1];
    const firstDayOfPrevious2Week = subDays(originalFirstDate, 7 * 2);
    // 生成兩週之前的第一天到原始資料最後一天的日期
    const eachDays = eachDay(firstDayOfPrevious2Week, originalLastDate);
    this.setState(prevState => ({
      datas: eachDays,
      currentPage: prevState.currentPage+2,
      pageOfToday: prevState.pageOfToday+2,
    }), () => {
      // 悄無聲息滾動
      this.scrollToPage(2, false);
    });
  }
複製程式碼

滑動到最後一頁需要載入下兩週日期:

//  onEndReached={() => { this.onEndReached(); } }
//  onEndReachedThreshold={0.01}
  onEndReached() {
    // console.log(`onEndReached`);
    this.loadNextTwoWeek(this.state.datas);
  }
  loadNextTwoWeek(originalDates) {
    const originalFirstDate = originalDates[0];
    const originalLastDate = originalDates[originalDates.length-1];
    const lastDayOfNext2Week = addDays(originalLastDate, 7 * 2);
    const eachDays = eachDay(originalFirstDate, lastDayOfNext2Week);
    this.setState({ datas: eachDays });
  }
複製程式碼

ScrollViewonMomentumScrollEnd屬性監聽頁數變化, 記錄今天所在周的頁數和當前展示的頁數

// onMomentumScrollEnd={this.momentumEnd}
// scrollEventThrottle={500}
  momentumEnd = (event) => {
    const firstDayInCalendar = this.state.datas ? this.state.datas[0] : new Date();
    // 從第一天到今天一共多少天
    const daysBeforeToday = differenceInDays(firstDayInCalendar, new Date());
    // ~~向下取整, 第一天到今天一共幾周, 也就是今天所在周所在的頁數
    const pageOfToday = ~~(Math.abs(daysBeforeToday / 7));
    const screenWidth = event.nativeEvent.layoutMeasurement.width;
    // 通過offset來獲取當前所在頁數
    const currentPage = event.nativeEvent.contentOffset.x / screenWidth;
    // 記錄今天所在周頁數, 當前展示周的頁數, 今天所在周是否被展示
    this.setState({
      pageOfToday,
      currentPage,
      isTodayVisible: currentPage === pageOfToday,
    });

    // 如果滑動到第一頁了就需要載入之前兩週資料
    if (event.nativeEvent.contentOffset.x < width) {
      this.loadPreviousTwoWeek(this.state.datas);
    }
  }
複製程式碼

最棘手的問題是使用者點選了日曆之外的一個button, 跳轉到日曆上指定的一天.

  1. 指定日期正好在當前展示的一個周內
  currentPageDatesIncludes = (date) => {
    const { currentPage } = this.state;
    const currentPageDates = this.state.datas.slice(7*currentPage, 7*(currentPage+1));
    // dont use currentPageDates.includes(date); because can`t compare Date in it
    return !!currentPageDates.find(d => isSameDay(d, date));
  }
複製程式碼

直接設定選中日期為指定日期.

  1. 指定日期不在當前展示周內, 但是當前控制元件日期資料包含指定日期
    const sameDay = (d) => isSameDay(d, nextSelectedDate);
      if (this.state.datas.find(sameDay)) {
        let selectedIndex = this.state.datas.findIndex(sameDay);
        if (selectedIndex === -1) selectedIndex = this.state.pageOfToday; // in case not find
        const selectedPage = ~~(selectedIndex / 7);
        this.scrollToPage(selectedPage);
      }
複製程式碼

找到指定日期所在周的頁數, 滾動過去.

  1. 指定日期不在當前展示周內, 並且當前控制元件日期資料不包含指定日期
if (isFuture(nextSelectedDate)) {
  const head = this.state.datas[0];
  const tail = endOfWeek(nextSelectedDate);
  const days = eachDay(head, tail);
  this.setState({
    datas: days,
    isTodayVisible: false,
  }, () => {
    const page = ~~(days.length/7 - 1);
    // to last page
    this.scrollToPage(page);
  });
} else {
  const head = startOfWeek(nextSelectedDate);
  const tail = this.state.datas[this.state.datas.length - 1];
  const days = eachDay(head, tail);
  this.setState({
    datas: days,
    isTodayVisible: false,
  }, () => {
    // to first page
    this.scrollToPage(0);
  });
}
複製程式碼

如果是未來某一天, 那麼生成那天所在周的週六到當前日期控制元件所有日期的第一天之間的所有日期, 找到最後一頁, 滾動過去.

如果是之前某一天, 那麼生成那天所在周的週日(第一天)到當前日期控制元件所有日期的最後一天之間的所有日期, 滾動到第一頁.

關於 pageOfTodaycurrentPage 交給 momentumEnd() 自動處理.

滾動到頁方法是利用 FlatListscrollToIndex 實現:

  scrollToPage = (page, animated=true) => {
    this._calendar.scrollToIndex({ animated, index: 7 * page });
  }
複製程式碼

下滑手勢:

  componentWillMount() {
    const touchThreshold = 50;
    const speedThreshold = 0.2;
    this._panResponder = PanResponder.create({
      onStartShouldSetPanResponder: () => false,
      onMoveShouldSetPanResponder: (evt, gestureState) => {
        const { dy, vy } = gestureState;
        // 滑動距離大雨50, 並且滑動速度大於0.2, 有效下滑
        if (dy > touchThreshold && vy > speedThreshold) {
          const { onSwipeDown } = this.props;
          onSwipeDown && onSwipeDown();
        }
        return false;
      },
      onPanResponderRelease: () => {},
    });
  }
  
  // 最外層 <View {...this._panResponder.panHandlers}>
複製程式碼

其他:

  • 使用 ChineseLunar 來轉換中國農曆.
  • isTodayVisible 為false時在日曆Header上展示一個 button
  • 點選 跳轉到今天所在周的頁數
  • 最終整個控制元件的 state 只有 :
this.state = {
  datas: this.getInitialDates(), // 儲存所有日期,
  isTodayVisible: true, // 今天所在周是否在展示
  pageOfToday: 2, // 今天在日曆的第幾頁,  從0開始
  currentPage: 2, // 當前是日曆的第幾頁,  從0開始
};
複製程式碼
  • 所有儲存的日期都是 Date格式, 並且是0點 Wed May 16 2018 00:00:00 GMT+0800 (CST)
  • 控制元件所需要的props:
CalendarStrip.propTypes = {
  selectedDate: PropTypes.object.isRequired,
  onPressDate: PropTypes.func,
  onPressGoToday: PropTypes.func,
  markedDate: PropTypes.array,
  onSwipeDown: PropTypes.func,
};

複製程式碼

PS. 使用datefns另一個好處是, 當傳給控制元件

markedDate = [`2018-01-01`, `2018-05-01`, `2018-06-01`]
複製程式碼

也是支援的, 不必須傳一個Date格式的日期.

如何開源

1. 託管到GitHub

2. 釋出到npmjs

3. travis持續整合(jest測試)

相關文章