react-native-calendar-picker
Version:
Calendar Picker Component for React Native
292 lines (257 loc) • 9.14 kB
JavaScript
// This is a bi-directional infinite scroller.
// As the beginning & end are reached, the dates are recalculated and the current
// index adjusted to match the previous visible date.
// RecyclerListView helps to efficiently recycle instances, but the data that
// it's fed is finite. Hence the data must be shifted at the ends to appear as
// an infinite scroller.
import React, { Component } from 'react';
import { View, Platform } from 'react-native';
import PropTypes from 'prop-types';
import { RecyclerListView, DataProvider, LayoutProvider } from 'recyclerlistview';
import { addMonths } from 'date-fns/addMonths';
import { subMonths } from 'date-fns/subMonths';
import { endOfMonth } from 'date-fns/endOfMonth';
import { isAfter } from 'date-fns/isAfter';
import { isBefore } from 'date-fns/isBefore';
import { isSameMonth } from 'date-fns/isSameMonth';
import { startOfMonth } from 'date-fns/startOfMonth';
export default class CalendarScroller extends Component {
static propTypes = {
data: PropTypes.array.isRequired,
initialRenderIndex: PropTypes.number,
renderMonth: PropTypes.func,
renderMonthParams: PropTypes.object.isRequired,
minDate: PropTypes.any,
maxDate: PropTypes.any,
maxSimultaneousMonths: PropTypes.number,
horizontal: PropTypes.bool,
updateMonthYear: PropTypes.func,
onMonthChange: PropTypes.func,
}
static defaultProps = {
data: [],
renderMonthParams: { styles: {} },
};
constructor(props) {
super(props);
this.updateLayout = dims => {
const itemWidth = dims.containerWidth;
let itemHeight = dims.containerHeight;
if (dims.dayWrapper && dims.dayWrapper.height) {
itemHeight = dims.dayWrapper.height * 6; // max 6 row weeks per month
}
const layoutProvider = new LayoutProvider(
() => 0, // only 1 view type
(type, dim) => {
dim.width = itemWidth;
dim.height = itemHeight;
}
);
return { layoutProvider, itemHeight, itemWidth };
};
this.dataProvider = new DataProvider((r1, r2) => {
return r1 !== r2;
});
this.updateMonthsData = data => {
return {
data,
numMonths: data.length,
dataProvider: this.dataProvider.cloneWithRows(data),
};
};
this.state = {
...this.updateLayout(props.renderMonthParams.styles),
...this.updateMonthsData(props.data),
numVisibleItems: 1, // updated in onLayout
};
}
shouldComponentUpdate(prevProps, prevState) {
return this.state.data !== prevState.data ||
this.state.itemHeight !== prevState.itemHeight ||
this.state.itemWidth !== prevState.itemWidth ||
this.props.renderMonthParams !== prevProps.renderMonthParams;
}
componentDidUpdate(prevProps) {
let newState = {};
let updateState = false;
if (this.props.renderMonthParams.styles !== prevProps.renderMonthParams.styles) {
updateState = true;
newState = this.updateLayout(this.props.renderMonthParams.styles);
}
if (this.props.data !== prevProps.data) {
updateState = true;
newState = { ...newState, ...this.updateMonthsData(this.props.data) };
}
if (Platform.OS === 'android' &&
this.props.renderMonthParams.selectedStartDate !== prevProps.renderMonthParams.selectedStartDate) {
// Android unexpectedly jumps to previous month on first selected date.
// Scroll RLV to selected date's month.
this.goToDate(this.props.renderMonthParams.selectedStartDate, 100);
}
if (updateState) {
this.setState(newState);
}
}
goToDate = (date, delay) => {
const data = this.state.data;
for (let i = 0; i < data.length; i++) {
if (isSameMonth(data[i], date)) {
if (delay) {
setTimeout(() => this.rlv && this.rlv.scrollToIndex(i, false), delay);
}
else {
this.rlv && this.rlv.scrollToIndex(i, false);
}
break;
}
}
}
// Scroll left, guarding against start index.
scrollLeft = () => {
const { currentIndex, numVisibleItems } = this.state;
if (currentIndex === 0) {
return;
}
const newIndex = Math.max(currentIndex - numVisibleItems, 0);
this.rlv && this.rlv.scrollToIndex(newIndex, true);
}
// Scroll right, guarding against end index.
scrollRight = () => {
const { currentIndex, numVisibleItems, numMonths } = this.state;
const newIndex = Math.min(currentIndex + numVisibleItems, numMonths - 1);
this.rlv && this.rlv.scrollToIndex(newIndex, true);
}
// Shift dates when end of list is reached.
shiftMonthsForward = currentMonth => {
this.shiftMonths(currentMonth, this.state.numMonths / 3);
}
// Shift dates when beginning of list is reached.
shiftMonthsBackward = currentMonth => {
this.shiftMonths(currentMonth, this.state.numMonths * 2 / 3);
}
shiftMonths = (currentMonth, offset) => {
const prevVisMonth = new Date(currentMonth);
const newStartMonth = subMonths(new Date(prevVisMonth), Math.floor(offset));
this.updateMonths(prevVisMonth, newStartMonth);
}
updateMonths = (prevVisMonth, newStartMonth) => {
if (this.shifting) {
return;
}
const {
minDate,
maxDate,
restrictMonthNavigation,
} = this.props;
const data = [];
let _newStartMonth = newStartMonth;
if (minDate && restrictMonthNavigation && isBefore(newStartMonth, startOfMonth(minDate))) {
_newStartMonth = new Date(minDate);
}
for (let i = 0; i < this.state.numMonths; i++) {
let date = addMonths(_newStartMonth, i);
if (maxDate && restrictMonthNavigation && isAfter(endOfMonth(date), maxDate)) {
break;
}
data.push(date);
}
// Prevent reducing range when the minDate - maxDate range is small.
if (data.length < this.props.maxSimultaneousMonths) {
return;
}
// Scroll to previous date
for (let i = 0; i < data.length; i++) {
if (isSameMonth(data[i], prevVisMonth)) {
this.shifting = true;
this.rlv && this.rlv.scrollToIndex(i, false);
// RecyclerListView sometimes returns position to old index after
// moving to the new one. Set position again after delay.
setTimeout(() => {
this.rlv && this.rlv.scrollToIndex(i, false);
this.shifting = false; // debounce
}, 800);
break;
}
}
this.setState({
data,
dataProvider: this.dataProvider.cloneWithRows(data),
});
}
// Track which dates are visible.
onVisibleIndicesChanged = (all, now) => {
const {
data,
numMonths,
currentMonth: _currentMonth,
} = this.state;
const {
updateMonthYear,
onMonthChange,
} = this.props;
// "now" contains the inflight indices, whereas "all" reflects indices
// after scrolling has settled. Prioritize "now" for faster header updates.
const currentIndex = now[0] || all[0];
const currentMonth = data[currentIndex]; // a date
// Fire month/year update on month changes. This is
// necessary for the header and onMonthChange updates.
if (!_currentMonth || !isSameMonth(_currentMonth, currentMonth)) {
const currMonth = new Date(currentMonth);
onMonthChange && onMonthChange(currMonth);
}
updateMonthYear && updateMonthYear(currentMonth, true);
if (currentIndex === 0) {
this.shiftMonthsBackward(currentMonth);
} else if (currentIndex > numMonths - 3) {
this.shiftMonthsForward(currentMonth);
}
this.setState({
currentMonth,
currentIndex,
});
}
onLayout = event => {
const containerWidth = event.nativeEvent.layout.width;
this.setState({
numVisibleItems: Math.floor(containerWidth / this.state.itemWidth),
...this.updateLayout(this.props.renderMonthParams.styles),
});
}
rowRenderer = (type, rowMonth, i, extState) => {
const { updateMonthYear, renderMonth } = this.props;
const { currentMonth: month, currentYear: year } = updateMonthYear(rowMonth);
return renderMonth && renderMonth({ ...extState, month, year });
}
render() {
const {
data,
numMonths,
itemHeight: height,
itemWidth: width,
layoutProvider,
dataProvider,
} = this.state;
if (!data || numMonths === 0 || !height) {
return null;
}
return (
<View style={{ width, height }} onLayout={this.onLayout}>
<RecyclerListView
ref={rlv => this.rlv = rlv}
layoutProvider={layoutProvider}
dataProvider={dataProvider}
rowRenderer={this.rowRenderer}
extendedState={this.props.renderMonthParams}
initialRenderIndex={this.props.initialRenderIndex}
onVisibleIndicesChanged={this.onVisibleIndicesChanged}
isHorizontal={this.props.horizontal}
scrollViewProps={{
showsHorizontalScrollIndicator: false,
snapToInterval: this.props.horizontal ? width : height,
}}
decelerationRate={this.props.scrollDecelarationRate}
/>
</View>
);
}
}