react-native-calendar-strip
Version:
Easy to use and visually stunning calendar component for React Native
363 lines (320 loc) • 11.6 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 } from "react-native";
import PropTypes from "prop-types";
import { RecyclerListView, DataProvider, LayoutProvider } from "recyclerlistview";
import moment from "moment";
export default class CalendarScroller extends Component {
static propTypes = {
data: PropTypes.array.isRequired,
initialRenderIndex: PropTypes.number,
renderDay: PropTypes.func,
renderDayParams: PropTypes.object.isRequired,
minDate: PropTypes.any,
maxDate: PropTypes.any,
maxSimultaneousDays: PropTypes.number,
updateMonthYear: PropTypes.func,
onWeekChanged: PropTypes.func,
onWeekScrollStart: PropTypes.func,
onWeekScrollEnd: PropTypes.func,
externalScrollView: PropTypes.func,
pagingEnabled: PropTypes.bool
}
static defaultProps = {
data: [],
renderDayParams: {},
};
constructor(props) {
super(props);
this.timeoutResetPositionId = null;
this.updateLayout = renderDayParams => {
const itemHeight = renderDayParams.height;
const itemWidth = renderDayParams.width + renderDayParams.marginHorizontal * 2;
const layoutProvider = new LayoutProvider(
index => 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.updateDaysData = data => {
return {
data,
numDays: data.length,
dataProvider: this.dataProvider.cloneWithRows(data),
}
}
this.state = {
...this.updateLayout(props.renderDayParams),
...this.updateDaysData(props.data),
numVisibleItems: 1, // updated in onLayout
};
}
componentWillUnmount() {
if (this.timeoutResetPositionId !== null) {
clearTimeout(this.timeoutResetPositionId);
this.timeoutResetPositionId = null;
}
}
componentDidUpdate(prevProps, prevState) {
let newState = {};
let updateState = false;
const {
width,
height,
selectedDate
} = this.props.renderDayParams;
if (width !== prevProps.renderDayParams.width || height !== prevProps.renderDayParams.height) {
updateState = true;
newState = this.updateLayout(this.props.renderDayParams);
}
if (selectedDate !== prevProps.renderDayParams.selectedDate) {
this.scrollToDate(selectedDate);
}
if (this.props.data !== prevProps.data) {
updateState = true;
newState = {...newState, ...this.updateDaysData(this.props.data)};
}
if (updateState) {
this.setState(newState);
}
}
// Scroll left, guarding against start index.
scrollLeft = () => {
if (this.state.visibleStartIndex === 0) {
return;
}
const newIndex = Math.max(this.state.visibleStartIndex - this.state.numVisibleItems, 0);
this.rlv.scrollToIndex(newIndex, true);
}
// Scroll right, guarding against end index.
scrollRight = () => {
const newIndex = this.state.visibleStartIndex + this.state.numVisibleItems;
if (newIndex >= (this.state.numDays - 1)) {
this.rlv.scrollToEnd(true); // scroll to the very end, including padding
return;
}
this.rlv.scrollToIndex(newIndex, true);
}
// Scroll to given date, and check against min and max date if available.
scrollToDate = (date) => {
let targetDate = moment(date).subtract(Math.round(this.state.numVisibleItems / 2) - 1, "days");
const {
minDate,
maxDate,
} = this.props;
// Falls back to min or max date when the given date exceeds the available dates
if (minDate && targetDate.isBefore(minDate, "day")) {
targetDate = minDate;
} else if (maxDate && targetDate.isAfter(maxDate, "day")) {
targetDate = maxDate;
}
for (let i = 0; i < this.state.data.length; i++) {
if (this.state.data[i].date.isSame(targetDate, "day")) {
this.rlv?.scrollToIndex(i, true);
break;
}
}
}
// Shift dates when end of list is reached.
shiftDaysForward = (visibleStartDate = this.state.visibleStartDate) => {
const prevVisStart = visibleStartDate.clone();
const newStartDate = prevVisStart.clone().subtract(Math.floor(this.state.numDays / 3), "days");
this.updateDays(prevVisStart, newStartDate);
}
// Shift dates when beginning of list is reached.
shiftDaysBackward = (visibleStartDate) => {
const prevVisStart = visibleStartDate.clone();
const newStartDate = prevVisStart.clone().subtract(Math.floor(this.state.numDays * 2/3), "days");
this.updateDays(prevVisStart, newStartDate);
}
updateDays = (prevVisStart, newStartDate) => {
if (this.shifting) {
return;
}
const {
minDate,
maxDate,
} = this.props;
const data = [];
let _newStartDate = newStartDate;
if (minDate && newStartDate.isBefore(minDate, "day")) {
_newStartDate = moment(minDate);
}
for (let i = 0; i < this.state.numDays; i++) {
let date = _newStartDate.clone().add(i, "days");
if (maxDate && date.isAfter(maxDate, "day")) {
break;
}
data.push({date});
}
// Prevent reducing range when the minDate - maxDate range is small.
if (data.length < this.props.maxSimultaneousDays) {
return;
}
// Scroll to previous date
for (let i = 0; i < data.length; i++) {
if (data[i].date.isSame(prevVisStart, "day")) {
this.shifting = true;
this.rlv.scrollToIndex(i, false);
// RecyclerListView sometimes returns position to old index after
// moving to the new one. Set position again after delay.
this.timeoutResetPositionId = setTimeout(() => {
this.timeoutResetPositionId = null;
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, notNow) => {
const {
data,
numDays,
numVisibleItems,
visibleStartDate: _visStartDate,
visibleEndDate: _visEndDate,
} = this.state;
const visibleStartIndex = all[0];
const visibleStartDate = data[visibleStartIndex] ? data[visibleStartIndex].date : moment();
const visibleEndIndex = Math.min(visibleStartIndex + numVisibleItems - 1, data.length - 1);
const visibleEndDate = data[visibleEndIndex] ? data[visibleEndIndex].date : moment();
const {
updateMonthYear,
onWeekChanged,
} = this.props;
// Fire month/year update on both week and month changes. This is
// necessary for the header and onWeekChanged updates.
if (!_visStartDate || !_visEndDate ||
!visibleStartDate.isSame(_visStartDate, "week") ||
!visibleEndDate.isSame(_visEndDate, "week") ||
!visibleStartDate.isSame(_visStartDate, "month") ||
!visibleEndDate.isSame(_visEndDate, "month") )
{
const visStart = visibleStartDate && visibleStartDate.clone();
const visEnd = visibleEndDate && visibleEndDate.clone();
onWeekChanged && onWeekChanged(visStart, visEnd);
}
// Always update weekstart/end for WeekSelectors.
updateMonthYear && updateMonthYear(visibleStartDate, visibleEndDate);
if (visibleStartIndex === 0) {
this.shiftDaysBackward(visibleStartDate);
} else {
const minEndOffset = numDays - numVisibleItems;
if (minEndOffset > numVisibleItems) {
for (let a of all) {
if (a > minEndOffset) {
this.shiftDaysForward(visibleStartDate);
break;
}
}
}
}
this.setState({
visibleStartDate,
visibleEndDate,
visibleStartIndex,
});
}
onScrollStart = (event) => {
const {onWeekScrollStart} = this.props;
const {prevStartDate, prevEndDate} = this.state;
if (onWeekScrollStart && prevStartDate && prevEndDate) {
onWeekScrollStart(prevStartDate.clone(), prevEndDate.clone());
}
}
onScrollEnd = () => {
const {onWeekScrollEnd} = this.props;
const {visibleStartDate, visibleEndDate, prevEndDate} = this.state;
if (onWeekScrollEnd && visibleStartDate && visibleEndDate) {
if (!visibleEndDate.isSame(prevEndDate, "day")) {
onWeekScrollEnd(visibleStartDate.clone(), visibleEndDate.clone());
}
}
}
onScrollBeginDrag = () => {
const {
onWeekScrollStart,
onWeekScrollEnd,
} = this.props;
// Prev dates required only if scroll callbacks are defined
if (!onWeekScrollStart && !onWeekScrollEnd) {
return;
}
const {
data,
visibleStartDate,
visibleEndDate,
visibleStartIndex,
visibleEndIndex,
} = this.state;
const prevStartDate = visibleStartDate ? visibleStartDate
: (data[visibleStartIndex] ? data[visibleStartIndex].date : moment());
const prevEndDate = visibleEndDate ? visibleEndDate
: (data[visibleEndIndex] ? data[visibleEndIndex].date : moment());
this.setState({
prevStartDate,
prevEndDate,
});
}
onLayout = event => {
let width = event.nativeEvent.layout.width;
this.setState({
numVisibleItems: Math.round(width / this.state.itemWidth),
});
}
rowRenderer = (type, data, i, extState) => {
return this.props.renderDay && this.props.renderDay({...data, ...extState});
}
render() {
if (!this.state.data || this.state.numDays === 0 || !this.state.itemHeight) {
return null;
}
const pagingProps = this.props.pagingEnabled ? {
decelerationRate: 0,
snapToInterval: this.state.itemWidth * this.state.numVisibleItems
} : {};
return (
<View
style={{ height: this.state.itemHeight, flex: 1 }}
onLayout={this.onLayout}
>
<RecyclerListView
ref={rlv => this.rlv = rlv}
layoutProvider={this.state.layoutProvider}
dataProvider={this.state.dataProvider}
rowRenderer={this.rowRenderer}
extendedState={this.props.renderDayParams}
initialRenderIndex={this.props.initialRenderIndex}
onVisibleIndicesChanged={this.onVisibleIndicesChanged}
isHorizontal
externalScrollView={this.props.externalScrollView}
scrollViewProps={{
showsHorizontalScrollIndicator: false,
contentContainerStyle: { paddingRight: this.state.itemWidth / 2 },
onMomentumScrollBegin: this.onScrollStart,
onMomentumScrollEnd: this.onScrollEnd,
onScrollBeginDrag: this.onScrollBeginDrag,
...pagingProps
}}
/>
</View>
);
}
}