因爲最近接到一個須要支持拖拽選擇日期的日曆需求,作出來感受體驗和效果都還不錯,因此今天想跟你們分享一下封裝這個日曆組件的過程。javascript
正所謂「磨刀不誤砍柴工」,既然要作一個日曆,那麼先讓咱們來看看最終想要作成什麼樣:html
因爲以前吃過RN在安卓上性能表現不佳的虧,深深地懷疑這東西作出來能在安卓上跑麼,尤爲是日期要實時地隨着手指滑動的位置發生變化。還有這牽涉到了手勢系統,以前又沒搗鼓過,誰知道有沒有什麼天坑在等着我。。。java
唉,無論了,先把最簡單的樣式實現了再考慮這些吧~react
But! 正所謂「巧婦難爲無米之炊」,沒有相應的日曆數據,怎麼畫日曆!So, let's do it first.android
Q1:如何肯定日曆要渲染哪些天的數據?ios
仔細觀察先前的示意圖,咱們能夠發現日曆中有些天是暗的,有些是高亮的。也就是說日曆上所渲染出來的這些格子,是有available/unavailable區別的。爲此,咱們能夠支持兩種方式經過props傳入:git
理清了思路,咱們來看看代碼實現:github
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的結構怎麼設計比較好?算法
通過上一步,咱們已經知曉了哪些day是須要渲染的,接下來咱們再看看數據結構應該怎麼設計:react-native
咱們再來看看相應的代碼應該如何實現:
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看看長什麼樣:
其實樣式這個環節,卻是最容易的,主要是對日曆的內容進行合適的拆解。
除此以外,還有一點就是必定要考慮該日曆組件的可擴展性,樣式方面確定是可讓調用方可自定義啦。爲此,代碼方面咱們能夠這麼寫:
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> ); } }
呼~ 長吁一口氣,萬里長征終於邁出了第一步,接下來就是要實現拖拽了。而要實現拖拽,咱們能夠經過大體如下流程:
爲此,咱們來逐一解決各個問題:
獲取相關佈局:
在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個:
除此以外,以上的回調函數都會攜帶兩個參數:event和gestureState,它們中包含了很是重要的信息。在這裏,咱們主要用到的是:
event.nativeEvent:
gestureState:
所以,咱們能夠在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}]} /> ]; } } // ... }
根據上面的步驟,咱們已經成功地獲取到了當前手指觸摸的實時座標。因此,接下來就是把該座標轉換成落在哪一個日期上,從而能夠判斷出選中日期是否發生變化。
這一步,說簡單也簡單,要想複雜那也能夠複雜。簡單來看。咱們的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)); } }
通過上一步的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來複制一個新的對象。這樣一來,狀態發生變化的月份會從新渲染,而沒有發生變化的月份不會,這反而算是一個性能上的優化吧。
其實,上面咱們已經實現了基本的拖拽操做。可是,還有一些遺留的小問題:
...
固然了,上面的這些問題都是細節問題,考慮篇幅緣由,就再也不詳述了。。。
可是!性能優化問題是確定要講的!由於,就目前作出來的這東西在ios上表現還能夠,可是在android上拖動的時候,會有一點卡頓感。尤爲是在性能差的機子上,卡頓感就更明顯了。。。
咱們都知道,react性能上的優化很大程度上得益於其強大的DomDiff,經過它能夠減小dom操做。可是過多的DomDiff也是一個消耗,因此怎麼減小無謂的DomDiff呢?答案是正確地使用shouldComponentUpdate函數,不過咱們仍是得首先找出哪些是無謂的DomDiff。
爲此,咱們能夠在咱們寫的全部_renderXXX函數中打一個log,在手指拖動的時候,都有哪些組件一直在render?
通過試驗,能夠發現每次選中日期發生變化的時候,_renderMonth,_renderMonthHeader,_renderMonthBody和_renderDay這幾個函數會觸發不少次。緣由很簡單,當選中日期發生變化時,咱們經過setState更新了clendarData,從而觸發了整個日曆從新render。所以,每一個month都會從新渲染,相應的這幾個render函數都會觸發一遍。
既然源頭已經找到,咱們就能夠對症下藥了。其實也簡單,咱們每次只要更新狀態發生變化的月份就能夠,其餘的月份能夠省略其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} /> ); } }
根據前面的試驗結果,其實咱們能夠發現每次渲染月份的時候,這個月份中的全部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])} /> ); } }
通過上面兩步,已經減緩了一部分的DomDiff開銷了。那還有什麼能夠優化的呢?還記得前文提到的positionToDate函數麼?目前咱們是經過遍歷的方式將座標轉換成日期的,時間複雜度是O(n),因此這裏還有優化的空間。那麼又該怎麼優化呢?
這時之前學的算法是終於有用武之地了,哈哈~ 因爲日曆中的日期排版頗有規律,從左到右看,都是遞增的;從上到下看,也是遞增的。so~ 咱們能夠用二分查找來減小這個查找次數,將時間複雜度降到O(nlog2)。不過,在這個case中,咱們應當如何使用二分呢?
其實,咱們可使用3次二分:
思路已經有了,但是咱們的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]) })); } // ... }
從上面打印出來的索引結果中,咱們能夠看到創建索引的過程主要是幹了兩件事:
接下來再看看二分查找的代碼:
// 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上的性能效果啦~
費了那麼大勁兒,又是封裝組件,又是優化性能的,如今終於能夠能派上用場啦~ 爲了應對產品變化無窮的需求,咱們早就對日曆的樣式作了可配置化。
來看看效果咋樣:
看着眼前的這個demo,也算是收穫不小,既接觸了RN的手勢系統,還漲了一波組件的優化經驗,甚至還用到了二分查找~ 嘿嘿嘿,美滋滋~
老規矩,本文代碼地址:
https://github.com/SmallStoneSK/react-native-draggable-calendar