开源一个ReactNative日历控件

4,941 阅读5分钟

项目地址: react-native-slideable-calendar-strip

演示地址: 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测试)