摘要:前言由于最近接到一個(gè)需要支持拖拽選擇日期的日歷需求,做出來(lái)感覺(jué)體驗(yàn)和效果都還不錯(cuò),所以今天想跟大家分享一下封裝這個(gè)日歷組件的過(guò)程。其中,代表該日期當(dāng)前的狀態(tài),主要是用以區(qū)分用戶在拖拽操作日歷時(shí),有沒(méi)有選中該日期。
1. 前言
由于最近接到一個(gè)需要支持拖拽選擇日期的日歷需求,做出來(lái)感覺(jué)體驗(yàn)和效果都還不錯(cuò),所以今天想跟大家分享一下封裝這個(gè)日歷組件的過(guò)程。
2. 調(diào)研開(kāi)始正所謂“磨刀不誤砍柴工”,既然要做一個(gè)日歷,那么先讓我們來(lái)看看最終想要做成什么樣:
由于之前吃過(guò)RN在安卓上性能表現(xiàn)不佳的虧,深深地懷疑這東西做出來(lái)能在安卓上跑么,尤其是日期要實(shí)時(shí)地隨著手指滑動(dòng)的位置發(fā)生變化。還有這牽涉到了手勢(shì)系統(tǒng),之前又沒(méi)搗鼓過(guò),誰(shuí)知道有沒(méi)有什么天坑在等著我。。。
唉,不管了,先把最簡(jiǎn)單的樣式實(shí)現(xiàn)了再考慮這些吧~
But! 正所謂“巧婦難為無(wú)米之炊”,沒(méi)有相應(yīng)的日歷數(shù)據(jù),怎么畫(huà)日歷!So, let"s do it first.
2.1 日歷數(shù)據(jù)Q1:如何確定日歷要渲染哪些天的數(shù)據(jù)?
仔細(xì)觀察先前的示意圖,我們可以發(fā)現(xiàn)日歷中有些天是暗的,有些是高亮的。也就是說(shuō)日歷上所渲染出來(lái)的這些格子,是有available/unavailable區(qū)別的。為此,我們可以支持兩種方式通過(guò)props傳入:
調(diào)用方指定fullDateRange和availableDateRange。fullDateRange是起始月份第一天到終止月份最后一天,availableDateRange是用戶可選范圍第一天到最后一天。
調(diào)用方指定maxDays。也就是今天是availableDateRange的第一天,而今天+maxDays是availableDateRange的最后一天;fullDateRange則是今天所在月份的第一天到今天+maxDays所在月份的最后一天。
理清了思路,我們來(lái)看看代碼實(shí)現(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ì)比較好?
經(jīng)過(guò)上一步,我們已經(jīng)知曉了哪些day是需要渲染的,接下來(lái)我們?cè)倏纯磾?shù)據(jù)結(jié)構(gòu)應(yīng)該怎么設(shè)計(jì):
首先,每個(gè)月份的數(shù)據(jù)其實(shí)是相似的,無(wú)非就是包括了有哪些天。因此,我們可以用一個(gè)map對(duì)象來(lái)存儲(chǔ),key就是year-month組成的字符串,value就是這個(gè)月份相對(duì)應(yīng)的數(shù)據(jù)。這樣既能利用年月作為特殊標(biāo)志符彼此區(qū)分,還能根據(jù)給定的年月信息快速定位到相應(yīng)的days數(shù)據(jù)。
再來(lái)看day的數(shù)據(jù)結(jié)構(gòu),我們可以先給它定義幾個(gè)基礎(chǔ)屬性:date、available、status。其中,status代表該日期當(dāng)前的狀態(tài),主要是用以區(qū)分用戶在拖拽操作日歷時(shí),有沒(méi)有選中該日期。
我們?cè)賮?lái)看看相應(yīng)的代碼應(yīng)該如何實(shí)現(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ù)就這樣大功告成啦,貌似還挺容易的嘛~ 我們來(lái)打個(gè)log看看長(zhǎng)什么樣:
2.2 日歷樣式其實(shí)樣式這個(gè)環(huán)節(jié),倒是最容易的,主要是對(duì)日歷的內(nèi)容進(jìn)行合適的拆解。
首先,我們可以拆分為renderHeader和renderBody。其中,header是上方的周幾信息,body則是由多個(gè)月份組成的主體內(nèi)容。
其次,每個(gè)月份由又可以拆分成renderMonthHeader和renderMonthBody。其中,monthHeader展示相應(yīng)的年月信息,monthBody則是這個(gè)月的日期信息。(PS: 有一點(diǎn)可以取巧的是monthBody部分,我們可以用FlatList的numColumns這個(gè)屬性實(shí)現(xiàn),只要設(shè)置成7就行。)
最后,我們可以用renderDay來(lái)渲染每個(gè)日期的信息。需要注意的是,每個(gè)Day可能有5種不同的狀態(tài)(NONE, SINGLE_CHOSEN, RANGE_BEGIN_CHOSEN, RANGE_MIDDLE_CHOSEN, RANGE_END_CHOSEN),所以需要不同的相應(yīng)樣式來(lái)對(duì)應(yīng)。
除此之外,還有一點(diǎn)就是一定要考慮該日歷組件的可擴(kuò)展性,樣式方面肯定是可以讓調(diào)用方可自定義啦。為此,代碼方面我們可以這么寫(xiě):
export class DraggableCalendar extends Component { // ... _renderHeader() { const {headerContainerStyle, headerTextStyle} = this.props; return (2.3 實(shí)現(xiàn)拖拽{["日", "一", "二", "三", "四", "五", "六"].map(item => ( ); } _renderBody() { const {calendarData} = this.state; return ({item} ))}{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) : ); } _renderMonthBody({identifier, data, index}) { return ({`${parseInt(year)}年${parseInt(month)}月`} }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) : ); } render() { const {style} = this.props; return ({item.date && ( }{item.date.getDate()} )}{this._renderHeader()} {this._renderBody()} ); } }
呼~(yú) 長(zhǎng)吁一口氣,萬(wàn)里長(zhǎng)征終于邁出了第一步,接下來(lái)就是要實(shí)現(xiàn)拖拽了。而要實(shí)現(xiàn)拖拽,我們可以通過(guò)大致以下流程:
獲得所有日歷中所有日期的布局信息,和手指觸摸的實(shí)時(shí)坐標(biāo)信息;
根據(jù)手指當(dāng)前所在的坐標(biāo)信息,計(jì)算出手指落在哪個(gè)日期上,也就是當(dāng)前選中的日期;
比較前后的選中日期信息,如果不同,更新state,觸發(fā)render重新渲染。
為此,我們來(lái)逐一解決各個(gè)問(wèn)題:
2.3.1 獲取相關(guān)布局和坐標(biāo)信息獲取相關(guān)布局:
在RN中,有兩種方法可以獲取一個(gè)元素的布局信息。一個(gè)是onLayout,還有一個(gè)就是UIManager.measure。講道理,兩種方法都能實(shí)現(xiàn)我們的需求,但是通過(guò)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)} /> ); } // ... }
通過(guò)給UIManager.measure封裝一層promise,我們可以巧妙地利用Promise.all來(lái)知道什么時(shí)候所有的month元素都已經(jīng)渲染完畢,然后可以進(jìn)行下一步的dayLayouts計(jì)算。但是,如果使用onLayout方法就不一樣了。由于onLayout是異步觸發(fā)的,所以沒(méi)法保證其調(diào)用的先后順序,更是不知道什么時(shí)候所有的month都渲染完畢了。除非,我們?cè)兕~外加一個(gè)計(jì)數(shù)器,當(dāng)onLayout觸發(fā)的次數(shù)(計(jì)數(shù)器的值)等于month的個(gè)數(shù),這樣才能知道所有month渲染完畢。不過(guò)相比于前一種方法,肯定是前一種更優(yōu)雅啦~
獲取手指觸摸的坐標(biāo)信息:
重頭戲終于要來(lái)啦!在RN中,有一個(gè)手勢(shì)系統(tǒng)封裝了豐富的手勢(shì)相關(guān)操作,相關(guān)文檔可以戳這里。
首先我們來(lái)思考這么個(gè)問(wèn)題,由于日歷的內(nèi)容是用ScrollView包裹起來(lái)的,因此我們正常的上下拖動(dòng)操作會(huì)導(dǎo)致ScrollView內(nèi)容上下滾動(dòng)。那么問(wèn)題就來(lái)了,我們應(yīng)該怎么區(qū)分這個(gè)上下拖動(dòng)操作,是應(yīng)該讓內(nèi)容上下滾動(dòng),還是選中不同的日歷范圍呢?
在這里,我采用的解決方案是用兩個(gè)透明的View蓋在ScrollView上層,然后把手勢(shì)處理系統(tǒng)加在這層View上。由于手指是觸摸在View上,并不會(huì)導(dǎo)致ScrollView滾動(dòng),因此完美地規(guī)避了上面這個(gè)問(wèn)題。
不過(guò),如果用這種方法會(huì)有另外一個(gè)問(wèn)題。因?yàn)橥该鞯腣iew是采用的絕對(duì)定位布局,left和top值是當(dāng)前選中日期的坐標(biāo)信息。但是當(dāng)ScrollView上下發(fā)生滾動(dòng)時(shí),這層透明View也要跟著動(dòng),也就是在onScroll事件中改變其top值,并刷新當(dāng)前組件。我們來(lái)看看具體代碼是怎么實(shí)現(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 (); } _renderDraggableArea() { const {startDate, endDate} = this.state; if(!startDate || !endDate) { return null; } else { const isSingleChosen = startDate.getTime() === endDate.getTime(); return [ {Object .keys(calendarData) .map((key, index) => this._renderMonth({identifier: key, data: calendarData[key], index})) } {this._renderDraggableArea()}, ]; } } // ... }
注意:state中的startDate和endDate是當(dāng)前選中時(shí)間范圍的第一天和最后一天。由于現(xiàn)在都還沒(méi)有值,所以目前看不出效果。
接下來(lái),我們?cè)賹?shí)現(xiàn)最重要的_initPanResponder方法。PanResponder提供了很多回調(diào),在這里,我們主要用到的就只有5個(gè):
onStartShouldSetPanResponder:開(kāi)始的時(shí)候申請(qǐng)成為響應(yīng)者;
onMoveShouldSetPanResponder:移動(dòng)的時(shí)候申請(qǐng)成為響應(yīng)者;
onPanResponderGrant:開(kāi)始手勢(shì)操作;
onPanResponderMove:移動(dòng)中;
onPanResponderRelease:手指放開(kāi),手勢(shì)操作結(jié)束。
除此之外,以上的回調(diào)函數(shù)都會(huì)攜帶兩個(gè)參數(shù):event和gestureState,它們中包含了非常重要的信息。在這里,我們主要用到的是:
event.nativeEvent:
locationX: 觸摸點(diǎn)相對(duì)于父元素的橫坐標(biāo)
locationY: 觸摸點(diǎn)相對(duì)于父元素的縱坐標(biāo)
gestureState:
dx: 從觸摸操作開(kāi)始時(shí)的累計(jì)橫向路程
dy: 從觸摸操作開(kāi)始時(shí)的累計(jì)縱向路程
因此,我們可以在onPanResponderGrant記錄下一開(kāi)始手指的坐標(biāo),然后在onPanResponderMove中獲取deltaX和deltaY,相加之后就得到當(dāng)前手指的實(shí)時(shí)坐標(biāo)。一起來(lái)看下代碼:
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隨意加個(gè)值,并給draggableArea加個(gè)半透明的紅色來(lái)測(cè)試下,我們的手勢(shì)操作到底有沒(méi)有起作用。
咦~ 怎么console得到的值看起來(lái)好像不太對(duì)。打印出來(lái)的(x, y)像是相對(duì)draggableArea的坐標(biāo),而不是整個(gè)ScrollView的坐標(biāo)。不過(guò)這也好辦,因?yàn)槲覀冎纃raggableArea的left和top值,所以加上就好了。我們可以在onTouchStart這個(gè)函數(shù)中做這件事,同時(shí)還可以區(qū)分當(dāng)前手指觸摸的是選中時(shí)間范圍內(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 [2.3.2 坐標(biāo)信息轉(zhuǎn)換成日期信息this._onTouchStart("start", startDate)} style={[styles.dragContainer, this._genDraggableAreaStyle(startDate)]} />, this._onTouchStart("end", endDate)} style={[styles.dragContainer, this._genDraggableAreaStyle(endDate), isSingleChosen && {height: 0}]} /> ]; } } // ... }
根據(jù)上面的步驟,我們已經(jīng)成功地獲取到了當(dāng)前手指觸摸的實(shí)時(shí)坐標(biāo)。所以,接下來(lái)就是把該坐標(biāo)轉(zhuǎn)換成落在哪個(gè)日期上,從而可以判斷出選中日期是否發(fā)生變化。
這一步,說(shuō)簡(jiǎn)單也簡(jiǎn)單,要想復(fù)雜那也可以復(fù)雜。簡(jiǎn)單來(lái)看。我們的this._dayLayouts保存了所有Day的layout,我們只需要進(jìn)行遍歷,判斷手指坐標(biāo)有沒(méi)有落在某個(gè)Day的范圍當(dāng)中即可。復(fù)雜來(lái)講,就是減少不必要的比較次數(shù)。不過(guò),我們還是先實(shí)現(xiàn)功能為主,優(yōu)化步驟在后面介紹。實(shí)現(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 對(duì)比前后選中日期,觸發(fā)渲染
經(jīng)過(guò)上一步的positionToDate,我們知道了當(dāng)前手指落在哪一天上。接下來(lái),就是比較當(dāng)前新的選中日期和拖動(dòng)之前舊的選中日期,看看有沒(méi)有發(fā)生變化。
特別注意:假如我們一開(kāi)始手指是觸摸在start上,但是拖動(dòng)之后手指停留的日期已經(jīng)大于end上的日期;或者反過(guò)來(lái),一開(kāi)始觸摸在end上,拖動(dòng)之后手指停留的日期小于start上的日期。這種特殊情況下,pressStart和pressEnd其實(shí)發(fā)生了變化,所以需要特殊處理。我們來(lái)看看代碼是怎么寫(xiě)的:
// 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(); } }
這里需要對(duì)_updateDayStatus函數(shù)進(jìn)行稍加解釋:
我們?cè)趓enderMonthBody用的是FlatList,由于FlatList是純組件,所以只有當(dāng)props發(fā)生變化時(shí),才會(huì)重新渲染。雖然我們?cè)赺updateDayStatus中更新了calendarData,但其實(shí)是同一個(gè)對(duì)象。所以,分配給renderMonthBody的data也會(huì)是同一個(gè)對(duì)象。為此,我們?cè)诟翫ay的status時(shí)用一個(gè)flag來(lái)表示該月份中是否有日期的狀態(tài)發(fā)生變化,如果發(fā)生變化,我們會(huì)用Object.assign來(lái)復(fù)制一個(gè)新的對(duì)象。這樣一來(lái),狀態(tài)發(fā)生變化的月份會(huì)重新渲染,而沒(méi)有發(fā)生變化的月份不會(huì),這反而算是一個(gè)性能上的優(yōu)化吧。
其實(shí),上面我們已經(jīng)實(shí)現(xiàn)了基本的拖拽操作。但是,還有一些遺留的小問(wèn)題:
用戶點(diǎn)選非選中時(shí)間段的日期,應(yīng)該重置當(dāng)前選中日期;
用戶手指停留的日期是unavailable(即不可操作的)時(shí),該日期不應(yīng)該被選中;
組件應(yīng)支持在初始化的時(shí)候選中props中指定的一段時(shí)間范圍;
手指在滑動(dòng)到月初/月末空白區(qū)域時(shí),也能響應(yīng)選中月初/月末;
...
當(dāng)然了,上面的這些問(wèn)題都是細(xì)節(jié)問(wèn)題,考慮篇幅原因,就不再詳述了。。。
但是!性能優(yōu)化問(wèn)題是肯定要講的!因?yàn)椋湍壳白龀鰜?lái)的這東西在ios上表現(xiàn)還可以,但是在android上拖動(dòng)的時(shí)候,會(huì)有一點(diǎn)卡頓感。尤其是在性能差的機(jī)子上,卡頓感就更明顯了。。。
3. 性能優(yōu)化我們都知道,react性能上的優(yōu)化很大程度上得益于其強(qiáng)大的DomDiff,通過(guò)它可以減少dom操作。但是過(guò)多的DomDiff也是一個(gè)消耗,所以怎么減少無(wú)謂的DomDiff呢?答案是正確地使用shouldComponentUpdate函數(shù),不過(guò)我們還是得首先找出哪些是無(wú)謂的DomDiff。
為此,我們可以在我們寫(xiě)的所有_renderXXX函數(shù)中打一個(gè)log,在手指拖動(dòng)的時(shí)候,都有哪些組件一直在render?
經(jīng)過(guò)試驗(yàn),可以發(fā)現(xiàn)每次選中日期發(fā)生變化的時(shí)候,_renderMonth,_renderMonthHeader,_renderMonthBody和_renderDay這幾個(gè)函數(shù)會(huì)觸發(fā)很多次。原因很簡(jiǎn)單,當(dāng)選中日期發(fā)生變化時(shí),我們通過(guò)setState更新了clendarData,從而觸發(fā)了整個(gè)日歷重新render。因此,每個(gè)month都會(huì)重新渲染,相應(yīng)的這幾個(gè)render函數(shù)都會(huì)觸發(fā)一遍。
3.1 減少renderMonth的DomDiff既然源頭已經(jīng)找到,我們就可以對(duì)癥下藥了。其實(shí)也簡(jiǎn)單,我們每次只要更新?tīng)顟B(tài)發(fā)生變化的月份就可以,其他的月份可以省略其DomDiff過(guò)程。
但是!!!這個(gè)解決方案有一個(gè)弊端,就是需要維護(hù)changingMonth這個(gè)變量。每次手指拖動(dòng)操作的時(shí)候,我們都得計(jì)算出哪些月份是發(fā)生狀態(tài)變化的;手指釋放之后,又得重置changingMonth。而且,現(xiàn)在這個(gè)組件的操作邏輯相對(duì)來(lái)說(shuō)還比較簡(jiǎn)單,如果交互邏輯往后變得越來(lái)越復(fù)雜,那這個(gè)維護(hù)成本會(huì)繼續(xù)上升。。。
所以,我們可以換個(gè)思路~ month不是每次都會(huì)DomDiff嗎?沒(méi)關(guān)系,我把month中的子組件封裝成PureComponent,這樣子組件的DomDiff過(guò)程是會(huì)被優(yōu)化掉的。所以,即使每次渲染month,也會(huì)大大減少無(wú)謂的DomDiff操作。而_renderMonthBody用的是FlatList,這已經(jīng)是純組件了,所以已經(jīng)起到一定的優(yōu)化效果,不然_renderDay的觸發(fā)次數(shù)會(huì)更多。因此,我們要做的只是把_renderMonthHeader改造成純組件就好了。來(lái)看看代碼:
// MonthHeader.js export class MonthHeader extends PureComponent { render() { const {identifier, monthHeaderTextStyle, renderMonthHeader} = this.props; const [year, month] = identifier.split("-"); return (3.2 減少renderDay的DomDiff{renderMonthHeader ? renderMonthHeader(identifier) : ); } } // DraggableCalendar.js export class DraggableCalendar extends Component { // ... _renderMonthHeader({identifier}) { const {monthHeaderTextStyle, renderMonthHeader} = this.props; return ({`${parseInt(year)}年${parseInt(month)}月`} }); } }
根據(jù)前面的試驗(yàn)結(jié)果,其實(shí)我們可以發(fā)現(xiàn)每次渲染月份的時(shí)候,這個(gè)月份中的所有DayItem都會(huì)被渲染一遍。但實(shí)際上只需要狀態(tài)發(fā)生變化的DayItem重新渲染即可。所以,這又給了我們優(yōu)化的空間,可以進(jìn)一步減少無(wú)謂的DomDiff。
上面的例子已經(jīng)證明PureComponent是再好不過(guò)的優(yōu)化利器了~ 所以,我們繼續(xù)把_renderDay改造成純組件,來(lái)看代碼:
// 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 (3.3 減少positionToDate的查找次數(shù){renderDay ? renderDay(data) : ); } } // DraggableCalendar.js export class DraggableCalendar extends Component { // ... _renderDay(item, index) { const styleKeys = [ "dayTextStyle", "selectedDayTextStyle", "dayContainerStyle", "singleDayContainerStyle", "beginDayContainerStyle", "middleDayContainerStyle", "endDayContainerStyle" ]; return ({data.date && ( }{data.date.getDate()} )}this.props[key])} /> ); } }
經(jīng)過(guò)上面兩步,已經(jīng)減緩了一部分的DomDiff開(kāi)銷了。那還有什么可以優(yōu)化的呢?還記得前文提到的positionToDate函數(shù)么?目前我們是通過(guò)遍歷的方式將坐標(biāo)轉(zhuǎn)換成日期的,時(shí)間復(fù)雜度是O(n),所以這里還有優(yōu)化的空間。那么又該怎么優(yōu)化呢?
這時(shí)以前學(xué)的算法是終于有用武之地了,哈哈~ 由于日歷中的日期排版很有規(guī)律,從左到右看,都是遞增的;從上到下看,也是遞增的。so~ 我們可以用二分查找來(lái)減少這個(gè)查找次數(shù),將時(shí)間復(fù)雜度降到O(nlog2)。不過(guò),在這個(gè)case中,我們應(yīng)當(dāng)如何使用二分呢?
其實(shí),我們可以使用3次二分:
因?yàn)镸onth垂直方向上是遞增的,縱坐標(biāo)y也是遞增的,所以先用二分定位到當(dāng)前手指落在哪個(gè)月份中;
同一個(gè)月內(nèi),水平方向上橫坐標(biāo)x是遞增的,所以再用一次二分定位到當(dāng)前手指落在周幾上;
同一個(gè)月內(nèi),垂直方向上縱坐標(biāo)y是遞增的,可以再用一次二分定位到當(dāng)前手指落在哪天上。
思路已經(jīng)有了,可是我們的this._dayLayouts是一個(gè)對(duì)象,沒(méi)法操作。所以,我們需要做一層轉(zhuǎn)換,姑且就叫索引吧,這樣顯得洋氣~~~ 來(lái)看代碼:
// 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]) })); } // ... }
從上面打印出來(lái)的索引結(jié)果中,我們可以看到建立索引的過(guò)程主要是干了兩件事:
保存下了每個(gè)月的上下左右邊界,這樣就可以用二分快速找到當(dāng)前手指落在哪個(gè)月份中了;
將原本一維的dayLayouts轉(zhuǎn)換成了二維數(shù)組,與日歷的展示方式保持一致,目的也是為了方便二分查找。
接下來(lái)再看看二分查找的代碼:
// 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; } // ... };
我們來(lái)舉個(gè)例子看看優(yōu)化的效果:假如渲染的日歷數(shù)據(jù)有6個(gè)月的內(nèi)容,也就是180天。最壞的情況下,原先需要查找180次才有結(jié)果。而現(xiàn)在呢?月份最多3次能確定,row最多3次能確定,col最多3次能確定,也就是最多9次就能找到結(jié)果。
啊哈~ 簡(jiǎn)直是文美~ 再看看手指拖拽時(shí)的效果,絲毫沒(méi)有卡頓感,媽媽再也不用擔(dān)心RN在android上的性能效果啦~
4. 實(shí)戰(zhàn)費(fèi)了那么大勁兒,又是封裝組件,又是優(yōu)化性能的,現(xiàn)在終于可以能派上用場(chǎng)啦~ 為了應(yīng)對(duì)產(chǎn)品變化多端的需求,我們?cè)缇蛯?duì)日歷的樣式做了可配置化。
來(lái)看看效果咋樣:
5. 寫(xiě)在最后看著眼前的這個(gè)demo,也算是收獲不小,既接觸了RN的手勢(shì)系統(tǒng),還漲了一波組件的優(yōu)化經(jīng)驗(yàn),甚至還用到了二分查找~ 嘿嘿嘿,美滋滋~
老規(guī)矩,本文代碼地址:
https://github.com/SmallStoneSK/react-native-draggable-calendar
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://specialneedsforspecialkids.com/yun/94739.html
摘要:插件的依賴文件插件的使用頁(yè)面結(jié)構(gòu)設(shè)置日歷頭部信息,如果設(shè)置為,則不顯示頭部信息。設(shè)置日歷的高度,包括日歷頭部,默認(rèn)未設(shè)置,高度根據(jù)值自適應(yīng)。 先說(shuō)一下我的另一博客地址: https://home.cnblogs.com/u/bllx/ FullCalendar的選擇 前段時(shí)間,一直在開(kāi)發(fā)考勤系統(tǒng),當(dāng)時(shí)為滿足設(shè)計(jì)的需求,選了好幾個(gè)插件,最后決定采用Fullcanlendar的插件。感覺(jué)這...
閱讀 3237·2021-09-07 10:10
閱讀 3582·2019-08-30 15:44
閱讀 2582·2019-08-30 15:44
閱讀 2997·2019-08-29 15:11
閱讀 2225·2019-08-28 18:26
閱讀 2748·2019-08-26 12:21
閱讀 1116·2019-08-23 16:12
閱讀 3027·2019-08-23 14:57