RN自定义组件封装 - 拖拽选择日期的日历

原文地址:https://github.com/SmallStoneSK/Blog/issues/2javascript

1. 前言

因为最近接到一个须要支持拖拽选择日期的日历需求,作出来感受体验和效果都还不错,因此今天想跟你们分享一下封装这个日历组件的过程。html

2. 调研开始

正所谓“磨刀不误砍柴工”,既然要作一个日历,那么先让咱们来看看最终想要作成什么样:java

因为以前吃过RN在安卓上性能表现不佳的亏,深深地怀疑这东西作出来能在安卓上跑么,尤为是日期要实时地随着手指滑动的位置发生变化。还有这牵涉到了手势系统,以前又没捣鼓过,谁知道有没有什么天坑在等着我。。。react

唉,无论了,先把最简单的样式实现了再考虑这些吧~android

But! 正所谓“巧妇难为无米之炊”,没有相应的日历数据,怎么画日历!So, let's do it first.ios

2.1 日历数据

Q1:如何肯定日历要渲染哪些天的数据?git

仔细观察先前的示意图,咱们能够发现日历中有些天是暗的,有些是高亮的。也就是说日历上所渲染出来的这些格子,是有available/unavailable区别的。为此,咱们能够支持两种方式经过props传入:github

  1. 调用方指定fullDateRange和availableDateRange。fullDateRange是起始月份第一天到终止月份最后一天,availableDateRange是用户可选范围第一天到最后一天。
  2. 调用方指定maxDays。也就是今天是availableDateRange的第一天,而今天+maxDays是availableDateRange的最后一天;fullDateRange则是今天所在月份的第一天到今天+maxDays所在月份的最后一天。

理清了思路,咱们来看看代码实现:算法

export class DraggableCalendar extends Component {

  constructor(props) {
    super(props);
    this.state = {
      calendarData: this._genCalendarData()
    };
  }

  _genCalendarData({fullDateRange, availableDateRange, maxDays}) {

    let startDate, endDate, availableStartDate, availableEndDate;

    // if the exact dateRange is given, use availableDateRange; or render [today, today + maxDays]
    if(fullDateRange) {
      [startDate, endDate] = fullDateRange;
      [availableStartDate, availableEndDate] = availableDateRange;
    } else {
      const today = Helper.parseDate(new Date(), 'yyyy-MM-dd');
      availableStartDate = today;
      availableEndDate = Helper.addDay(today, maxDays);
      startDate = new Date(new Date(today).setDate(1));
      endDate = Helper.getLastDayOfMonth(availableEndDate.getFullYear(), availableEndDate.getMonth());
    }

    // TODO: realize _genDayData function
    return this._genDayData({startDate, endDate, availableStartDate, availableEndDate});
  }

  // ...
}
复制代码

Q2:calendarData的结构怎么设计比较好?react-native

通过上一步,咱们已经知晓了哪些day是须要渲染的,接下来咱们再看看数据结构应该怎么设计:

  1. 首先,每月份的数据实际上是类似的,无非就是包括了有哪些天。所以,咱们能够用一个map对象来存储,key就是year-month组成的字符串,value就是这个月份相对应的数据。这样既能利用年月做为特殊标志符彼此区分,还能根据给定的年月信息快速定位到相应的days数据。
  2. 再来看day的数据结构,咱们能够先给它定义几个基础属性:date、available、status。其中,status表明该日期当前的状态,主要是用以区分用户在拖拽操做日历时,有没有选中该日期。

咱们再来看看相应的代码应该如何实现:

const DAY_STATUS = {
  NONE: 0,
  SINGLE_CHOSEN: 1,
  RANGE_BEGIN_CHOSEN: 2,
  RANGE_MIDDLE_CHOSEN: 3,
  RANGE_END_CHOSEN: 4
};

_genDayData({startDate, endDate, availableStartDate, availableEndDate}) {

  let result = {}, curDate = new Date(startDate);

  while(curDate <= endDate) {

    // use `year-month` as the unique identifier
    const identifier = Helper.formatDate(curDate, 'yyyy-MM');

    // if it is the first day of a month, init it with an array
    // Note: there are maybe several empty days at the first of each month
    if(!result[identifier]) {
      result[identifier] = [...(new Array(curDate.getDay() % 7).fill({}))];
    }

    // save each day's data into result
    result[identifier].push({
      date: curDate,
      status: DAY_STATUS.NONE,
      available: (curDate >= availableStartDate && curDate <= availableEndDate)
    });

    // curDate + 1
    curDate = Helper.addDay(curDate, 1);
  }

  // there are several empty days in each month
  Object.keys(result).forEach(key => {
    const len = result[key].length;
    result[key].push(...(new Array((7 - len % 7) % 7).fill({})));
  });

  return result;
}
复制代码

生成日历数据就这样大功告成啦,貌似还挺容易的嘛~ 咱们来打个log看看长什么样:

2.2 日历样式

其实样式这个环节,却是最容易的,主要是对日历的内容进行合适的拆解。

  1. 首先,咱们能够拆分为renderHeader和renderBody。其中,header是上方的周几信息,body则是由多个月份组成的主体内容。
  2. 其次,每月份由又能够拆分红renderMonthHeader和renderMonthBody。其中,monthHeader展现相应的年月信息,monthBody则是这个月的日期信息。(PS: 有一点能够取巧的是monthBody部分,咱们能够用FlatList的numColumns这个属性实现,只要设置成7就行。)
  3. 最后,咱们能够用renderDay来渲染每一个日期的信息。须要注意的是,每一个Day可能有5种不一样的状态(NONE, SINGLE_CHOSEN, RANGE_BEGIN_CHOSEN, RANGE_MIDDLE_CHOSEN, RANGE_END_CHOSEN),因此须要不一样的相应样式来对应。

除此以外,还有一点就是必定要考虑该日历组件的可扩展性,样式方面确定是可让调用方可自定义啦。为此,代码方面咱们能够这么写:

export class DraggableCalendar extends Component {

  // ...

  _renderHeader() {
    const {headerContainerStyle, headerTextStyle} = this.props;
    return (
      <View style={[styles.headerContainer, headerContainerStyle]}> {['日', '一', '二', '三', '四', '五', '六'].map(item => ( <Text key={item} style={[styles.headerText, headerTextStyle]}>{item}</Text> ))} </View>
    );
  }

  _renderBody() {
    const {calendarData} = this.state;
    return (
      <ScrollView> {Object .keys(calendarData) .map((key, index) => this._renderMonth({identifier: key, data: calendarData[key], index})) } </ScrollView>
    );
  }

  _renderMonth({identifier, data, index}) {
    return [
      this._renderMonthHeader({identifier}),
      this._renderMonthBody({identifier, data, index})
    ];
  }

  _renderMonthHeader({identifier}) {
    const {monthHeaderStyle, renderMonthHeader} = this.props;
    const [year, month] = identifier.split('-');
    return (
      <View key={`month-header-${identifier}`}> {renderMonthHeader ? renderMonthHeader(identifier) : <Text style={[styles.monthHeaderText, monthHeaderStyle]}>{`${parseInt(year)}年${parseInt(month)}月`}</Text> } </View>
    );
  }

  _renderMonthBody({identifier, data, index}) {
    return (
      <FlatList ref={_ => this._refs['months'][index] = _} data={data} numColumns={7} bounces={false} key={`month-body-${identifier}`} keyExtractor={(item, index) => index} renderItem={({item, index}) => this._renderDay(item, index)} /> ); } _renderDay(item, index) { const { renderDay, dayTextStyle, selectedDayTextStyle, dayContainerStyle, singleDayContainerStyle, beginDayContainerStyle, middleDayContainerStyle, endDayContainerStyle } = this.props; let usedDayTextStyle = [styles.dayText, dayTextStyle]; let usedDayContainerStyle = [styles.dayContainer, dayContainerStyle]; if(item.status !== DAY_STATUS.NONE) { const containerStyleMap = { 1: [styles.singleDayContainer, singleDayContainerStyle], 2: [styles.beginDayContainer, beginDayContainerStyle], 3: [styles.middleDayContainer, middleDayContainerStyle], 4: [styles.endDayContainer, endDayContainerStyle] }; usedDayTextStyle.push(styles.selectedDayText, selectedDayTextStyle); usedDayContainerStyle.push(...(containerStyleMap[item.status] || {})); } return ( <View key={`day-${index}`} style={{flex: 1}}> {renderDay ? renderDay(item, index) : <View style={usedDayContainerStyle}> {item.date && ( <Text style={[...usedDayTextStyle, !item.available && {opacity: .6}]}> {item.date.getDate()} </Text> )} </View> } </View> ); } render() { const {style} = this.props; return ( <View style={[styles.container, style]}> {this._renderHeader()} {this._renderBody()} </View> ); } } 复制代码

2.3 实现拖拽

呼~ 长吁一口气,万里长征终于迈出了第一步,接下来就是要实现拖拽了。而要实现拖拽,咱们能够经过大体如下流程:

  1. 得到全部日历中全部日期的布局信息,和手指触摸的实时坐标信息;
  2. 根据手指当前所在的坐标信息,计算出手指落在哪一个日期上,也就是当前选中的日期;
  3. 比较先后的选中日期信息,若是不一样,更新state,触发render从新渲染。

为此,咱们来逐一解决各个问题:

2.3.1 获取相关布局和坐标信息

获取相关布局:
在RN中,有两种方法能够获取一个元素的布局信息。一个是onLayout,还有一个就是UIManager.measure。讲道理,两种方法都能实现咱们的需求,可是经过UIManager.measure,咱们这里的代码能够更优雅。具体代码以下:

export class DraggableCalendar extends Component {

  constructor(props) {
    // ...
    this._monthRefs = [];
    this._dayLayouts = {};
  }

  componentDidMount() {
    Helper.waitFor(0).then(() => this._genLayouts());
  }

  _getRefLayout(ref) {
    return new Promise(resolve => {
      UIManager.measure(findNodeHandle(ref), (x, y, width, height, pageX, pageY) => {
        resolve({x, y, width, height, pageX, pageY});
      });
    });
  }

  _genDayLayout(identifier, layout) {

    // according to the identifier, find the month data from calendarData
    const monthData = this.state.calendarData[identifier];

    // extract info from layout, and calculate the width and height for each day item
    const {x, y, width, height} = layout;
    const ITEM_WIDTH = width / 7, ITEM_HEIGHT = height / (monthData.length / 7);

    // calculate the layout for each day item
    const dayLayouts = {};
    monthData.forEach((data, index) => {
      if(data.date) {
        dayLayouts[Helper.formatDate(data.date, 'yyyy-MM-dd')] = {
          x: x + (index % 7) * ITEM_WIDTH,
          y: y + parseInt(index / 7) * ITEM_HEIGHT,
          width: ITEM_WIDTH,
          height: ITEM_HEIGHT
        };
      }
    });

    // save dayLayouts into this._layouts.days
    Object.assign(this._dayLayouts, dayLayouts);
  }

  _genLayouts() {
    // after rendering scrollView and months, generates the layout params for each day item.
    Promise
      .all(this._monthRefs.map(ref => this._getRefLayout(ref)))
      .then((monthLayouts) => {
        // according to the month's layout, calculate each day's layout
        monthLayouts.forEach((monthLayout, index) => {
          this._genDayLayout(Object.keys(this.state.calendarData).sort()[index], monthLayout);
        });
        console.log(Object.keys(this._dayLayouts).map(key => this._dayLayouts[key].y));
      });
  }

  _renderMonthBody({identifier, data, index}) {
    return (
      <FlatList ref={_ => this._monthRefs[index] = _} data={data} numColumns={7} bounces={false} key={`month-body-${identifier}`} keyExtractor={(item, index) => index} renderItem={({item, index}) => this._renderDay(item, index)} /> ); } // ... } 复制代码

经过给UIManager.measure封装一层promise,咱们能够巧妙地利用Promise.all来知道何时全部的month元素都已经渲染完毕,而后能够进行下一步的dayLayouts计算。可是,若是使用onLayout方法就不同了。因为onLayout是异步触发的,因此无法保证其调用的前后顺序,更是不知道何时全部的month都渲染完毕了。除非,咱们再额外加一个计数器,当onLayout触发的次数(计数器的值)等于month的个数,这样才能知道全部month渲染完毕。不过相比于前一种方法,确定是前一种更优雅啦~

获取手指触摸的坐标信息:
重头戏终于要来啦!在RN中,有一个手势系统封装了丰富的手势相关操做,相关文档能够戳这里

首先咱们来思考这么个问题,因为日历的内容是用ScrollView包裹起来的,所以咱们正常的上下拖动操做会致使ScrollView内容上下滚动。那么问题就来了,咱们应该怎么区分这个上下拖动操做,是应该让内容上下滚动,仍是选中不一样的日历范围呢?

在这里,我采用的解决方案是用两个透明的View盖在ScrollView上层,而后把手势处理系统加在这层View上。因为手指是触摸在View上,并不会致使ScrollView滚动,所以完美地规避了上面这个问题。

不过,若是用这种方法会有另一个问题。由于透明的View是采用的绝对定位布局,left和top值是当前选中日期的坐标信息。可是当ScrollView上下发生滚动时,这层透明View也要跟着动,也就是在onScroll事件中改变其top值,并刷新当前组件。咱们来看看具体代码是怎么实现的:

export class DraggableCalendar extends Component {

  constructor(props) {

    // ...

    this._scrollY = 0;
    this._panResponder = {};

    this._onScroll = this._onScroll.bind(this);
  }

  componentWillMount() {
    this._initPanResponder();
  }

  _initPanResponder() {
    // TODO
  }

  _genDraggableAreaStyle(date) {
    if(!date) {
      return null;
    } else {
      if(Helper.isEmptyObject(this._dayLayouts)) {
        return null;
      } else {
        const {x, y, width, height} = this._dayLayouts[Helper.formatDate(date, 'yyyy-MM-dd')];
        return {left: x, top: y - this._scrollY, width, height};
      }
    }
  }

  _onScroll(e) {
    this._scrollY = Helper.getValue(e, 'nativeEvent:contentOffset:y', this._scrollY);
    clearTimeout(this.updateTimer);
    this.updateTimer = setTimeout(() => {
      this.forceUpdate();
    }, 100);
  }

  _renderBody() {
    const {calendarData} = this.state;
    return (
      <View style={styles.bodyContainer}> <ScrollView scrollEventThrottle={1} onScroll={this._onScroll}> {Object .keys(calendarData) .map((key, index) => this._renderMonth({identifier: key, data: calendarData[key], index})) } </ScrollView> {this._renderDraggableArea()} </View>
    );
  }

  _renderDraggableArea() {
    const {startDate, endDate} = this.state;
    if(!startDate || !endDate) {
      return null;
    } else {
      const isSingleChosen = startDate.getTime() === endDate.getTime();
      return [
        <View
          key={'drag-start'}
          {...this._panResponder.panHandlers}					style={[styles.dragContainer, this._genDraggableAreaStyle(startDate)]}
        />,
        <View
          key={'drag-end'}
          {...this._panResponder.panHandlers}
          style={[styles.dragContainer, this._genDraggableAreaStyle(endDate), isSingleChosen && {height: 0}]}
        />
      ];
    }
  }

  // ...
}
复制代码

注意:state中的startDate和endDate是当前选中时间范围的第一天和最后一天。因为如今都尚未值,因此目前看不出效果。

接下来,咱们再实现最重要的_initPanResponder方法。PanResponder提供了不少回调,在这里,咱们主要用到的就只有5个:

  1. onStartShouldSetPanResponder:开始的时候申请成为响应者;
  2. onMoveShouldSetPanResponder:移动的时候申请成为响应者;
  3. onPanResponderGrant:开始手势操做;
  4. onPanResponderMove:移动中;
  5. onPanResponderRelease:手指放开,手势操做结束。

除此以外,以上的回调函数都会携带两个参数:event和gestureState,它们中包含了很是重要的信息。在这里,咱们主要用到的是:

event.nativeEvent:

  1. locationX: 触摸点相对于父元素的横坐标
  2. locationY: 触摸点相对于父元素的纵坐标

gestureState:

  1. dx: 从触摸操做开始时的累计横向路程
  2. dy: 从触摸操做开始时的累计纵向路程

所以,咱们能够在onPanResponderGrant记录下一开始手指的坐标,而后在onPanResponderMove中获取deltaX和deltaY,相加以后就获得当前手指的实时坐标。一块儿来看下代码:

export class DraggableCalendar extends Component {

  constructor(props) {
    // ...

    this.state = {
      startDate: new Date(2018, 5, 7, 0, 0, 0),
      endDate: new Date(2018, 5, 10, 0, 0, 0),
      calendarData: this._genCalendarData({fullDateRange, availableDateRange, maxDays})
    };

    this._touchPoint = {};

    this._onPanGrant = this._onPanGrant.bind(this);
    this._onPanMove = this._onPanMove.bind(this);
    this._onPanRelease = this._onPanRelease.bind(this);
  }

  _initPanResponder() {
    this._panResponder = PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onMoveShouldSetPanResponder: () => true,
      onPanResponderGrant: this._onPanGrant,
      onPanResponderMove: this._onPanMove,
      onPanResponderRelease: this._onPanRelease
    });
  }

  _onPanGrant(evt) {
    // save the initial position
    const {locationX, locationY} = evt.nativeEvent;
    this._touchPoint.x = locationX;
    this._touchPoint.y = locationY;
  }

  _onPanMove(evt, gesture) {

    // save the delta offset
    const {dx, dy} = gesture;
    this._touchPoint.dx = dx;
    this._touchPoint.dy = dy;

    // console for test
    console.log('(x, y):', this._touchPoint.x + dx, this._touchPoint.y + dy);
  }

  _onPanRelease() {
    // clear the saved info
    this._touchPoint = {};
  }

  // ...
}
复制代码

咱们给state中的startDate和endDate随意加个值,并给draggableArea加个半透明的红色来测试下,咱们的手势操做到底有没有起做用。

咦~ 怎么console获得的值看起来好像不太对。打印出来的(x, y)像是相对draggableArea的坐标,而不是整个ScrollView的坐标。不过这也好办,由于咱们知道draggableArea的left和top值,因此加上就行了。咱们能够在onTouchStart这个函数中作这件事,同时还能够区分当前手指触摸的是选中时间范围内的第一天仍是最后一天。代码以下:

export class DraggableCalendar extends Component {

  constructor(props) {
    // ...
    this._pressEnd = false;
    this._pressStart = false;
  }

  _onTouchStart(type, date) {
    const pressMap = {start: '_pressStart', end: '_pressEnd'};
    this[pressMap[type]] = true;
    if(this._pressStart || this._pressEnd) {
      const dateStr = Helper.formatDate(date, 'yyyy-MM-dd');
      this._touchPoint.x += Helper.getValue(this, `_dayLayouts:${dateStr}:x`, 0);
      this._touchPoint.y += Helper.getValue(this, `_dayLayouts:${dateStr}:y`, 0);
    }
  }

  _renderDraggableArea() {
    const {startDate, endDate} = this.state;
    if(!startDate || !endDate) {
      return null;
    } else {
      const isSingleChosen = startDate.getTime() === endDate.getTime();
      return [
        <View
          key={'drag-start'}
          {...this._panResponder.panHandlers}
          onTouchStart={() => this._onTouchStart('start', startDate)}
          style={[styles.dragContainer, this._genDraggableAreaStyle(startDate)]}
        />,
        <View
          key={'drag-end'}
          {...this._panResponder.panHandlers}
          onTouchStart={() => this._onTouchStart('end', endDate)}
          style={[styles.dragContainer, this._genDraggableAreaStyle(endDate), isSingleChosen && {height: 0}]}
        />
      ];
    }
  }

  // ...
}
复制代码

2.3.2 坐标信息转换成日期信息

根据上面的步骤,咱们已经成功地获取到了当前手指触摸的实时坐标。因此,接下来就是把该坐标转换成落在哪一个日期上,从而能够判断出选中日期是否发生变化。

这一步,说简单也简单,要想复杂那也能够复杂。简单来看。咱们的this._dayLayouts保存了全部Day的layout,咱们只须要进行遍历,判断手指坐标有没有落在某个Day的范围当中便可。复杂来说,就是减小没必要要的比较次数。不过,咱们仍是先实现功能为主,优化步骤在后面介绍。实现代码以下:

// Helper.js
export const Helper = {
  // ...
  positionToDate(position, dayLayouts) {
    let date = null;
    Object.keys(dayLayouts).forEach(key => {
      const {x, y} = position, layout = dayLayouts[key];
      if(
        x >= layout.x &&
        x <= layout.x + layout.width &&
        y >= layout.y &&
        y <= layout.y + layout.height
      ) {
        date = Helper.parseDate(key);
      }
    });
    return date;
  }
}

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _onPanMove(evt, gesture) {
    // ...
    // for test
    console.log('cur date:', Helper.positionToDate({x: this._touchPoint.x + dx, y: this._touchPoint.y + dy}, this._dayLayouts));
  }
}
复制代码

2.3.3 对比先后选中日期,触发渲染

通过上一步的positionToDate,咱们知道了当前手指落在哪一天上。接下来,就是比较当前新的选中日期和拖动以前旧的选中日期,看看有没有发生变化。

**特别注意:**假如咱们一开始手指是触摸在start上,可是拖动以后手指停留的日期已经大于end上的日期;或者反过来,一开始触摸在end上,拖动以后手指停留的日期小于start上的日期。这种特殊状况下,pressStart和pressEnd其实发生了变化,因此须要特殊处理。咱们来看看代码是怎么写的:

// Helper.js
export const Helper = {
  getDayStatus(date, selectionRange = []) {

    let status = DAY_STATUS.NONE;
    const [startDate, endDate] = selectionRange;

    if(!startDate || !endDate) {
      return status;
    }

    if(startDate.getTime() === endDate.getTime()) {
      if(date.getTime() === startDate.getTime()) {
        return DAY_STATUS.SINGLE_CHOSEN;
      }
    } else {
      if(date.getTime() === startDate.getTime()) {
        return DAY_STATUS.RANGE_BEGIN_CHOSEN;
      } else if(date > startDate && date < endDate) {
        return DAY_STATUS.RANGE_MIDDLE_CHOSEN;
      } else if(date.getTime() === endDate.getTime()) {
        return DAY_STATUS.RANGE_END_CHOSEN;
      }
    }

    return status;
  }
};

// DraggableCalendar.js
export class DraggableCalendar extends Component {

  _updateDayStatus(selectionRange) {

    const {calendarData} = this.state;
    Object.keys(calendarData).forEach(key => {

      // set a flag: if status has changed, it means this month should be re-rendered.
      let hasChanged = false;
      calendarData[key].forEach(dayData => {
        if(dayData.date) {
          const newDayStatus = Helper.getDayStatus(dayData.date, selectionRange);
          if(dayData.status !== newDayStatus) {
            hasChanged = true;
            dayData.status = newDayStatus;
          }
        }
      });

      // as monthBody is FlatList, the data should be two objects. Or it won't be re-rendered
      if(hasChanged) {
        calendarData[key] = Object.assign([], calendarData[key]);
      }
    });

    this.setState({calendarData});
  }

  _updateSelection() {

    const {x, dx, y, dy} = this._touchPoint;
    const touchingDate = Helper.positionToDate({x: x + dx, y: y + dy}, this._dayLayouts);

    // if touchingDate doesn't exist, return
    if(!touchingDate) return;

    // generates new selection dateRange
    let newSelection = [], {startDate, endDate} = this.state;
    if(this._pressStart && touchingDate.getTime() !== startDate.getTime()) {
      if(touchingDate <= endDate) {
        newSelection = [touchingDate, endDate];
      } else {
        this._pressStart = false;
        this._pressEnd = true;
        newSelection = [endDate, touchingDate];
      }
    } else if(this._pressEnd && touchingDate.getTime() !== endDate.getTime()) {
      if(touchingDate >= startDate) {
        newSelection = [startDate, touchingDate];
      } else {
        this._pressStart = true;
        this._pressEnd = false;
        newSelection = [touchingDate, startDate];
      }
    }

    // if selection dateRange changes, update it
    if(newSelection.length > 0) {
      this._updateDayStatus(newSelection);
      this.setState({startDate: newSelection[0], endDate: newSelection[1]});
    }
  }

  _onPanMove(evt, gesture) {
    // ...
    this._updateSelection();
  }
}
复制代码

这里须要对_updateDayStatus函数进行稍加解释:
咱们在renderMonthBody用的是FlatList,因为FlatList是纯组件,因此只有当props发生变化时,才会从新渲染。虽然咱们在_updateDayStatus中更新了calendarData,但实际上是同一个对象。因此,分配给renderMonthBody的data也会是同一个对象。为此,咱们在更新Day的status时用一个flag来表示该月份中是否有日期的状态发生变化,若是发生变化,咱们会用Object.assign来复制一个新的对象。这样一来,状态发生变化的月份会从新渲染,而没有发生变化的月份不会,这反而算是一个性能上的优化吧。

2.4 其余

其实,上面咱们已经实现了基本的拖拽操做。可是,还有一些遗留的小问题:

  1. 用户点选非选中时间段的日期,应该重置当前选中日期;
  2. 用户手指停留的日期是unavailable(即不可操做的)时,该日期不该该被选中;
  3. 组件应支持在初始化的时候选中props中指定的一段时间范围;
  4. 手指在滑动到月初/月末空白区域时,也能响应选中月初/月末;
    ...

固然了,上面的这些问题都是细节问题,考虑篇幅缘由,就再也不详述了。。。

可是!性能优化问题是确定要讲的!由于,就目前作出来的这东西在ios上表现还能够,可是在android上拖动的时候,会有一点卡顿感。尤为是在性能差的机子上,卡顿感就更明显了。。。

3. 性能优化

咱们都知道,react性能上的优化很大程度上得益于其强大的DomDiff,经过它能够减小dom操做。可是过多的DomDiff也是一个消耗,因此怎么减小无谓的DomDiff呢?答案是正确地使用shouldComponentUpdate函数,不过咱们仍是得首先找出哪些是无谓的DomDiff。

为此,咱们能够在咱们写的全部_renderXXX函数中打一个log,在手指拖动的时候,都有哪些组件一直在render?

通过试验,能够发现每次选中日期发生变化的时候,_renderMonth,_renderMonthHeader,_renderMonthBody和_renderDay这几个函数会触发不少次。缘由很简单,当选中日期发生变化时,咱们经过setState更新了clendarData,从而触发了整个日历从新render。所以,每一个month都会从新渲染,相应的这几个render函数都会触发一遍。

3.1 减小renderMonth的DomDiff

既然源头已经找到,咱们就能够对症下药了。其实也简单,咱们每次只要更新状态发生变化的月份就能够,其余的月份能够省略其DomDiff过程。

可是!!!这个解决方案有一个弊端,就是须要维护changingMonth这个变量。每次手指拖动操做的时候,咱们都得计算出哪些月份是发生状态变化的;手指释放以后,又得重置changingMonth。并且,如今这个组件的操做逻辑相对来讲还比较简单,若是交互逻辑日后变得愈来愈复杂,那这个维护成本会继续上升。。。

因此,咱们能够换个思路~ month不是每次都会DomDiff吗?不要紧,我把month中的子组件封装成PureComponent,这样子组件的DomDiff过程是会被优化掉的。因此,即便每次渲染month,也会大大减小无谓的DomDiff操做。而_renderMonthBody用的是FlatList,这已是纯组件了,因此已经起到必定的优化效果,否则_renderDay的触发次数会更多。所以,咱们要作的只是把_renderMonthHeader改形成纯组件就行了。来看看代码:

// MonthHeader.js
export class MonthHeader extends PureComponent {
  render() {
    const {identifier, monthHeaderTextStyle, renderMonthHeader} = this.props;
    const [year, month] = identifier.split('-');
    return (
      <View> {renderMonthHeader ? renderMonthHeader(identifier) : <Text style={[styles.monthHeaderText, monthHeaderTextStyle]}> {`${parseInt(year)}年${parseInt(month)}月`} </Text> } </View>
    );
  }
}

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _renderMonthHeader({identifier}) {
    const {monthHeaderTextStyle, renderMonthHeader} = this.props;
    return (
      <MonthHeader key={identifier} identifier={identifier} monthHeaderTextStyle={monthHeaderTextStyle} renderMonthHeader={renderMonthHeader} /> ); } } 复制代码

3.2 减小renderDay的DomDiff

根据前面的试验结果,其实咱们能够发现每次渲染月份的时候,这个月份中的全部DayItem都会被渲染一遍。但实际上只须要状态发生变化的DayItem从新渲染便可。因此,这又给了咱们优化的空间,能够进一步减小无谓的DomDiff。

上面的例子已经证实PureComponent是再好不过的优化利器了~ 因此,咱们继续把_renderDay改形成纯组件,来看代码:

// Day.js
export class Day extends PureComponent {

  _genStyle() {
    const {
      data, dayTextStyle, selectedDayTextStyle,
      dayContainerStyle, singleDayContainerStyle,
      beginDayContainerStyle, middleDayContainerStyle, endDayContainerStyle
    } = this.props;
    const usedDayTextStyle = [styles.dayText, dayTextStyle];
    const usedDayContainerStyle = [styles.dayContainer, dayContainerStyle];
    if(data.status !== DAY_STATUS.NONE) {
      const containerStyleMap = {
        1: [styles.singleDayContainer, singleDayContainerStyle],
        2: [styles.beginDayContainer, beginDayContainerStyle],
        3: [styles.middleDayContainer, middleDayContainerStyle],
        4: [styles.endDayContainer, endDayContainerStyle]
      };
      usedDayTextStyle.push(styles.selectedDayText, selectedDayTextStyle);
      usedDayContainerStyle.push(...(containerStyleMap[data.status] || {}));
    }
    return {usedDayTextStyle, usedDayContainerStyle};
  }

  render() {
    const {data, renderDay} = this.props;
    const {usedDayTextStyle, usedDayContainerStyle} = this._genStyle();
    return (
      <View style={{flex: 1}}> {renderDay ? renderDay(data) : <View style={usedDayContainerStyle}> {data.date && ( <Text style={[...usedDayTextStyle, !data.available && {opacity: .6}]}> {data.date.getDate()} </Text> )} </View> } </View>
    );
  }
}

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _renderDay(item, index) {
    const styleKeys = [
      'dayTextStyle', 'selectedDayTextStyle',
      'dayContainerStyle', 'singleDayContainerStyle',
      'beginDayContainerStyle', 'middleDayContainerStyle', 'endDayContainerStyle'
    ];
    return (
      <Day key={`day-${index}`} data={item} status={item.status} {...styleKeys.map(key => this.props[key])} /> ); } } 复制代码

3.3 减小positionToDate的查找次数

通过上面两步,已经减缓了一部分的DomDiff开销了。那还有什么能够优化的呢?还记得前文提到的positionToDate函数么?目前咱们是经过遍历的方式将坐标转换成日期的,时间复杂度是O(n),因此这里还有优化的空间。那么又该怎么优化呢?

这时之前学的算法是终于有用武之地了,哈哈~ 因为日历中的日期排版颇有规律,从左到右看,都是递增的;从上到下看,也是递增的。so~ 咱们能够用二分查找来减小这个查找次数,将时间复杂度降到O(nlog2)。不过,在这个case中,咱们应当如何使用二分呢?

其实,咱们可使用3次二分:

  1. 由于Month垂直方向上是递增的,纵坐标y也是递增的,因此先用二分定位到当前手指落在哪一个月份中;
  2. 同一个月内,水平方向上横坐标x是递增的,因此再用一次二分定位到当前手指落在周几上;
  3. 同一个月内,垂直方向上纵坐标y是递增的,能够再用一次二分定位到当前手指落在哪天上。

思路已经有了,但是咱们的this._dayLayouts是一个对象,无法操做。因此,咱们须要作一层转换,姑且就叫索引吧,这样显得洋气~~~ 来看代码:

// Helper.js
export const Helper = {
  // ...
  arrayTransform(arr = []) {

    if(arr.length === 0) return [];

    let result = [[]], lastY = arr[0].y;
    for(let i = 0, count = 0; i < arr.length; i++) {
      if(arr[i].y === lastY) {
        result[count].push(arr[i]);
      } else {
        lastY = arr[i].y;
        result[++count] = [arr[i]];
      }
    }

    return result;
  },
  buildIndexItem({identifier, dayLayouts, left, right}) {
    const len = dayLayouts.length;
    return {
      identifier,
      boundary: {
        left, right, upper: dayLayouts[0].y,
        lower: dayLayouts[len - 1].y + dayLayouts[len - 1].height
      },
      dayLayouts: Helper.arrayTransform(dayLayouts.map((item, index) => {
        const date = `${identifier}-${index + 1}`;
        if(index === 0){
          return Object.assign({date}, item, {x: left, width: item.x + item.width - left});
        } else if (index === len - 1) {
          return Object.assign({date}, item, {width: right - item.x});
        } else {
          return Object.assign({date}, item);
        }
      }))
    };
  }
};

// DraggableCalendar.js
export class DraggableCalendar extends Component {

  constructor(props) {
    // ...
    this._dayLayoutsIndex = [];
  }

  _genDayLayout(identifier, layout) {
    // ...
    // build the index for days' layouts to speed up transforming (x, y) to date
    this._dayLayoutsIndex.push(Helper.buildIndexItem({
      identifier, left: x, right: x + width,
      dayLayouts: Object.keys(dayLayouts).map(key => dayLayouts[key])
    }));
  }

  // ...
}
复制代码

从上面打印出来的索引结果中,咱们能够看到创建索引的过程主要是干了两件事:

  1. 保存下了每月的上下左右边界,这样就能够用二分快速找到当前手指落在哪一个月份中了;
  2. 将本来一维的dayLayouts转换成了二维数组,与日历的展现方式保持一致,目的也是为了方便二分查找。

接下来再看看二分查找的代码:

// Helper.js
export const Helper = {
  binarySearch(data=[], comparedObj, comparedFunc) {

    let start = 0;
    let end = data.length - 1;
    let middle;

    let compareResult;
    while(start <= end) {
      middle = Math.floor((start + end) / 2);
      compareResult = comparedFunc(data[middle], comparedObj);
      if(compareResult < 0) {
        end = middle - 1;
      } else if(compareResult === 0) {
        return data[middle];
      } else {
        start = middle + 1;
      }
    }

    return undefined;
  },
  positionToDate(position, dayLayoutsIndex) {

    // 1. use binary search to find the monthIndex
    const monthData = Helper.binarySearch(dayLayoutsIndex, position, (cur, compared) => {
      if(compared.y < cur.boundary.upper) {
        return -1;
      } else if(compared.y > cur.boundary.lower) {
        return 1;
      } else {
        return 0;
      }
    });

    // 2. use binary search to find the rowData
    if(monthData === undefined) return null;
    const rowData = Helper.binarySearch(monthData.dayLayouts, position, (cur, compared) => {
      if(compared.y < cur[0].y) {
        return -1;
      } else if(compared.y > cur[0].y + cur[0].height) {
        return 1;
      } else {
        return 0;
      }
    });

    // 3. use binary search to find the result
    if(rowData === undefined) return null;
    const result = Helper.binarySearch(rowData, position, (cur, compared) => {
      if(compared.x < cur.x) {
        return -1;
      } else if(compared.x > cur.x + cur.width) {
        return 1;
      } else {
        return 0;
      }
    });

    // 4. return the final result
    return result !== undefined ? Helper.parseDate(result.date) : null;
  }
  // ...
};
复制代码

咱们来绝歌例子看看优化的效果:假如渲染的日历数据有6个月的内容,也就是180天。最坏的状况下,原先须要查找180次才有结果。而如今呢?月份最多3次能肯定,row最多3次能肯定,col最多3次能肯定,也就是最多9次就能找到结果。

啊哈~ 简直是文美~ 再看看手指拖拽时的效果,丝毫没有卡顿感,妈妈不再用担忧RN在android上的性能效果啦~

4. 实战

费了那么大劲儿,又是封装组件,又是优化性能的,如今终于能够能派上用场啦~ 为了应对产品变化无穷的需求,咱们早就对日历的样式作了可配置化。

来看看效果咋样:

5. 写在最后

看着眼前的这个demo,也算是收获不小,既接触了RN的手势系统,还涨了一波组件的优化经验,甚至还用到了二分查找~ 嘿嘿嘿,美滋滋~

老规矩,本文代码地址:

github.com/SmallStoneS…

相关文章
相关标签/搜索