成人国产在线小视频_日韩寡妇人妻调教在线播放_色成人www永久在线观看_2018国产精品久久_亚洲欧美高清在线30p_亚洲少妇综合一区_黄色在线播放国产_亚洲另类技巧小说校园_国产主播xx日韩_a级毛片在线免费

資訊專欄INFORMATION COLUMN

RN自定義組件封裝 - 拖拽選擇日期的日歷

ivydom / 3386人閱讀

摘要:前言由于最近接到一個需要支持拖拽選擇日期的日歷需求,做出來感覺體驗和效果都還不錯,所以今天想跟大家分享一下封裝這個日歷組件的過程。其中,代表該日期當前的狀態(tài),主要是用以區(qū)分用戶在拖拽操作日歷時,有沒有選中該日期。

1. 前言

由于最近接到一個需要支持拖拽選擇日期的日歷需求,做出來感覺體驗和效果都還不錯,所以今天想跟大家分享一下封裝這個日歷組件的過程。

2. 調(diào)研開始

正所謂“磨刀不誤砍柴工”,既然要做一個日歷,那么先讓我們來看看最終想要做成什么樣:

由于之前吃過RN在安卓上性能表現(xiàn)不佳的虧,深深地懷疑這東西做出來能在安卓上跑么,尤其是日期要實時地隨著手指滑動的位置發(fā)生變化。還有這牽涉到了手勢系統(tǒng),之前又沒搗鼓過,誰知道有沒有什么天坑在等著我。。。

唉,不管了,先把最簡單的樣式實現(xiàn)了再考慮這些吧~

But! 正所謂“巧婦難為無米之炊”,沒有相應(yīng)的日歷數(shù)據(jù),怎么畫日歷!So, let"s do it first.

2.1 日歷數(shù)據(jù)

Q1:如何確定日歷要渲染哪些天的數(shù)據(jù)?

仔細觀察先前的示意圖,我們可以發(fā)現(xiàn)日歷中有些天是暗的,有些是高亮的。也就是說日歷上所渲染出來的這些格子,是有available/unavailable區(qū)別的。為此,我們可以支持兩種方式通過props傳入:

調(diào)用方指定fullDateRange和availableDateRange。fullDateRange是起始月份第一天到終止月份最后一天,availableDateRange是用戶可選范圍第一天到最后一天。

調(diào)用方指定maxDays。也就是今天是availableDateRange的第一天,而今天+maxDays是availableDateRange的最后一天;fullDateRange則是今天所在月份的第一天到今天+maxDays所在月份的最后一天。

理清了思路,我們來看看代碼實現(xiàn):

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的結(jié)構(gòu)怎么設(shè)計比較好?

經(jīng)過上一步,我們已經(jīng)知曉了哪些day是需要渲染的,接下來我們再看看數(shù)據(jù)結(jié)構(gòu)應(yīng)該怎么設(shè)計:

首先,每個月份的數(shù)據(jù)其實是相似的,無非就是包括了有哪些天。因此,我們可以用一個map對象來存儲,key就是year-month組成的字符串,value就是這個月份相對應(yīng)的數(shù)據(jù)。這樣既能利用年月作為特殊標志符彼此區(qū)分,還能根據(jù)給定的年月信息快速定位到相應(yīng)的days數(shù)據(jù)。

再來看day的數(shù)據(jù)結(jié)構(gòu),我們可以先給它定義幾個基礎(chǔ)屬性:date、available、status。其中,status代表該日期當前的狀態(tài),主要是用以區(qū)分用戶在拖拽操作日歷時,有沒有選中該日期。

我們再來看看相應(yīng)的代碼應(yīng)該如何實現(xiàn):

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

生成日歷數(shù)據(jù)就這樣大功告成啦,貌似還挺容易的嘛~ 我們來打個log看看長什么樣:

2.2 日歷樣式

其實樣式這個環(huán)節(jié),倒是最容易的,主要是對日歷的內(nèi)容進行合適的拆解。

首先,我們可以拆分為renderHeader和renderBody。其中,header是上方的周幾信息,body則是由多個月份組成的主體內(nèi)容。

其次,每個月份由又可以拆分成renderMonthHeader和renderMonthBody。其中,monthHeader展示相應(yīng)的年月信息,monthBody則是這個月的日期信息。(PS: 有一點可以取巧的是monthBody部分,我們可以用FlatList的numColumns這個屬性實現(xiàn),只要設(shè)置成7就行。)

最后,我們可以用renderDay來渲染每個日期的信息。需要注意的是,每個Day可能有5種不同的狀態(tài)(NONE, SINGLE_CHOSEN, RANGE_BEGIN_CHOSEN, RANGE_MIDDLE_CHOSEN, RANGE_END_CHOSEN),所以需要不同的相應(yīng)樣式來對應(yīng)。

除此之外,還有一點就是一定要考慮該日歷組件的可擴展性,樣式方面肯定是可以讓調(diào)用方可自定義啦。為此,代碼方面我們可以這么寫:

export class DraggableCalendar extends Component {

  // ...

  _renderHeader() {
    const {headerContainerStyle, headerTextStyle} = this.props;
    return (
      
        {["日", "一", "二", "三", "四", "五", "六"].map(item => (
          {item}
        ))}
      
    );
  }

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

  _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 (
      
        {renderMonthHeader ?
          renderMonthHeader(identifier) :
          {`${parseInt(year)}年${parseInt(month)}月`}
        }
      
    );
  }

  _renderMonthBody({identifier, data, index}) {
    return (
       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 (
      
        {renderDay ?
          renderDay(item, index) :
          
            {item.date && (
              
                {item.date.getDate()}
              
            )}
          
        }
      
    );
  }

  render() {
    const {style} = this.props;
    return (
      
        {this._renderHeader()}
        {this._renderBody()}
      
    );
  }
}

2.3 實現(xiàn)拖拽

呼~ 長吁一口氣,萬里長征終于邁出了第一步,接下來就是要實現(xiàn)拖拽了。而要實現(xiàn)拖拽,我們可以通過大致以下流程:

獲得所有日歷中所有日期的布局信息,和手指觸摸的實時坐標信息;

根據(jù)手指當前所在的坐標信息,計算出手指落在哪個日期上,也就是當前選中的日期;

比較前后的選中日期信息,如果不同,更新state,觸發(fā)render重新渲染。

為此,我們來逐一解決各個問題:

2.3.1 獲取相關(guān)布局和坐標信息

獲取相關(guān)布局:
在RN中,有兩種方法可以獲取一個元素的布局信息。一個是onLayout,還有一個就是UIManager.measure。講道理,兩種方法都能實現(xiàn)我們的需求,但是通過UIManager.measure,我們這里的代碼可以更優(yōu)雅。具體代碼如下:

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 (
       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元素都已經(jīng)渲染完畢,然后可以進行下一步的dayLayouts計算。但是,如果使用onLayout方法就不一樣了。由于onLayout是異步觸發(fā)的,所以沒法保證其調(diào)用的先后順序,更是不知道什么時候所有的month都渲染完畢了。除非,我們再額外加一個計數(shù)器,當onLayout觸發(fā)的次數(shù)(計數(shù)器的值)等于month的個數(shù),這樣才能知道所有month渲染完畢。不過相比于前一種方法,肯定是前一種更優(yōu)雅啦~

獲取手指觸摸的坐標信息:
重頭戲終于要來啦!在RN中,有一個手勢系統(tǒng)封裝了豐富的手勢相關(guān)操作,相關(guān)文檔可以戳這里。

首先我們來思考這么個問題,由于日歷的內(nèi)容是用ScrollView包裹起來的,因此我們正常的上下拖動操作會導(dǎo)致ScrollView內(nèi)容上下滾動。那么問題就來了,我們應(yīng)該怎么區(qū)分這個上下拖動操作,是應(yīng)該讓內(nèi)容上下滾動,還是選中不同的日歷范圍呢?

在這里,我采用的解決方案是用兩個透明的View蓋在ScrollView上層,然后把手勢處理系統(tǒng)加在這層View上。由于手指是觸摸在View上,并不會導(dǎo)致ScrollView滾動,因此完美地規(guī)避了上面這個問題。

不過,如果用這種方法會有另外一個問題。因為透明的View是采用的絕對定位布局,left和top值是當前選中日期的坐標信息。但是當ScrollView上下發(fā)生滾動時,這層透明View也要跟著動,也就是在onScroll事件中改變其top值,并刷新當前組件。我們來看看具體代碼是怎么實現(xiàn)的:

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 (
      
        
          {Object
            .keys(calendarData)
            .map((key, index) => this._renderMonth({identifier: key, data: calendarData[key], index}))
          }
        
        {this._renderDraggableArea()}
      
    );
  }

  _renderDraggableArea() {
    const {startDate, endDate} = this.state;
    if(!startDate || !endDate) {
      return null;
    } else {
      const isSingleChosen = startDate.getTime() === endDate.getTime();
      return [
        ,
        
      ];
    }
  }

  // ...
}

注意:state中的startDate和endDate是當前選中時間范圍的第一天和最后一天。由于現(xiàn)在都還沒有值,所以目前看不出效果。

接下來,我們再實現(xiàn)最重要的_initPanResponder方法。PanResponder提供了很多回調(diào),在這里,我們主要用到的就只有5個:

onStartShouldSetPanResponder:開始的時候申請成為響應(yīng)者;

onMoveShouldSetPanResponder:移動的時候申請成為響應(yīng)者;

onPanResponderGrant:開始手勢操作;

onPanResponderMove:移動中;

onPanResponderRelease:手指放開,手勢操作結(jié)束。

除此之外,以上的回調(diào)函數(shù)都會攜帶兩個參數(shù):event和gestureState,它們中包含了非常重要的信息。在這里,我們主要用到的是:

event.nativeEvent:

locationX: 觸摸點相對于父元素的橫坐標

locationY: 觸摸點相對于父元素的縱坐標

gestureState:

dx: 從觸摸操作開始時的累計橫向路程

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這個函數(shù)中做這件事,同時還可以區(qū)分當前手指觸摸的是選中時間范圍內(nèi)的第一天還是最后一天。代碼如下:

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 [
         this._onTouchStart("start", startDate)}
          style={[styles.dragContainer, this._genDraggableAreaStyle(startDate)]}
        />,
         this._onTouchStart("end", endDate)}
          style={[styles.dragContainer, this._genDraggableAreaStyle(endDate), isSingleChosen && {height: 0}]}
        />
      ];
    }
  }

  // ...
}
2.3.2 坐標信息轉(zhuǎn)換成日期信息

根據(jù)上面的步驟,我們已經(jīng)成功地獲取到了當前手指觸摸的實時坐標。所以,接下來就是把該坐標轉(zhuǎn)換成落在哪個日期上,從而可以判斷出選中日期是否發(fā)生變化。

這一步,說簡單也簡單,要想復(fù)雜那也可以復(fù)雜。簡單來看。我們的this._dayLayouts保存了所有Day的layout,我們只需要進行遍歷,判斷手指坐標有沒有落在某個Day的范圍當中即可。復(fù)雜來講,就是減少不必要的比較次數(shù)。不過,我們還是先實現(xiàn)功能為主,優(yōu)化步驟在后面介紹。實現(xiàn)代碼如下:

// 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 對比前后選中日期,觸發(fā)渲染

經(jīng)過上一步的positionToDate,我們知道了當前手指落在哪一天上。接下來,就是比較當前新的選中日期和拖動之前舊的選中日期,看看有沒有發(fā)生變化。

特別注意:假如我們一開始手指是觸摸在start上,但是拖動之后手指停留的日期已經(jīng)大于end上的日期;或者反過來,一開始觸摸在end上,拖動之后手指停留的日期小于start上的日期。這種特殊情況下,pressStart和pressEnd其實發(fā)生了變化,所以需要特殊處理。我們來看看代碼是怎么寫的:

// 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函數(shù)進行稍加解釋:
我們在renderMonthBody用的是FlatList,由于FlatList是純組件,所以只有當props發(fā)生變化時,才會重新渲染。雖然我們在_updateDayStatus中更新了calendarData,但其實是同一個對象。所以,分配給renderMonthBody的data也會是同一個對象。為此,我們在更新Day的status時用一個flag來表示該月份中是否有日期的狀態(tài)發(fā)生變化,如果發(fā)生變化,我們會用Object.assign來復(fù)制一個新的對象。這樣一來,狀態(tài)發(fā)生變化的月份會重新渲染,而沒有發(fā)生變化的月份不會,這反而算是一個性能上的優(yōu)化吧。

2.4 其他

其實,上面我們已經(jīng)實現(xiàn)了基本的拖拽操作。但是,還有一些遺留的小問題:

用戶點選非選中時間段的日期,應(yīng)該重置當前選中日期;

用戶手指停留的日期是unavailable(即不可操作的)時,該日期不應(yīng)該被選中;

組件應(yīng)支持在初始化的時候選中props中指定的一段時間范圍;

手指在滑動到月初/月末空白區(qū)域時,也能響應(yīng)選中月初/月末;

...

當然了,上面的這些問題都是細節(jié)問題,考慮篇幅原因,就不再詳述了。。。

但是!性能優(yōu)化問題是肯定要講的!因為,就目前做出來的這東西在ios上表現(xiàn)還可以,但是在android上拖動的時候,會有一點卡頓感。尤其是在性能差的機子上,卡頓感就更明顯了。。。

3. 性能優(yōu)化

我們都知道,react性能上的優(yōu)化很大程度上得益于其強大的DomDiff,通過它可以減少dom操作。但是過多的DomDiff也是一個消耗,所以怎么減少無謂的DomDiff呢?答案是正確地使用shouldComponentUpdate函數(shù),不過我們還是得首先找出哪些是無謂的DomDiff。

為此,我們可以在我們寫的所有_renderXXX函數(shù)中打一個log,在手指拖動的時候,都有哪些組件一直在render?

經(jīng)過試驗,可以發(fā)現(xiàn)每次選中日期發(fā)生變化的時候,_renderMonth,_renderMonthHeader,_renderMonthBody和_renderDay這幾個函數(shù)會觸發(fā)很多次。原因很簡單,當選中日期發(fā)生變化時,我們通過setState更新了clendarData,從而觸發(fā)了整個日歷重新render。因此,每個month都會重新渲染,相應(yīng)的這幾個render函數(shù)都會觸發(fā)一遍。

3.1 減少renderMonth的DomDiff

既然源頭已經(jīng)找到,我們就可以對癥下藥了。其實也簡單,我們每次只要更新狀態(tài)發(fā)生變化的月份就可以,其他的月份可以省略其DomDiff過程。

但是!?。∵@個解決方案有一個弊端,就是需要維護changingMonth這個變量。每次手指拖動操作的時候,我們都得計算出哪些月份是發(fā)生狀態(tài)變化的;手指釋放之后,又得重置changingMonth。而且,現(xiàn)在這個組件的操作邏輯相對來說還比較簡單,如果交互邏輯往后變得越來越復(fù)雜,那這個維護成本會繼續(xù)上升。。。

所以,我們可以換個思路~ month不是每次都會DomDiff嗎?沒關(guān)系,我把month中的子組件封裝成PureComponent,這樣子組件的DomDiff過程是會被優(yōu)化掉的。所以,即使每次渲染month,也會大大減少無謂的DomDiff操作。而_renderMonthBody用的是FlatList,這已經(jīng)是純組件了,所以已經(jīng)起到一定的優(yōu)化效果,不然_renderDay的觸發(fā)次數(shù)會更多。因此,我們要做的只是把_renderMonthHeader改造成純組件就好了。來看看代碼:

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

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _renderMonthHeader({identifier}) {
    const {monthHeaderTextStyle, renderMonthHeader} = this.props;
    return (
      
    );
  }
}
3.2 減少renderDay的DomDiff

根據(jù)前面的試驗結(jié)果,其實我們可以發(fā)現(xiàn)每次渲染月份的時候,這個月份中的所有DayItem都會被渲染一遍。但實際上只需要狀態(tài)發(fā)生變化的DayItem重新渲染即可。所以,這又給了我們優(yōu)化的空間,可以進一步減少無謂的DomDiff。

上面的例子已經(jīng)證明PureComponent是再好不過的優(yōu)化利器了~ 所以,我們繼續(xù)把_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 (
      
        {renderDay ?
          renderDay(data) :
          
            {data.date && (
              
                {data.date.getDate()}
              
            )}
          
        }
      
    );
  }
}

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _renderDay(item, index) {
    const styleKeys = [
      "dayTextStyle", "selectedDayTextStyle",
      "dayContainerStyle", "singleDayContainerStyle",
      "beginDayContainerStyle", "middleDayContainerStyle", "endDayContainerStyle"
    ];
    return (
       this.props[key])}
      />
    );
  }
}
3.3 減少positionToDate的查找次數(shù)

經(jīng)過上面兩步,已經(jīng)減緩了一部分的DomDiff開銷了。那還有什么可以優(yōu)化的呢?還記得前文提到的positionToDate函數(shù)么?目前我們是通過遍歷的方式將坐標轉(zhuǎn)換成日期的,時間復(fù)雜度是O(n),所以這里還有優(yōu)化的空間。那么又該怎么優(yōu)化呢?

這時以前學的算法是終于有用武之地了,哈哈~ 由于日歷中的日期排版很有規(guī)律,從左到右看,都是遞增的;從上到下看,也是遞增的。so~ 我們可以用二分查找來減少這個查找次數(shù),將時間復(fù)雜度降到O(nlog2)。不過,在這個case中,我們應(yīng)當如何使用二分呢?

其實,我們可以使用3次二分:

因為Month垂直方向上是遞增的,縱坐標y也是遞增的,所以先用二分定位到當前手指落在哪個月份中;

同一個月內(nèi),水平方向上橫坐標x是遞增的,所以再用一次二分定位到當前手指落在周幾上;

同一個月內(nèi),垂直方向上縱坐標y是遞增的,可以再用一次二分定位到當前手指落在哪天上。

思路已經(jīng)有了,可是我們的this._dayLayouts是一個對象,沒法操作。所以,我們需要做一層轉(zhuǎn)換,姑且就叫索引吧,這樣顯得洋氣~~~ 來看代碼:

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

  // ...
}

從上面打印出來的索引結(jié)果中,我們可以看到建立索引的過程主要是干了兩件事:

保存下了每個月的上下左右邊界,這樣就可以用二分快速找到當前手指落在哪個月份中了;

將原本一維的dayLayouts轉(zhuǎn)換成了二維數(shù)組,與日歷的展示方式保持一致,目的也是為了方便二分查找。

接下來再看看二分查找的代碼:

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

我們來舉個例子看看優(yōu)化的效果:假如渲染的日歷數(shù)據(jù)有6個月的內(nèi)容,也就是180天。最壞的情況下,原先需要查找180次才有結(jié)果。而現(xiàn)在呢?月份最多3次能確定,row最多3次能確定,col最多3次能確定,也就是最多9次就能找到結(jié)果。

啊哈~ 簡直是文美~ 再看看手指拖拽時的效果,絲毫沒有卡頓感,媽媽再也不用擔心RN在android上的性能效果啦~

4. 實戰(zhàn)

費了那么大勁兒,又是封裝組件,又是優(yōu)化性能的,現(xiàn)在終于可以能派上用場啦~ 為了應(yīng)對產(chǎn)品變化多端的需求,我們早就對日歷的樣式做了可配置化。

來看看效果咋樣:

5. 寫在最后

看著眼前的這個demo,也算是收獲不小,既接觸了RN的手勢系統(tǒng),還漲了一波組件的優(yōu)化經(jīng)驗,甚至還用到了二分查找~ 嘿嘿嘿,美滋滋~

老規(guī)矩,本文代碼地址:

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

文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。

轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/94739.html

相關(guān)文章

  • FullCalendar插件基本使用

    摘要:插件的依賴文件插件的使用頁面結(jié)構(gòu)設(shè)置日歷頭部信息,如果設(shè)置為,則不顯示頭部信息。設(shè)置日歷的高度,包括日歷頭部,默認未設(shè)置,高度根據(jù)值自適應(yīng)。 先說一下我的另一博客地址: https://home.cnblogs.com/u/bllx/ FullCalendar的選擇 前段時間,一直在開發(fā)考勤系統(tǒng),當時為滿足設(shè)計的需求,選了好幾個插件,最后決定采用Fullcanlendar的插件。感覺這...

    import. 評論0 收藏0

發(fā)表評論

0條評論

最新活動
閱讀需要支付1元查看
<