RN Custom Component Package-Drag Calendar to Select Date

  calendar, javascript, react-native, react.js

1. Preface

Since I recently received a calendar requirement that needs to support dragging and selecting dates, the experience and effect are not bad, so today I want to share with you the process of packaging this calendar component.

2. Start of research

As the saying goes, “sharpening knives and cutting firewood at the same time”, since we have to make a calendar, let’s first see what we want to make in the end:

calendar-example.png

Since I have suffered from RN’s poor performance on Android before, I deeply doubt whether this product can run on Android, especially if the date changes in real time with the sliding position of the finger. And this involves gesture system, which has not been tampered with before. Who knows if there is any sinkhole waiting for me. . .

Alas, no matter what, let’s consider the simplest style first ~

But! As the saying goes, “It is impossible for a skillful woman to cook without rice”. How can one draw a calendar without corresponding calendar data? So, let’s do it first.

2.1 Calendar Data

Q1: How to Determine Which Days of Data to Render in Calendar?

Looking closely at the previous diagram, we can find that some days in the calendar are dark and some are highlighted. In other words, the grids rendered on the calendar are different from available/unavailable. To this end, we can support two ways of incoming through props:

  1. The caller specifies fullDateRange and availableDateRange. FullDateRange is the first day of the starting month to the last day of the ending month, and availableDateRange is the first day to the last day of the user selectable range.
  2. The caller specifies maxDays. That is, today is the first day of the availableDateRange, and today +maxDays is the last day of the availableDateRange; FullDateRange is the first day of today’s month to the last day of today’s +maxDays month.

After clarifying the thinking, let’s look at the code implementation:

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: How is the structure of Calendar Data better designed?

After the previous step, we already know which day needs to be rendered. Next, let’s look at how the data structure should be designed:

  1. First of all, the data for each month are actually similar, except for the days included. Therefore, we can use a map object to store, key is the string of year-month and value is the corresponding data for this month. In this way, not only can the year and year be used as special identifiers to distinguish each other, but also the corresponding days data can be quickly located according to the given year and year information.
  2. Looking at the data structure of day, we can first define several basic attributes for it: date, available, status. Among them, status represents the current status of the date, which is mainly used to distinguish whether the date is selected or not when the user drags the calendar.

Let’s look at how the corresponding code should be implemented:

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;
}

This is how the calendar data is generated. It seems quite easy. Let’s log and see what it looks like.

calendar-data

2.2 Calendar Style

In fact, style is the easiest link, mainly to properly disassemble the contents of the calendar.

  1. First, we can split it into renderHeader and renderBody. Among them, header is the information of the week above, and body is the main content composed of multiple months.
  2. Secondly, each month can be split into renderMonthHeader and renderMonthBody. Among them, monthHeader displays the corresponding year and month information, and monthBody is the date information for this month. (PS: one thing that can be tricky is the monthBody part, which we can implement with the attribute numColumns of FlatList, as long as it is set to 7. )
  3. Finally, we can use renderDay to render the information of each date. It should be noted that each Day may have five different states (none, single _ chosen, range _ begin _ chosen, range _ middle _ chosen, range _ end _ chosen), so different corresponding styles are required to correspond.

In addition, one more thing is that the extensibility of the calendar component must be considered, and the style can definitely be customized by the caller. To this end, we can write code:

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>
    );
  }
}

calendar-style

2.3 Implementation of Drag and Drop

Breathe ~ sigh of relief, the long March finally took the first step, the next step is to realize drag and drop. To achieve drag and drop, we can go through the following process:

  1. Acquiring layout information of all dates in all calendars and real-time coordinate information touched by fingers;
  2. According to the coordinate information where the finger is currently located, the date on which the finger falls is calculated, that is, the currently selected date;
  3. Compare the selected date information before and after. If it is different, update state and trigger render to render again.

To this end, let’s solve each problem one by one:

2.3.1 Obtain relevant layout and coordinate information

Get related layouts:
In RN, there are two methods to obtain the layout information of an element. One is onLayout, and the other is UIManager.measure Reasonable, both methods can meet our needs, but through UIManager.measure, our code here can be more elegant. The specific code is as follows:

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)}
      />
    );
  }

  // ...
}

By encapsulating UIManager.measure with a layer of promise, we can skillfully use Promise.all to know when all month elements have been rendered, and then we can proceed to the next dayLayouts calculation. However, if you use the onLayout method, it is different. Because onLayout is triggered asynchronously, the sequence of its calls cannot be guaranteed, and it is not known when all month has been rendered. Unless we add another counter, when the number of onLayout triggers (the value of the counter) is equal to the number of Months, we can know that all Months have been rendered. However, compared with the former method, it must be more elegant ~

Get coordinate information of finger touch:
The grand finale is finally coming! In RN, a gesture system encapsulates a wealth of gesture-related operations, and related documents can be stampedHere.

First of all, let’s think about such a problem. Since the contents of the calendar are wrapped in ScrollView, our normal dragging operation will cause ScrollView contents to scroll up and down. Then the question arises, how should we distinguish this up-and-down dragging operation, should we make the content scroll up and down, or should we select a different calendar range?

Here, my solution is to cover the upper layer of ScrollView with two transparent views, and then add gesture processing system to this layer of View. Since the finger touches the View and does not cause ScrollView to scroll, the above problem is perfectly avoided.

However, if this method is used, there will be another problem. Because the transparent View is the absolute positioning layout, the left and top values are the coordinate information of the currently selected date. However, when ScrollView scrolls up and down, this transparent View will also follow, that is, change its top value and refresh the current component in the onScroll event. Let’s look at how the specific code is implemented:

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}]}
        />
      ];
    }
  }

  // ...
}

Note: startDate and endDate in state are the first and last days of the currently selected time range. As there is no value at present, there is no effect at present.

Next, we will implement the most important _initPanResponder method. PanResponder provides many callbacks, here, we mainly use only five:

  1. OnStartShouldSetPanResponder: Apply to be a responder at the beginning;
  2. OnMoveShouldSetPanResponder: Apply to be a responder when moving;
  3. OnPanResponderGrant: Start gesture operation;
  4. OnPanResponderMove: moving;
  5. OnPanResponderRelease: Finger Release and Gesture Operation End.

In addition, the above callback functions all carry two parameters: event and gestureState, which contain very important information. Here, we mainly use is:

event.nativeEvent:

  1. LocationX: the abscissa of the touch point relative to the parent element
  2. LocationY: the ordinate of the touch point relative to the parent element

gestureState:

  1. Dx: Cumulative Lateral Distance from Touch Operation
  2. Dy: Cumulative Longitudinal Distance from the Beginning of Touch Operation

Therefore, we can record the coordinates of the next starting finger in onPanResponderGrant, then obtain deltaX and deltaY in onPanResponderMove, and add them to obtain the real-time coordinates of the current finger. Let’s look at the code:

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 = {};
  }

  // ...
}

We randomly add a value to startDate and endDate in state, and add a semitransparent red color to draggableArea to test whether our gesture operation works.

Yi ~ How does console get the wrong value? The printed (x, y) looks like the coordinates relative to draggableArea, not the coordinates of the entire ScrollView. However, this is all right because we know the left and top values of the draggableArea, so just add it. We can do this in the onTouchStart function, and at the same time we can distinguish whether the current finger touches the first day or the last day of the selected time range. The code is as follows:

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 Coordinate information is converted into date information

According to the above steps, we have successfully obtained the real-time coordinates of the current finger touch. Therefore, the next step is to convert the coordinate into which date it falls, so as to judge whether the selected date has changed.

This step is as simple as saying it is simple, and it can be complicated if you want to be complicated. Simply put. Our THIS. _ Day layout saves all this._dayLayouts. We only need to traverse to determine whether the finger coordinates fall within the range of a certain Day. Complex, is to reduce the number of unnecessary comparison. However, we still give priority to the implementation of functions first, and the optimization steps will be described later. The implementation code is as follows:

// 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 Select the date before and after comparison to trigger rendering

After the previous positionToDate, we know on which day the current finger falls. Next, compare the current new selection date with the old selection date before dragging to see if there is any change.

Special attention:If we touch the finger on start at the beginning, but the date when the finger stops after dragging is already greater than the date on end; Or vice versa, touch on end at the beginning, and the date on which the finger stays after dragging is smaller than the date on start. In this special case, pressStart and pressEnd have actually changed, so special treatment is required. Let’s look at how the code is written:

// 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();
  }
}

The _updateDayStatus function needs a little explanation here:
We use FlatList in renderMonthBody. Because FlatList is a pure component, it will only render again when props changes. Although we updated calendarData in _updateDayStatus, it is actually the same object. Therefore, the data assigned to renderMonthBody will also be the same object. For this reason, when updating the status of Day, we use a flag to indicate whether there is a change in the status of the date in the month. If there is a change, we will use Object.assign to copy a new object. In this way, the month with changed status will be re-rendered, while the month without changed status will not, which is a performance optimization instead.

2.4 Other

In fact, we have already implemented the basic dragging operation above. However, there are still some minor problems left over:

  1. When the user clicks on the date of the non-selected time period, the currently selected date should be reset.
  2. When the date on which the user’s finger stays is unavailable (i.e. inoperable), the date should not be selected;
  3. The component should support selecting the time range specified in props during initialization;
  4. When the finger slides to the blank area at the beginning/end of the month, it can also respond to the selected beginning/end of the month.

Of course, the above problems are all details. Considering the reasons of space, we will not go into details. . .

But! The problem of performance optimization must be addressed! This is because the things that have been made so far can perform well on ios, but when dragging on android, they will feel a little stuck. Especially on machines with poor performance, the feeling of Catton is even more obvious. . .

3. Performance optimization

We all know that the optimization of react performance is largely due to its powerful DomDiff, through which dom operations can be reduced. But too much DomDiff is also a drain, so how to reduce unnecessary DomDiff? The answer is to use the shouldComponentUpdate function correctly, but we still have to find out what is meaningless DomDiff first.

For this reason, we can type a log in all the _renderXXX functions we write, which components are always render when the finger drags?

After tests, it can be found that the functions, _renderMonth, _renderMonthHeader, _renderMonthBody and _renderDay will trigger many times each time the selected date changes. The reason is very simple. When the election date changes, we updated clendarData through setState, which triggered the entire calendar to be render again. Therefore, every month will be rendered again, and the corresponding render functions will be triggered again.

3.1 reduce DomDiff for renderMonth

Now that the source has been found, we can prescribe the right medicine. In fact, it is also simple. We only need to update the month in which the status changes each time, and the DomDiff process can be omitted for other months.

But! ! ! One disadvantage of this solution is that the variable changingMonth needs to be maintained. Every time a finger drags, we have to calculate which months have changed state. After the finger is released, changingMonth has to be reset. Moreover, the operation logic of this component is relatively simple now. If the interaction logic becomes more and more complex in the future, the maintenance cost will continue to rise. . .

So, we can change our thinking ~ doesn’t month always DomDiff? It doesn’t matter, I encapsulate the subcomponents in month into PureComponent, so the DomDiff process of the component will be optimized. Therefore, even if month is rendered every time, unnecessary DomDiff operations will be greatly reduced. While _renderMonthBody uses FlatList, which is already a pure component, so it has achieved certain optimization effect, otherwise _renderDay will trigger more times. Therefore, all we have to do is transform _renderMonthHeader into a pure component. Let’s look at the code:

// 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 reduce DomDiff for renderDay

According to the previous experimental results, we can actually find that every time a month is rendered, all DayItem in that month will be rendered again. But in fact, only DayItem with changed state needs to be re-rendered. Therefore, this gives us room for optimization, which can further reduce unnecessary DomDiff.

The above example has proved that PureComponent is a perfect optimization tool ~ So, let’s continue to transform _renderDay into a pure component and look at the code:

// 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 reduce the number of lookups for positionToDate

After the above two steps, some of the DomDiff overhead has been reduced. What else can be optimized? Remember the positionToDate function mentioned earlier? At present, we convert coordinates into dates through traversal, and the time complexity is O(n), so there is still room for optimization. So how to optimize it?

At this time, the previously learned algorithm is finally useful. haha ~ since the date layout in the calendar is very regular, it increases from left to right. From top to bottom, it is also increasing. So~ we can use binary search to reduce the number of searches and reduce the time complexity to O(nlog2). However, in this case, how should we use two points?

As a matter of fact, we can use two points three times:

  1. Because the vertical direction of Month is increasing and the vertical coordinate y is also increasing, the current finger is first positioned in which month by using two points.
  2. In the same month, the horizontal abscissa x increases gradually, so use one more bisection to locate the current finger on the day of the week.
  3. In the same month, the ordinate Y in the vertical direction increases gradually, and the current finger can be positioned on which day by one second.

The train of thought already exists, but our this._dayLayouts is an object and cannot be operated. So, we need to do a layer of conversion, let’s call it index, so it looks foreign ~ ~ ~ look at the code:

// 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])
    }));
  }

  // ...
}

calendar-day-layouts-index

From the index results printed above, we can see that the process of establishing the index mainly involves two things:

  1. The upper, lower, left and right borders of each month are saved, so that the current month in which the finger falls can be quickly found with two points.
  2. The original one-dimensional dayLayouts is converted into a two-dimensional array, which is consistent with the display method of the calendar, and is also for the convenience of binary search.

Next, look at the binary search code:

// 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;
  }
  // ...
};

Let’s take an example to see the optimization effect: if the rendered calendar data has 6 months of content, that is 180 days. In the worst case, it would have taken 180 searches to get results. And now? At most 3 times in the month, row can be determined at most 3 times, col can be determined at most 3 times, that is, the result can be found at most 9 times.

Aha ~ it’s just Wen mei ~ let’s look at the effect of finger dragging. there’s no feeling of being stuck. mom doesn’t have to worry about RN’s performance on android anymore ~

4. Actual combat

It took so much effort to package the components and optimize the performance, and now it can finally be used ~ in order to meet the changing needs of products, we have already made the calendar configurable.

Let’s take a look at the effect:

calendar-demo

5. Write at the end

Looking at the demo in front of me, it was a big gain. It not only came into contact with RN’s gesture system, but also gained a wave of optimization experience of components. It even used two-point search ~ Gnome male, alacrity ~

As usual, this article code address:

https://github.com/SmallStoneSK/react-native-draggable-calendar