react-native-toggle-calendar
Version:
Horizontal as well as Grid calendar built on top of react-native-calendars
451 lines (404 loc) • 16.2 kB
JavaScript
import React, {Component} from 'react';
import {
Text,
View,
Dimensions,
Animated,
ViewPropTypes,
} from 'react-native';
import PropTypes from 'prop-types';
import XDate from 'xdate';
import {parseDate, xdateToData} from '../interface';
import dateutils from '../dateutils';
import CalendarList from '../calendar-list';
import ReservationsList from './reservation-list';
import styleConstructor from './style';
import { VelocityTracker } from '../input';
const HEADER_HEIGHT = 104;
const KNOB_HEIGHT = 24;
//Fallback when RN version is < 0.44
const viewPropTypes = ViewPropTypes || View.propTypes;
export default class AgendaView extends Component {
static propTypes = {
// Specify theme properties to override specific styles for calendar parts. Default = {}
theme: PropTypes.object,
// agenda container style
style: viewPropTypes.style,
// the list of items that have to be displayed in agenda. If you want to render item as empty date
// the value of date key has to be an empty array []. If there exists no value for date key it is
// considered that the date in question is not yet loaded
items: PropTypes.object,
// callback that gets called when items for a certain month should be loaded (month became visible)
loadItemsForMonth: PropTypes.func,
// callback that fires when the calendar is opened or closed
onCalendarToggled: PropTypes.func,
// callback that gets called on day press
onDayPress: PropTypes.func,
// callback that gets called when day changes while scrolling agenda list
onDaychange: PropTypes.func,
// specify how each item should be rendered in agenda
renderItem: PropTypes.func,
// specify how each date should be rendered. day can be undefined if the item is not first in that day.
renderDay: PropTypes.func,
// specify how agenda knob should look like
renderKnob: PropTypes.func,
// specify how empty date content with no items should be rendered
renderEmptyDay: PropTypes.func,
// specify what should be rendered instead of ActivityIndicator
renderEmptyData: PropTypes.func,
// specify your item comparison function for increased performance
rowHasChanged: PropTypes.func,
// Max amount of months allowed to scroll to the past. Default = 50
pastScrollRange: PropTypes.number,
// Max amount of months allowed to scroll to the future. Default = 50
futureScrollRange: PropTypes.number,
// initially selected day
selected: PropTypes.any,
// Minimum date that can be selected, dates before minDate will be grayed out. Default = undefined
minDate: PropTypes.any,
// Maximum date that can be selected, dates after maxDate will be grayed out. Default = undefined
maxDate: PropTypes.any,
// If firstDay=1 week starts from Monday. Note that dayNames and dayNamesShort should still start from Sunday.
firstDay: PropTypes.number,
// Collection of dates that have to be marked. Default = items
markedDates: PropTypes.object,
// Optional marking type if custom markedDates are provided
markingType: PropTypes.string,
// Hide knob button. Default = false
hideKnob: PropTypes.bool,
// Month format in calendar title. Formatting values: http://arshaw.com/xdate/#Formatting
monthFormat: PropTypes.string,
// A RefreshControl component, used to provide pull-to-refresh functionality for the ScrollView.
refreshControl: PropTypes.element,
// If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make sure to also set the refreshing prop correctly.
onRefresh: PropTypes.func,
// Set this true while waiting for new data from a refresh.
refreshing: PropTypes.bool,
// Display loading indicador. Default = false
displayLoadingIndicator: PropTypes.bool,
};
constructor(props) {
super(props);
this.styles = styleConstructor(props.theme);
const windowSize = Dimensions.get('window');
this.viewHeight = windowSize.height;
this.viewWidth = windowSize.width;
this.scrollTimeout = undefined;
this.headerState = 'idle';
this.state = {
scrollY: new Animated.Value(0),
calendarIsReady: false,
calendarScrollable: false,
firstResevationLoad: false,
selectedDay: parseDate(this.props.selected) || XDate(true),
topDay: parseDate(this.props.selected) || XDate(true),
};
this.currentMonth = this.state.selectedDay.clone();
this.onLayout = this.onLayout.bind(this);
this.onScrollPadLayout = this.onScrollPadLayout.bind(this);
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
this.onStartDrag = this.onStartDrag.bind(this);
this.onSnapAfterDrag = this.onSnapAfterDrag.bind(this);
this.generateMarkings = this.generateMarkings.bind(this);
this.knobTracker = new VelocityTracker();
this.state.scrollY.addListener(({value}) => this.knobTracker.add(value));
}
calendarOffset() {
return 90 - (this.viewHeight / 2);
}
initialScrollPadPosition() {
return Math.max(0, this.viewHeight - HEADER_HEIGHT);
}
setScrollPadPosition(y, animated) {
this.scrollPad._component.scrollTo({x: 0, y, animated});
}
onScrollPadLayout() {
// When user touches knob, the actual component that receives touch events is a ScrollView.
// It needs to be scrolled to the bottom, so that when user moves finger downwards,
// scroll position actually changes (it would stay at 0, when scrolled to the top).
this.setScrollPadPosition(this.initialScrollPadPosition(), false);
// delay rendering calendar in full height because otherwise it still flickers sometimes
setTimeout(() => this.setState({calendarIsReady: true}), 0);
}
onLayout(event) {
this.viewHeight = event.nativeEvent.layout.height;
this.viewWidth = event.nativeEvent.layout.width;
this.forceUpdate();
}
onTouchStart() {
this.headerState = 'touched';
if (this.knob) {
this.knob.setNativeProps({style: { opacity: 0.5 }});
}
}
onTouchEnd() {
if (this.knob) {
this.knob.setNativeProps({style: { opacity: 1 }});
}
if (this.headerState === 'touched') {
this.setScrollPadPosition(0, true);
this.enableCalendarScrolling();
}
this.headerState = 'idle';
}
onStartDrag() {
this.headerState = 'dragged';
this.knobTracker.reset();
}
onSnapAfterDrag(e) {
// on Android onTouchEnd is not called if dragging was started
this.onTouchEnd();
const currentY = e.nativeEvent.contentOffset.y;
this.knobTracker.add(currentY);
const projectedY = currentY + this.knobTracker.estimateSpeed() * 250/*ms*/;
const maxY = this.initialScrollPadPosition();
const snapY = (projectedY > maxY / 2) ? maxY : 0;
this.setScrollPadPosition(snapY, true);
if (snapY === 0) {
this.enableCalendarScrolling();
}
}
onVisibleMonthsChange(months) {
if (this.props.items && !this.state.firstResevationLoad) {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(() => {
if (this.props.loadItemsForMonth && this._isMounted) {
this.props.loadItemsForMonth(months[0]);
}
}, 200);
}
}
loadReservations(props) {
if ((!props.items || !Object.keys(props.items).length) && !this.state.firstResevationLoad) {
this.setState({
firstResevationLoad: true
}, () => {
if (this.props.loadItemsForMonth) {
this.props.loadItemsForMonth(xdateToData(this.state.selectedDay));
}
});
}
}
componentWillMount() {
this._isMounted = true;
this.loadReservations(this.props);
}
componentWillUnmount() {
this._isMounted = false;
}
componentWillReceiveProps(props) {
if (props.items) {
this.setState({
firstResevationLoad: false
});
} else {
this.loadReservations(props);
}
}
enableCalendarScrolling() {
this.setState({
calendarScrollable: true
});
if (this.props.onCalendarToggled) {
this.props.onCalendarToggled(true);
}
// Enlarge calendarOffset here as a workaround on iOS to force repaint.
// Otherwise the month after current one or before current one remains invisible.
// The problem is caused by overflow: 'hidden' style, which we need for dragging
// to be performant.
// Another working solution for this bug would be to set removeClippedSubviews={false}
// in CalendarList listView, but that might impact performance when scrolling
// month list in expanded CalendarList.
// Further info https://github.com/facebook/react-native/issues/1831
this.calendar.scrollToDay(this.state.selectedDay, this.calendarOffset() + 1, true);
}
_chooseDayFromCalendar(d) {
this.chooseDay(d, !this.state.calendarScrollable);
}
chooseDay(d, optimisticScroll) {
const day = parseDate(d);
this.setState({
calendarScrollable: false,
selectedDay: day.clone()
});
if (this.props.onCalendarToggled) {
this.props.onCalendarToggled(false);
}
if (!optimisticScroll) {
this.setState({
topDay: day.clone()
});
}
this.setScrollPadPosition(this.initialScrollPadPosition(), true);
this.calendar.scrollToDay(day, this.calendarOffset(), true);
if (this.props.loadItemsForMonth) {
this.props.loadItemsForMonth(xdateToData(day));
}
if (this.props.onDayPress) {
this.props.onDayPress(xdateToData(day));
}
}
renderReservations() {
return (
<ReservationsList
refreshControl={this.props.refreshControl}
refreshing={this.props.refreshing}
onRefresh={this.props.onRefresh}
rowHasChanged={this.props.rowHasChanged}
renderItem={this.props.renderItem}
renderDay={this.props.renderDay}
renderEmptyDate={this.props.renderEmptyDate}
reservations={this.props.items}
selectedDay={this.state.selectedDay}
renderEmptyData={this.props.renderEmptyData}
topDay={this.state.topDay}
onDayChange={this.onDayChange.bind(this)}
onScroll={() => {}}
ref={(c) => this.list = c}
theme={this.props.theme}
/>
);
}
onDayChange(day) {
const newDate = parseDate(day);
const withAnimation = dateutils.sameMonth(newDate, this.state.selectedDay);
this.calendar.scrollToDay(day, this.calendarOffset(), withAnimation);
this.setState({
selectedDay: parseDate(day)
});
if (this.props.onDayChange) {
this.props.onDayChange(xdateToData(newDate));
}
}
generateMarkings() {
let markings = this.props.markedDates;
if (!markings) {
markings = {};
Object.keys(this.props.items || {}).forEach(key => {
if (this.props.items[key] && this.props.items[key].length) {
markings[key] = {marked: true};
}
});
}
const key = this.state.selectedDay.toString('yyyy-MM-dd');
return {...markings, [key]: {...(markings[key] || {}), ...{selected: true}}};
}
render() {
const agendaHeight = Math.max(0, this.viewHeight - HEADER_HEIGHT);
const weekDaysNames = dateutils.weekDayNames(this.props.firstDay);
const weekdaysStyle = [this.styles.weekdays, {
opacity: this.state.scrollY.interpolate({
inputRange: [agendaHeight - HEADER_HEIGHT, agendaHeight],
outputRange: [0, 1],
extrapolate: 'clamp',
}),
transform: [{ translateY: this.state.scrollY.interpolate({
inputRange: [Math.max(0, agendaHeight - HEADER_HEIGHT), agendaHeight],
outputRange: [-HEADER_HEIGHT, 0],
extrapolate: 'clamp',
})}]
}];
const headerTranslate = this.state.scrollY.interpolate({
inputRange: [0, agendaHeight],
outputRange: [agendaHeight, 0],
extrapolate: 'clamp',
});
const contentTranslate = this.state.scrollY.interpolate({
inputRange: [0, agendaHeight],
outputRange: [0, agendaHeight/2],
extrapolate: 'clamp',
});
const headerStyle = [
this.styles.header,
{ bottom: agendaHeight, transform: [{ translateY: headerTranslate }] },
];
if (!this.state.calendarIsReady) {
// limit header height until everything is setup for calendar dragging
headerStyle.push({height: 0});
// fill header with appStyle.calendarBackground background to reduce flickering
weekdaysStyle.push({height: HEADER_HEIGHT});
}
const shouldAllowDragging = !this.props.hideKnob && !this.state.calendarScrollable;
const scrollPadPosition = (shouldAllowDragging ? HEADER_HEIGHT : 0) - KNOB_HEIGHT;
const scrollPadStyle = {
position: 'absolute',
width: 80,
height: KNOB_HEIGHT,
top: scrollPadPosition,
left: (this.viewWidth - 80) / 2,
};
let knob = (<View style={this.styles.knobContainer}/>);
if (!this.props.hideKnob) {
const knobView = this.props.renderKnob ? this.props.renderKnob() : (<View style={this.styles.knob}/>);
knob = this.state.calendarScrollable ? null : (
<View style={this.styles.knobContainer}>
<View ref={(c) => this.knob = c}>{knobView}</View>
</View>
);
}
return (
<View onLayout={this.onLayout} style={[this.props.style, {flex: 1, overflow: 'hidden'}]}>
<View style={this.styles.reservations}>
{this.renderReservations()}
</View>
<Animated.View style={headerStyle}>
<Animated.View style={{flex:1, transform: [{ translateY: contentTranslate }]}}>
<CalendarList
onLayout={() => {
this.calendar.scrollToDay(this.state.selectedDay.clone(), this.calendarOffset(), false);
}}
calendarWidth={this.viewWidth}
theme={this.props.theme}
onVisibleMonthsChange={this.onVisibleMonthsChange.bind(this)}
ref={(c) => this.calendar = c}
minDate={this.props.minDate}
maxDate={this.props.maxDate}
current={this.currentMonth}
markedDates={this.generateMarkings()}
markingType={this.props.markingType}
removeClippedSubviews={this.props.removeClippedSubviews}
onDayPress={this._chooseDayFromCalendar.bind(this)}
scrollingEnabled={this.state.calendarScrollable}
hideExtraDays={this.state.calendarScrollable}
firstDay={this.props.firstDay}
monthFormat={this.props.monthFormat}
pastScrollRange={this.props.pastScrollRange}
futureScrollRange={this.props.futureScrollRange}
dayComponent={this.props.dayComponent}
disabledByDefault={this.props.disabledByDefault}
displayLoadingIndicator={this.props.displayLoadingIndicator}
showWeekNumbers={this.props.showWeekNumbers}
/>
</Animated.View>
{knob}
</Animated.View>
<Animated.View style={weekdaysStyle}>
{this.props.showWeekNumbers && <Text allowFontScaling={false} style={this.styles.weekday} numberOfLines={1}></Text>}
{weekDaysNames.map((day, index) => (
<Text allowFontScaling={false} key={day+index} style={this.styles.weekday} numberOfLines={1}>{day}</Text>
))}
</Animated.View>
<Animated.ScrollView
ref={c => this.scrollPad = c}
overScrollMode='never'
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
style={scrollPadStyle}
scrollEventThrottle={1}
scrollsToTop={false}
onTouchStart={this.onTouchStart}
onTouchEnd={this.onTouchEnd}
onScrollBeginDrag={this.onStartDrag}
onScrollEndDrag={this.onSnapAfterDrag}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: this.state.scrollY } } }],
{ useNativeDriver: true },
)}
>
<View style={{height: agendaHeight + KNOB_HEIGHT}} onLayout={this.onScrollPadLayout} />
</Animated.ScrollView>
</View>
);
}
}