How to make a horizontal calendar slider in React Native with Flatlist (long story)
What I want to build?
I want to create a component representing one week, with the ability for the user to scroll to the right or the left for changing the displayed week.
What I need for that?
For the UI I want to use Native Base but any “css” works too, and the components we are about to use are available in React Native too.
For the date I like to use Luxon, but any other “date framework” works well too. The classic date object from javascript works too if you have the time for that (got it ? 😅).
import React, { type ReactElement, useEffect, useState } from 'react';
import { Box, Center, FlatList, HStack } from 'native-base';
import { Dimensions, type ScaledSize } from 'react-native';
const RowCalendar = (): ReactElement => {
const windowDimensions: ScaledSize = Dimensions.get('window');
const [dayItemWidth, setDayItemWidth] = useState<number>(0);
/**
* calculate the item dimension for one day, we want the total width split by seven day
*/
useEffect((): void => {
setDayItemWidth(windowDimensions.width / 7);
}, []);
/**
* render the week day
* @returns {ReactElement} the Element itself
*/
function generateCurrentWeek(): ReactElement[] {
return ['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((value: string, index: number) => {
return (
<Center key={index} h={'10'} w={dayItemWidth} bg={'primary.500'}>
{value}
</Center>
);
});
}
return (
<Box>
<HStack justifyContent='center'>
{generateCurrentWeek()}
</HStack>
<FlatList horizontal={true} />
</Box>
);
};
export default RowCalendar;
This actual code gives the following result :
The date
Now we need to add some data into our flatlist. For that, we need to have the current month split by week. With a little peculiarity 🙂
Because we want to show a full week, that represent seven days, if the month end on another day than Sunday or the week start another day than Monday we have some missing number.
Because of that, we need to “complete” the first and the last week of each month if the array representing the week is not 7 long.
Let’s begging with the function for generating the current month :
/**
* generate an array containing all the day for a given month and year
* @param {number} month the month number, 0 based
* @param {number} year the year, not zero based, required to account for leap years
* @returns {Date[]} list with date objects for each day of the month
*/
getDaysInMonth(month: number, year: number): DateTime[] {
const date: DateTime = DateTime.local(year, month);
const daysInMonth: PossibleDaysInMonth | undefined = date.daysInMonth; // e.g 30 in number format
// return an array containing dateTime object
return Array.from(Array(daysInMonth), (_, x: number) => {
return DateTime.local(year, month, x + 1);
});
}
Ok what we have here :
- with Luxon we generate the today date object :
DateTime.local()
- again we use Luxon and his
date.daysInMonth
function, to get the total of day composing the month - and finally we generate the array with
Array.from
function
Now that we have the current month in an array, we need to divide it by week. We also need to add other data, for each day, such as a boolean representing whether the day is today or not.
We want something like :
[
[
{date: dateObject, isToday: false},
{date: dateObject, isToday: false},
{date: dateObject, isToday: false},
{date: dateObject, isToday: false},
{date: dateObject, isToday: false},
{date: dateObject, isToday: false},
{date: dateObject, isToday: false}
],
...
]
/**
* generate an array containing all the day for a given month and year split by week
* SET `isToday` FOR THE RIGHT DATE
* @param {number} month the month number, 0 based
* @param {number} year the year, not zero based, required to account for leap years
* @returns {calendarData} list with date objects for each day of the month
*/
public getDaysInMonthSplitByWeek(month: number, year: number): calendarData {
const dayInMonth: DateTime[] = this.getDaysInMonth(month, year);
const result: calendarData = [];
let tempArray: weekData = [];
// create the data for the current month
dayInMonth.reduce((previousValue: DateTime, currentValue: DateTime, currentIndex: number) => {
// compare two string without the hours
const isToday: boolean = currentValue.toLocaleString(DateTime.DATE_SHORT) === DateTime.local().toLocaleString(DateTime.DATE_SHORT);
tempArray.push({ date: currentValue, isToday });
if (currentValue.weekday === 7 || currentIndex === dayInMonth.length - 1) { // sunday or end of the current month
result.push(tempArray);
tempArray = [];
}
return currentValue;
}, dayInMonth[0]);
return result;
}
We start with a call to the previous function we have made, and we init two arrays :
- result for the final result
- and tempArray to work with into the function
Next, I used a very cool function called reduce given by the array (check the doc for more information). This part of the code create an object containing a key called “isToday” and an index called “date”. The first is a boolean with true or false depending on if the current data is equal to today date, and the second is for stocking the date object.
The “if” allows the reduce function to split each week into an array.
And finally we return the result.
Notice the “calendarData” type created for the occasion :
export interface dayData {
date: DateTime
isToday: boolean
}
export type weekData = dayData[]
export type calendarData = weekData[]
Now we need to complete each week like I said at the beginning of this chapter.
For that, we need to create two small functions with very similar code. Note that we have the possibility to refactor this code for having only one function and maybe replacing the for loop with a reduce.
The two following functions are pretty straight forward, so I won’t go into much detail ; but basically each function calculate the missing day and add the right data for each week. With some special treatment if the current month is January or December. Because if it’s the case, we need to add or remove a year.
/**
* complete an array of date with the missing date of the previous month
* e.g. : you have the first week of a month but the week, start on wednesday, the function add monday and tuesday
* @param {weekData} data the array you want to clean-up
* @param {number} currentYear the current year
* @param {number} currentMonth the current month
* @returns {weekData} the update arrays, if needed
*/
public prependMissingDay(data: weekData, currentYear: number, currentMonth: number): weekData {
if (data.length === 7) return data; // if the week is full, no needs
const numberOfMissingDay: number = 7 - data.length;
let m = currentMonth - 1; // in most case we want the previous month
let y = currentYear; // for the current year
if (currentMonth === 1) { // if the current month is january
m = 12;
y = currentYear - 1;
}
const numberOfDayInThePreviousMonth = DateTime.local(y, m).daysInMonth;
// add the missing day
if (numberOfDayInThePreviousMonth !== undefined) {
for (let i: number = 0; i < numberOfMissingDay; i++) {
data = [{
date: DateTime.local(y, m, numberOfDayInThePreviousMonth - i),
isToday: false
}, ...data];
}
}
return data;
}
/**
* complete an array of date with the missing date of the next month
* e.g. : you have the last week of a month but the week, end on friday, the function add saturday and sunday
* @param {weekData} data the array you want to clean-up
* @param {number} currentYear the current year
* @param {number} currentMonth the current month
* @returns {weekData} the update arrays, if needed
*/
public appendMissingDay(data: weekData, currentYear: number, currentMonth: number): weekData {
if (data.length === 7) return data; // if the week is full, no needs
const numberOfMissingDay: number = 7 - data.length;
let m = currentMonth + 1; // in most case we want the next month
let y = currentYear; // for the current year
if (currentMonth === 12) { // if the current month is december
m = 1;
y = currentYear + 1;
}
// add the missing day
for (let i: number = 0; i < numberOfMissingDay; i++) {
data = [...data, {
date: DateTime.local(y, m, 1 + i),
isToday: false
}];
}
return data;
}
And now we just have to add these two functions into our previous main function, like so :
/**
* generate an array containing all the day for a given month and year split by week
* SET `isToday` FOR THE RIGHT DATE
* @param {number} month the month number, 0 based
* @param {number} year the year, not zero based, required to account for leap years
* @param {boolean} complete default to true, if you want to complete the first and last week, for having seven days in each week
* @returns {calendarData} list with date objects for each day of the month
*/
public getDaysInMonthSplitByWeek(month: number, year: number, complete: boolean = true): calendarData {
const dayInMonth: DateTime[] = this.getDaysInMonth(month, year);
const result: calendarData = [];
let tempArray: weekData = [];
// create the data for the current month
dayInMonth.reduce((previousValue: DateTime, currentValue: DateTime, currentIndex: number) => {
// compare two string without the hours
const isToday: boolean = currentValue.toLocaleString(DateTime.DATE_SHORT) === DateTime.local().toLocaleString(DateTime.DATE_SHORT);
tempArray.push({ date: currentValue, isToday });
if (currentValue.weekday === 7 || currentIndex === dayInMonth.length - 1) { // sunday or end of the current month
result.push(tempArray);
tempArray = [];
}
return currentValue;
}, dayInMonth[0]);
if (complete) {
// if the first week is less than 7 day add the previous month days
result[0] = this.prependMissingDay(result[0], year, month);
// if the last week is less than 7 day add the next month days
const lastElem = result.length - 1;
result[lastElem] = this.appendMissingDay(result[lastElem], year, month);
}
return result;
}
I have chosen to add a parameter into the function called “complete” because if I need to deactivate this completion, now I have the ability to do so without refactoring the code.
Ok ! Now we have our data, so let’s inject it into our flatlist. For that, we create a small function called “getCurrentMonth” :
/**
* get an array of date for the current month split by week
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getCurrentMonth(): calendarData {
const now: DateTime = DateTime.now();
return DateServ.getInstance().getDaysInMonthSplitByWeek(now.month, now.year);
}
Nothing special here, this is just a call for our previous date function.
Here is the full code with the React state and the injection into the flatlist :
import React, { type ReactElement, useEffect, useState } from 'react';
import { Box, Center, FlatList, HStack } from 'native-base';
import { Dimensions, type ScaledSize } from 'react-native';
import { type calendarData } from '../types/interfaces';
import { DateTime } from 'luxon';
import DateServ from '../services/date';
const RowCalendar = (): ReactElement => {
const windowDimensions: ScaledSize = Dimensions.get('window');
const [dayItemWidth, setDayItemWidth] = useState<number>(0);
const [dateData, setDateData] = useState<calendarData | undefined>(undefined);
/**
* calculate the item dimension for one day, we want the total width split by seven day
* load up the data for the date when the user arrived to this screen
*/
useEffect((): void => {
setDayItemWidth(windowDimensions.width / 7);
setDateData(getCurrentMonth());
}, []);
/**
* get an array of date for the current month split by week
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getCurrentMonth(): calendarData {
const now: DateTime = DateTime.now();
return DateServ.getInstance().getDaysInMonthSplitByWeek(now.month, now.year);
}
/**
* render the week day
* @returns {ReactElement} the Element itself
*/
function generateCurrentWeek(): ReactElement[] {
return ['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((value: string, index: number) => {
return (
<Center key={index} h={'10'} w={dayItemWidth} bg={'primary.500'}>
{value}
</Center>
);
});
}
return (
<Box>
<HStack justifyContent='center'>
{generateCurrentWeek()}
</HStack>
{(dateData !== undefined) && <FlatList
horizontal={true}
data={dateData}
keyExtractor={(item: weekData, index: number): string => index.toString()}
/>}
</Box>
);
};
export default RowCalendar;
The following step is pretty simple : we need to render our flatlist !
Render each day
Ok for that nothing fancy, we render each day into a box, and we set the background color to red if the day is tagged “isToday” :
/**
* render the component for the day row for the `flatlist`
* @param {weekData} week array luxon DateTime
* @returns {ReactElement[]} the Element itself
*/
function dayComponent(week: weekData): ReactElement[] {
return week.map((value: dayData, index: number) => {
return (
<Center
key={value.date.toString()} h={'10'} w={dayItemWidth}
bg={value.isToday ? 'red.200' : `primary.${index + 1}00`}
>
{value.date.day}
</Center>
);
});
}
Into the flastlist that make the following code :
{(dateData !== undefined) && <FlatList
horizontal={true}
data={dateData}
keyExtractor={(item: weekData, index: number): string => index.toString()}
// for some reason the type accept only ReactElement and not ReactElement[] so I put the return into this ugly `Fragment`
renderItem={(week: ListRenderItemInfo<weekData>): ReactElement => <Fragment>{dayComponent(week.item)}</Fragment>}
/>}
Now we have the following result :
But we have two problems too 🙂
The scroll is not week-by-week, and the initial load does not position itself on today.
For the first problem, we have to use the flatlist property “snapToInterval”. This property give the ability to the flatlist to understand the width of the element you want to set up.
For example, if you want to scroll two day by two day this is totally possible, same if you want to scroll month-by-month 🤷. Here our need is more classic, we need to scroll week-by-week :
{(dateData !== undefined) && <FlatList
horizontal={true}
data={dateData}
keyExtractor={(item: weekData, index: number): string => index.toString()}
// for some reason the type accept only ReactElement and not ReactElement[] so I put the return into this ugly `Fragment`
renderItem={(week: ListRenderItemInfo<weekData>): ReactElement => <Fragment>{dayComponent(week.item)}</Fragment>}
snapToAlignment={'start'}
snapToInterval={windowDimensions.width} // set the swap on the whole elem, like so the user switch week by week
decelerationRate={'fast'} // better feedback for the user, the ui stop on the next/previous week and not later
/>}
The other property allows a better feedback for the user.
Ok ! Now we need to tell the flatlist that we need the current week for the first render. For that we made a little function that return the index of the only date that have “isToday = true” and we return that index to the flatlist :
const [todayIndex, setTodayIndex] = useState<number>(0);
/**
* calculate today index and set it up
*/
useEffect((): void => {
if (dateData !== undefined) setTodayIndex(getTodayIndex(dateData));
}, [dateData]);
/**
* find the current date sub array into `calendarData`
* @param {calendarData} dateArray date array
* @returns {number} the index of the current week
*/
function getTodayIndex(dateArray: calendarData): number {
return dateArray.findIndex(
(item: weekData): boolean => {
// if the checked sub object doesn't return -1 the object contain the today date
return item.findIndex((subItem: dayData): boolean => {
return subItem.isToday;
}) !== -1;
});
}
And in the flatlist we just add the following parameter :
initialScrollIndex={todayIndex}
// `getItemLayout` is needed by `initialScrollIndex` to work
getItemLayout={(data: calendarData | null | undefined, index: number): { length: number, offset: number, index: number } => ({
length: windowDimensions.width, offset: windowDimensions.width * index, index
})}
And voilà !
The gif may take a moment to show the result.
The code at this moment looks like that :
import React, { Fragment, type ReactElement, useEffect, useState } from 'react';
import { Box, Center, FlatList, HStack } from 'native-base';
import { Dimensions, type ListRenderItemInfo, type ScaledSize } from 'react-native';
import { type calendarData, type dayData, type weekData } from '../types/interfaces';
import { DateTime } from 'luxon';
import DateServ from '../services/date';
const RowCalendar = (): ReactElement => {
const windowDimensions: ScaledSize = Dimensions.get('window');
const [dayItemWidth, setDayItemWidth] = useState<number>(0);
const [dateData, setDateData] = useState<calendarData | undefined>(undefined);
const [todayIndex, setTodayIndex] = useState<number>(0);
/**
* calculate the item dimension for one day, we want the total width split by seven day
*/
useEffect((): void => {
setDayItemWidth(windowDimensions.width / 7);
setDateData(getCurrentMonth());
}, []);
/**
* calculate today index and set it up
*/
useEffect((): void => {
if (dateData !== undefined) setTodayIndex(getTodayIndex(dateData));
}, [dateData]);
/**
* get an array of date for the current month split by week
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getCurrentMonth(): calendarData {
const now: DateTime = DateTime.now();
return DateServ.getInstance().getDaysInMonthSplitByWeek(now.month, now.year);
}
/**
* render the week day
* @returns {ReactElement} the Element itself
*/
function generateCurrentWeek(): ReactElement[] {
return ['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((value: string, index: number) => {
return (
<Center key={index} h={'10'} w={dayItemWidth} bg={'primary.500'}>
{value}
</Center>
);
});
}
/**
* render the component for the day row for the `flatlist`
* @param {weekData} week array luxon DateTime
* @returns {ReactElement[]} the Element itself
*/
function dayComponent(week: weekData): ReactElement[] {
return week.map((value: dayData, index: number) => {
return (
<Center
key={value.date.toString()} h={'10'} w={dayItemWidth}
bg={value.isToday ? 'red.200' : `primary.${index + 1}00`}
>
{value.date.day }
</Center>
);
});
}
/**
* find the current date sub array into `calendarData`
* @param {calendarData} dateArray date array
* @returns {number} the index of the current week
*/
function getTodayIndex(dateArray: calendarData): number {
return dateArray.findIndex(
(item: weekData): boolean => {
// if the checked sub object doesn't return -1 the object contain the today date
return item.findIndex((subItem: dayData): boolean => {
return subItem.isToday;
}) !== -1;
});
}
return (
<Box>
<HStack justifyContent='center'>
{generateCurrentWeek()}
</HStack>
{(dateData !== undefined) && <FlatList
horizontal={true}
data={dateData}
// for some reason the type accept only ReactElement and not ReactElement[] so I put the return into this ugly `Fragment`
renderItem={(week: ListRenderItemInfo<weekData>): ReactElement => <Fragment>{dayComponent(week.item)}</Fragment>}
snapToAlignment={'start'}
snapToInterval={windowDimensions.width} // set the swap on the whole elem, like so the user switch week by week
decelerationRate={'fast'} // better feedback for the user, the ui stop on the next/previous week and not later
initialScrollIndex={todayIndex}
// `getItemLayout` is needed by `initialScrollIndex` to work
getItemLayout={(data: calendarData | null | undefined, index: number): { length: number, offset: number, index: number } => ({
length: windowDimensions.width, offset: windowDimensions.width * index, index
})}
keyExtractor={(item: weekData, index: number): string => index.toString()}
/>}
</Box>
);
};
export default RowCalendar;
Generate next values
So; now we have a fully functional flatlist but when we reach the end, or the start, nothing happens. Ideally, we want to have another month loaded into the list.
For now, we are keeping focus on the next month loaded, when the user reaches the right side of the flatlist.
Because our generating function for the date complete the ending week, we need to build a function that understand if the next month have data already loaded or not :
For this purpose, the flatlist element have an “onEndReached” parameter that allows us to trigger some function when the user reaches the end :
onEndReachedThreshold={0.5}
onEndReached={(): void => { appendData(); }}
Here, the “onEndReachedThreshold” parameter tells the flatlist that “onEndReached” is triggered when at least 50% (1 = 100%) of the last element is shown.
“appendData” function is build like this, I have put comment on it if you want to understand each part 🙂 :
/**
* append data to `dateData`
* @returns {void}
*/
function appendData(): void {
// in this function I assume that the last week is completed (see `getDaysInMonthSplitByWeek` function)
// get the last date show to the user, the [6] represent sunday
if (dateData === undefined) return;
const lastDate: dayData = dateData[dateData?.length - 1][6];
// get the size of the last date month
const lastDateMonthSize: PossibleDaysInMonth | undefined = lastDate.date.daysInMonth;
// if the last date show to the user is equal to the length of the month we need the next month
// (that mean the date is the last date of the month) otherwise we need the same month
let nextDateData;
if (lastDateMonthSize === lastDate.date.day) {
// use a new object for working (luxon date are immutable, so I use a `let` for avoiding working with a lot of unuseful `const`)
let d: DateTime = DateTime.local(lastDate.date.year, lastDate.date.month, lastDate.date.day);
d = d.plus({ month: 1 });
// if the date is also the last of the year we need to add one year
if (d.month === 12 && d.day === 31) {
// set the date to 1 january d.year+1
d = d.plus({ year: 1 });
d = d.set({ month: 1, day: 1 });
}
nextDateData = getAMonth(d);
} else {
nextDateData = getAMonth(lastDate.date);
// because `getDaysInMonthSplitByWeek` function add days into the first and last week for having a full first and last week (with 7 days)
// we need to remove the first week of the new data because the data is already present into `dateData`
nextDateData.shift();
}
setDateData([...dateData, ...nextDateData]);
}
The “getAMonth” function called in it is simply a wrapper for the “getDaysInMonthSplitByWeek” function :
/**
* get an array of date representing the month for the given date
* @param {DateTime} date luxon date object for the month you want -- day is doesn't used
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getAMonth(date: DateTime): calendarData {
return DateServ.getInstance().getDaysInMonthSplitByWeek(date.month, date.year);
}
So here the result :
All the code for this part :
import React, { Fragment, type ReactElement, useEffect, useState } from 'react';
import { Box, Center, FlatList, HStack } from 'native-base';
import { Dimensions, type ListRenderItemInfo, type ScaledSize } from 'react-native';
import { type calendarData, type dayData, type weekData } from '../types/interfaces';
import { DateTime, type PossibleDaysInMonth } from 'luxon';
import DateServ from '../services/date';
const RowCalendar = (): ReactElement => {
const windowDimensions: ScaledSize = Dimensions.get('window');
const [dayItemWidth, setDayItemWidth] = useState<number>(0);
const [dateData, setDateData] = useState<calendarData | undefined>(undefined);
const [todayIndex, setTodayIndex] = useState<number>(0);
/**
* calculate the item dimension for one day, we want the total width split by seven day
*/
useEffect((): void => {
setDayItemWidth(windowDimensions.width / 7);
setDateData(getCurrentMonth());
}, []);
/**
* calculate today index and set it up
*/
useEffect((): void => {
if (dateData !== undefined) setTodayIndex(getTodayIndex(dateData));
}, [dateData]);
/**
* get an array of date for the current month split by week
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getCurrentMonth(): calendarData {
const now: DateTime = DateTime.now();
return DateServ.getInstance().getDaysInMonthSplitByWeek(now.month, now.year);
}
/**
* render the week day
* @returns {ReactElement} the Element itself
*/
function generateCurrentWeek(): ReactElement[] {
return ['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((value: string, index: number) => {
return (
<Center key={index} h={'10'} w={dayItemWidth} bg={'primary.500'}>
{value}
</Center>
);
});
}
/**
* render the component for the day row for the `flatlist`
* @param {weekData} week array luxon DateTime
* @returns {ReactElement[]} the Element itself
*/
function dayComponent(week: weekData): ReactElement[] {
return week.map((value: dayData, index: number) => {
return (
<Center
key={value.date.toString()} h={'10'} w={dayItemWidth}
bg={value.isToday ? 'red.200' : `primary.${index + 1}00`}
>
{value.date.day }
</Center>
);
});
}
/**
* find the current date sub array into `calendarData`
* @param {calendarData} dateArray date array
* @returns {number} the index of the current week
*/
function getTodayIndex(dateArray: calendarData): number {
return dateArray.findIndex(
(item: weekData): boolean => {
// if the checked sub object doesn't return -1 the object contain the today date
return item.findIndex((subItem: dayData): boolean => {
return subItem.isToday;
}) !== -1;
});
}
/**
* get an array of date representing the month for the given date
* @param {DateTime} date luxon date object for the month you want -- day is doesn't used
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getAMonth(date: DateTime): calendarData {
return DateServ.getInstance().getDaysInMonthSplitByWeek(date.month, date.year);
}
/**
* append data to `dateData`
* @returns {void}
*/
function appendData(): void {
// in this function I assume that the last week is (maybe) completed (see `getDaysInMonthSplitByWeek` function)
// get the last date show to the user, the [6] represent sunday
if (dateData === undefined) return;
const lastDate: dayData = dateData[dateData?.length - 1][6];
// get the size of the last date month
const lastDateMonthSize: PossibleDaysInMonth | undefined = lastDate.date.daysInMonth;
// if the last date show to the user is equal to the length of the month we need the next month
// (that mean the date is the last date of the month) otherwise we need the same month
let nextDateData;
if (lastDateMonthSize === lastDate.date.day) {
// use a new object for working (luxon date are immutable, so I use a `let` for avoiding working with a lot of unuseful `const`)
let d: DateTime = DateTime.local(lastDate.date.year, lastDate.date.month, lastDate.date.day);
d = d.plus({ month: 1 });
// if the date is also the last of the year we need to add one year
if (d.month === 12 && d.day === 31) {
// set the date to 1 january d.year+1
d = d.plus({ year: 1 });
d = d.set({ month: 1, day: 1 });
}
nextDateData = getAMonth(d);
} else {
nextDateData = getAMonth(lastDate.date);
// because `getDaysInMonthSplitByWeek` function add days into the first and last week for having a full first and last week (with 7 days)
// we need to remove the first week of the new data because the data is already present into `dateData`
nextDateData.shift();
}
setDateData([...dateData, ...nextDateData]);
}
return (
<Box>
<HStack justifyContent='center'>
{generateCurrentWeek()}
</HStack>
{(dateData !== undefined) && <FlatList
horizontal={true}
data={dateData}
// for some reason the type accept only ReactElement and not ReactElement[] so I put the return into this ugly `Fragment`
renderItem={(week: ListRenderItemInfo<weekData>): ReactElement => <Fragment>{dayComponent(week.item)}</Fragment>}
snapToAlignment={'start'}
snapToInterval={windowDimensions.width} // set the swap on the whole elem, like so the user switch week by week
decelerationRate={'fast'} // better feedback for the user, the ui stop on the next/previous week and not later
initialScrollIndex={todayIndex}
// `getItemLayout` is needed by `initialScrollIndex` to work
getItemLayout={(data: calendarData | null | undefined, index: number): { length: number, offset: number, index: number } => ({
length: windowDimensions.width, offset: windowDimensions.width * index, index
})}
keyExtractor={(item: weekData, index: number): string => index.toString()}
onEndReachedThreshold={0.5}
onEndReached={(): void => { appendData(); }}
/>}
</Box>
);
};
export default RowCalendar;
Generate previous values
This part is practically the same that the chapter before 🙂
We have the same “problem” but with the first week :
But first we have another problem: flatlist provides a parameter to detect when the user has reached the end, but not to detect when the user has reached the beginning!!
For this particular problem we have, hopefully, a workaround with the “onScroll” parameter :
// use `onScroll` to handle the data when the user reach the start
onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>): void => { handleScroll(event); }}
The “onScroll” event gives us the ability to read the real value of the scroll offset, with this value we just have a little math to do and, voilà, we have our “detector” 🙃 :
/**
* check if the user is at the start of the scroll list and fetch data if so
* @param {NativeSyntheticEvent<NativeScrollEvent>} event scroll event
* @returns {void}
*/
function handleScroll(event: NativeSyntheticEvent<NativeScrollEvent>): void {
// if distanceFromStart.x === 0 we reach the start of the list
const distanceFromStart: NativeScrollPoint = event.nativeEvent.contentOffset;
if (distanceFromStart.x === 0) prependData();
}
Ok, now what happens in the “prependData” function :
/**
* prepend data to `dateData`
* @returns {void}
*/
function prependData(): void {
console.log('prepend');
}
Ho 🤨 ok give me some time, I forgot to create this function […]
Here we go :
/**
* prepend data to `dateData`
* @returns {void}
*/
function prependData(): void {
// in this function I assume that the first week is (maybe) completed (see `getDaysInMonthSplitByWeek` function)
// get the first date show to the user
// the first [0] represent the first week, the last [0] represent monday
if (dateData === undefined) return;
const firstDate: dayData = dateData[0][0];
// if the first date show to the user is equal to 1 we need the previous month
// (if 1 mean that monday is the first day of the current month, so we have a no completed week)
// otherwise we need the same month
let previousDateData;
if (firstDate.date.day === 1) {
// use a new object for working (luxon date are immutable, so I use a `let` for avoiding working with a lot of unuseful `const`)
let d: DateTime = DateTime.local(firstDate.date.year, firstDate.date.month, firstDate.date.day);
d = d.minus({ month: 1 });
// if the date is also the first of the year we need to remove one year
if (d.month === 1 && d.day === 1) {
// set the date to 1 december d.year-1
d = d.minus({ year: 1 });
d = d.set({ month: 12, day: 1 });
}
previousDateData = getAMonth(d);
} else {
previousDateData = getAMonth(firstDate.date);
// because `getDaysInMonthSplitByWeek` function add days into the first and last week for having a full first and last week (with 7 days)
// we need to remove the last week of the new data because the data is already present into `dateData`
previousDateData.pop();
}
setDateData([...previousDateData, ...dateData]);
}
Like I said before, this is practically the same as the “appendData” function 🙂
Ok but now we have a new problem : when the data is added the size of the list changed, that mean for the same index we have another data. The flatlist doesn’t move the user to the same index, but the data changed for the user :
For fixing that, we need to give the new index to the flatlist. We need to use the “useRef” hook !
We defined the reference :
const dateFlatList = useRef();
And we add the parameter on the flatlist itself :
<FlatList
ref={dateFlatList}
...
With this reference, we “just” have to calculate the new index. Let’s improve our “prependData” function, at the end of it :
// save the current index before adding any data
// and add the new array length for having the new index
let indexToMove: number = 0;
if (typeof currentObjectInfo?.viewableItems[0].index === 'number') {
indexToMove = currentObjectInfo?.viewableItems[0].index;
indexToMove = indexToMove + previousDateData.length;
}
// add the new data
setDateData([...previousDateData, ...dateData]);
// set the user on the right index
if (dateFlatList.current !== undefined) {
dateFlatList.current.scrollToIndex({ index: indexToMove, animated: false });
}
Here nothing too hard, we take the current index (before adding new data), next we add to this index the length of the new array, and, we have the new index 🙂
The “scrollToIndex” function gives us the opportunity to set the index for the user !
And the “animated” parameter is here very important. We need to set it to false, otherwise the user will see an animation when the function adds the new data.
The only thing I don’t have managed to get, yet, is the type of the flatlist for having the “scrollToIndex” proposed when I put the dot on the “dateFlatList.current” index.
Ideally we want it in the “useRef” like we have made to the other hook :
const dateFlatList = useRef<FlatListType | undefined>(undefined);
Bonus — display the current month and year
For the end of this series, I can give you a bonus tips : get the current displayed data !
But, what can it be used for ?
A small example :
With that, we can display to the user the current month and year, for example.
Another use case can be that you want to have a unique “add event” button that have the right week number automatically bind to it, for example. Just be creative !
For that, we have to use the “onViewableItemsChanged” parameter from the flatlist :
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
onViewableItemsChanged={onViewableItemsChanged.current}
Here we have two parameters :
- “viewabilityConfig” that tell when an item is considered visible, here 50 means that an item is considered visible if it’s visible for more than 50 percents
- “onViewableItemsChanged” that calls another react ref and sets the current displayed object into a state
const onViewableItemsChanged = useRef((info: onViewableItemsChangedInfo): void => { setCurrentObjectInfo(info); });
const [currentObjectInfo, setCurrentObjectInfo] = useState<onViewableItemsChangedInfo | undefined>(undefined);
And for the display part, nothing tedious :
{(currentObjectInfo !== undefined) && <Text>
{currentObjectInfo.viewableItems[0].item[0].date.monthLong} - {currentObjectInfo.viewableItems[0].item[0].date.year}
</Text>
And, voilà !
Bonus — Go to today button
Another thing we want in an app with calendar is a “today” button 🙂
Here, we just have to use the same function as before for navigating to “todayIndex”.
Note here that I have refactored the code for having only one function called “gotToIndex”.
So if you want to do the same thing, you need to update the “prependData” function to avoid duplicating the code !
/**
* set the given index for the flatlist
* @param {number} index the index you want the flatlist to be
* @param {boolean} isAnimated if you want an animated transition
*/
function gotToIndex(index: number, isAnimated: boolean): void {
if (dateFlatList.current !== undefined) {
// @ts-expect-error todo what type is used for the flatList ??
dateFlatList.current.scrollToIndex({ index, animated: isAnimated });
}
}
And in the render part :
<HStack alignItems={'center'} m={1}>
<Button size={'xs'} w={20} marginRight={2} onPress={() => { gotToIndex(todayIndex, true); }}>Today</Button>
{(currentObjectInfo !== undefined) && <Text>
{currentObjectInfo.viewableItems[0].item[0].date.monthLong} - {currentObjectInfo.viewableItems[0].item[0].date.year}
</Text>
}
</HStack>
The result is nice, no ?
Your next move now is certainly to update the color for something more “modern” and less blueish 🙂
See ya !
Hey don’t go yet 👋 Here’s all the code, with the bonuses 😉
import React, { Fragment, type ReactElement, useEffect, useRef, useState } from 'react';
import { Box, Button, Center, FlatList, HStack, Text } from 'native-base';
import DateServ from '../services/date';
import { DateTime, type PossibleDaysInMonth } from 'luxon';
import {
Dimensions,
type ListRenderItemInfo,
type NativeScrollEvent, type NativeScrollPoint,
type NativeSyntheticEvent,
type ScaledSize
} from 'react-native';
import type { calendarData, dayData, weekData, onViewableItemsChangedInfo } from '../types/interfaces';
const RowCalendar = (): ReactElement => {
const windowDimensions: ScaledSize = Dimensions.get('window');
const dayItemWidth = useRef<number>(0);
const dateFlatList = useRef();
const onViewableItemsChanged = useRef((info: onViewableItemsChangedInfo): void => { setCurrentObjectInfo(info); });
const [dateData, setDateData] = useState<calendarData | undefined>(undefined);
const [todayIndex, setTodayIndex] = useState<number>(0);
const [currentObjectInfo, setCurrentObjectInfo] = useState<onViewableItemsChangedInfo | undefined>(undefined);
const [flatListRefreshing, setFlatListRefreshing] = useState<boolean>(false);
/**
* calculate the item dimension for one day, we want the total width split by seven day
* load up the data for the date when the user arrived to this screen
*/
useEffect((): void => {
dayItemWidth.current = windowDimensions.width / 7;
setDateData(getCurrentMonth());
}, []);
/**
* calculate today index and set it up
*/
useEffect((): void => {
if (dateData !== undefined) setTodayIndex(getTodayIndex(dateData));
}, [dateData]);
/**
* find the current date sub array into `calendarData`
* @param {calendarData} dateArray date array
* @returns {number} the index of the current week
*/
function getTodayIndex(dateArray: calendarData): number {
return dateArray.findIndex(
(item: weekData): boolean => {
// if the checked sub object doesn't return -1 the object contain the today date
return item.findIndex((subItem: dayData): boolean => {
return subItem.isToday;
}) !== -1;
});
}
/**
* render the week day
* @returns {ReactElement} the Element itself
*/
function generateCurrentWeek(): ReactElement[] {
return ['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((value: string, index: number) => {
return (
<Center key={index} h={'10'} w={dayItemWidth.current} bg={'primary.500'}>
{value}
</Center>
);
});
}
/**
* get an array of date for the current month split by week
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getCurrentMonth(): calendarData {
setFlatListRefreshing(true);
const now: DateTime = DateTime.now();
const data = DateServ.getInstance().getDaysInMonthSplitByWeek(now.month, now.year);
setFlatListRefreshing(false);
return data;
}
/**
* get an array of date representing the month for the given date
* @param {DateTime} date luxon date object for the month you want -- day is doesn't used
* @returns {calendarData} array of array of luxon DateTime, each sub array is a week
*/
function getAMonth(date: DateTime): calendarData {
return DateServ.getInstance().getDaysInMonthSplitByWeek(date.month, date.year);
}
/**
* render the component for the day row for the `flatlist`
* @param {weekData} week array luxon DateTime
* @returns {ReactElement[]} the Element itself
*/
function dayComponent(week: weekData): ReactElement[] {
return week.map((value: dayData, index: number) => {
return (
<Center
key={value.date.toString()} h={'10'} w={dayItemWidth.current}
bg={value.isToday ? 'red.200' : `primary.${index + 1}00`}
>
{value.date.day}
</Center>
);
});
}
/**
* check if the user is at the start of the scroll list and fetch data if so
* @param {NativeSyntheticEvent<NativeScrollEvent>} event scroll event
* @returns {void}
*/
function handleScroll(event: NativeSyntheticEvent<NativeScrollEvent>): void {
// if distanceFromStart.x === 0 we reach the start of the list
const distanceFromStart: NativeScrollPoint = event.nativeEvent.contentOffset;
if (distanceFromStart.x === 0) prependData();
}
/**
* set the given index for the flatlist
* @param {number} index the index you want the flatlist to be
* @param {boolean} isAnimated if you want an animated transition
*/
function gotToIndex(index: number, isAnimated: boolean): void {
if (dateFlatList.current !== undefined) {
// @ts-expect-error todo what type is used for the flatList ??
dateFlatList.current.scrollToIndex({ index, animated: isAnimated });
}
}
/**
* prepend data to `dateData`
* @returns {void}
*/
function prependData(): void {
// in this function I assume that the first week is (maybe) completed (see `getDaysInMonthSplitByWeek` function)
setFlatListRefreshing(true);
// get the first date show to the user
// the first [0] represent the first week, the last [0] represent monday
if (dateData === undefined) return;
const firstDate: dayData = dateData[0][0];
// if the first date show to the user is equal to 1 we need the previous month
// (if 1 mean that monday is the first day of the current month, so we have a no completed week)
// otherwise we need the same month
let previousDateData;
if (firstDate.date.day === 1) {
// use a new object for working (luxon date are immutable, so I use a `let` for avoiding working with a lot of unuseful `const`)
let d: DateTime = DateTime.local(firstDate.date.year, firstDate.date.month, firstDate.date.day);
d = d.minus({ month: 1 });
// if the date is also the first of the year we need to remove one year
if (d.month === 1 && d.day === 1) {
// set the date to 1 december d.year-1
d = d.minus({ year: 1 });
d = d.set({ month: 12, day: 1 });
}
previousDateData = getAMonth(d);
} else {
previousDateData = getAMonth(firstDate.date);
// because `getDaysInMonthSplitByWeek` function add days into the first and last week for having a full first and last week (with 7 days)
// we need to remove the last week of the new data because the data is already present into `dateData`
previousDateData.pop();
}
// save the current index before adding any data
// and add the new array length for having the new index
let indexToMove: number = 0;
if (typeof currentObjectInfo?.viewableItems[0].index === 'number') {
indexToMove = currentObjectInfo?.viewableItems[0].index;
indexToMove = indexToMove + previousDateData.length;
}
setDateData([...previousDateData, ...dateData]);
gotToIndex(indexToMove, false);
setFlatListRefreshing(false);
}
/**
* append data to `dateData`
* @returns {void}
*/
function appendData(): void {
// in this function I assume that the last week is (maybe) completed (see `getDaysInMonthSplitByWeek` function)
setFlatListRefreshing(true);
// get the last date show to the user, the [6] represent sunday
if (dateData === undefined) return;
const lastDate: dayData = dateData[dateData?.length - 1][6];
// get the size of the last date month
const lastDateMonthSize: PossibleDaysInMonth | undefined = lastDate.date.daysInMonth;
// if the last date show to the user is equal to the length of the month we need the next month
// (that mean the date is the last date of the month) otherwise we need the same month
let nextDateData;
if (lastDateMonthSize === lastDate.date.day) {
// use a new object for working (luxon date are immutable, so I use a `let` for avoiding working with a lot of unuseful `const`)
let d: DateTime = DateTime.local(lastDate.date.year, lastDate.date.month, lastDate.date.day);
d = d.plus({ month: 1 });
// if the date is also the last of the year we need to add one year
if (d.month === 12 && d.day === 31) {
// set the date to 1 january d.year+1
d = d.plus({ year: 1 });
d = d.set({ month: 1, day: 1 });
}
nextDateData = getAMonth(d);
} else {
nextDateData = getAMonth(lastDate.date);
// because `getDaysInMonthSplitByWeek` function add days into the first and last week for having a full first and last week (with 7 days)
// we need to remove the first week of the new data because the data is already present into `dateData`
nextDateData.shift();
}
setDateData([...dateData, ...nextDateData]);
setFlatListRefreshing(false);
}
return (
<Box>
<HStack alignItems={'center'} m={1}>
<Button size={'xs'} w={20} marginRight={2} onPress={() => { gotToIndex(todayIndex, true); }}>Today</Button>
{(currentObjectInfo !== undefined) && <Text>
{currentObjectInfo.viewableItems[0].item[0].date.monthLong} - {currentObjectInfo.viewableItems[0].item[0].date.year}
</Text>
}
</HStack>
<HStack justifyContent={'center'}>
{generateCurrentWeek()}
</HStack>
{(dateData !== undefined) && <FlatList
ref={dateFlatList}
refreshing={flatListRefreshing}
horizontal={true}
snapToAlignment={'start'}
snapToInterval={windowDimensions.width} // set the swap on the whole elem, like so the user switch week by week
decelerationRate={'fast'} // better feedback for the user, the ui stop on the next/previous week and not later
data={dateData}
initialScrollIndex={todayIndex}
// `getItemLayout` is needed by `initialScrollIndex` to work
getItemLayout={(data: calendarData | null | undefined, index: number): { length: number, offset: number, index: number } => ({
length: windowDimensions.width, offset: windowDimensions.width * index, index
})}
keyExtractor={(item: weekData, index: number): string => index.toString()}
// for some reason the type accept only ReactElement and not ReactElement[] so I put the return into this ugly `Fragment`
renderItem={(week: ListRenderItemInfo<weekData>): ReactElement => <Fragment>{dayComponent(week.item)}</Fragment>}
// use `onScroll` to handle the data when the user reach the start
onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>): void => { handleScroll(event); }}
onEndReachedThreshold={0.5}
onEndReached={(): void => { appendData(); }}
// 50 means that item is considered visible if it's visible for more than 50 percents
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
onViewableItemsChanged={onViewableItemsChanged.current}
/>}
</Box>
);
};
export default RowCalendar;