Skip to content

Commit 9bf22ed

Browse files
[feat]: Add ability to scroll to a week row (#37)
* Add ability to scroll to a week row * Address date feedback * Addresses scrollToMonth API feedback * Update docs * Fix the need for an additionalOffset * Add changeset --------- Co-authored-by: Marcelo T Prado <[email protected]>
1 parent 9ef657b commit 9bf22ed

File tree

9 files changed

+224
-43
lines changed

9 files changed

+224
-43
lines changed

.changeset/shiny-pigs-talk.md

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@marceloterreiro/flash-calendar": major
3+
---
4+
5+
# Flash Calendar 1.0.0 🚢 🎉
6+
7+
This release officially marks the package as ready for production use (`1.0.0`).
8+
While it's been stable since the first release, bumping to `1.0.0` was something
9+
I had in mind for a while.
10+
11+
- New: Add `.scrollToMonth` and `.scrollToDate`, increasing the options available for imperative scrolling.
12+
13+
## Breaking changes
14+
15+
This release introduces one slightly change in behavior if you're app uses
16+
imperative scrolling. Previously, `.scrollToDate` would scroll to the month
17+
containing the date instead of the exact date. Now, `.scrollToDate` will scroll
18+
to the exact date as the name suggests.
19+
20+
If you intentionally want to scroll to the month instead, a new `.scrollToMonth`
21+
method was added (same signature).
22+
23+
I don't expect this to cause any issues for existing apps, but worth mentioned
24+
nonetheless.

apps/docs/docs/fundamentals/tips-and-tricks.mdx

+16-5
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ These two convertions functions were [battle-tested](https://github.com/MarceloP
4848

4949
## Programmatically scrolling to a date
5050

51-
Flash Calendar exposes a `ref` that allows imperative scrolling to a desired date.
51+
Flash Calendar exposes a `ref` that allows imperative scrolling to a desired
52+
date (`.scrollToDate`), a month (`.scrollToMonth`), or an offset
53+
(`.scrollToOffset`).
5254

5355
<HStack spacing={24} alignItems="flex-start">
5456

@@ -63,17 +65,20 @@ import { Button, Text, View } from "react-native";
6365

6466
export function ImperativeScrolling() {
6567
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));
66-
6768
const ref = useRef<CalendarListRef>(null);
6869

70+
const onCalendarDayPress = useCallback((dateId: string) => {
71+
ref.current?.scrollToDate(fromDateId(dateId), true);
72+
}, []);
73+
6974
return (
7075
<View style={{ paddingTop: 20, flex: 1 }}>
7176
<View style={{ flexDirection: "row", gap: 12 }}>
7277
<Button
7378
onPress={() => {
7479
const pastMonth = subMonths(currentMonth, 1);
7580
setCurrentMonth(pastMonth);
76-
ref.current?.scrollToDate(pastMonth, true);
81+
ref.current?.scrollToMonth(pastMonth, true);
7782
}}
7883
title="Past month"
7984
/>
@@ -82,15 +87,15 @@ export function ImperativeScrolling() {
8287
onPress={() => {
8388
const nextMonth = addMonths(currentMonth, 1);
8489
setCurrentMonth(nextMonth);
85-
ref.current?.scrollToDate(nextMonth, true);
90+
ref.current?.scrollToMonth(nextMonth, true);
8691
}}
8792
title="Next month"
8893
/>
8994
</View>
9095
<View style={{ flex: 1, width: "100%" }}>
9196
<Calendar.List
9297
calendarInitialMonthId={toDateId(currentMonth)}
93-
onCalendarDayPress={(dateId) => console.log(`Pressed ${dateId}`)}
98+
onCalendarDayPress={onCalendarDayPress}
9499
ref={ref}
95100
/>
96101
</View>
@@ -107,6 +112,12 @@ export function ImperativeScrolling() {
107112

108113
</HStack>
109114

115+
You can also pass an `additionalOffset` to fine-tune the scroll position:
116+
117+
```tsx
118+
.scrollToDate(fromDateId("2024-07-01"), true, { additionalOffset: 4 })
119+
```
120+
110121
## Setting Border Radius to `Calendar.Item.Day`
111122

112123
To apply a border radius to the `Calendar.Item.Day` component, it's necessary to
1.96 MB
Binary file not shown.

kitchen-sink/expo/src/App.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { CalendarListDemo } from "./CalendarList";
99
import { BottomSheetCalendar } from "./BottomSheetCalendar";
1010
import { CalendarCustomFormatting } from "./CalendarCustomFormatting";
1111
import { ImperativeScrolling } from "./ImperativeScroll";
12-
import { SlowExampleAddressed } from "./SlowExampleAddressed";
12+
// import { SlowExampleAddressed } from "./SlowExampleAddressed";
1313

1414
export default function App() {
1515
const [demo, setDemo] = useState<"calendar" | "calendarList">("calendar");
@@ -33,10 +33,9 @@ export default function App() {
3333
3434
{demo === "calendar" ? <CalendarDemo /> : <CalendarListDemo />}
3535
</View> */}
36-
{/* <ImperativeScrolling /> */}
37-
{/* <ImperativeScrolling /> */}
36+
<ImperativeScrolling />
3837
{/* <BottomSheetCalendar /> */}
39-
<SlowExampleAddressed />
38+
{/* <SlowExampleAddressed /> */}
4039
</SafeAreaView>
4140
</GestureHandlerRootView>
4241
);

kitchen-sink/expo/src/ImperativeScroll.tsx

+13-6
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
import { addMonths, subMonths, startOfMonth } from "date-fns";
22
import type { CalendarListRef } from "@marceloterreiro/flash-calendar";
3-
import { Calendar, toDateId } from "@marceloterreiro/flash-calendar";
4-
import { useRef, useState } from "react";
3+
import {
4+
Calendar,
5+
toDateId,
6+
fromDateId,
7+
} from "@marceloterreiro/flash-calendar";
8+
import { useCallback, useRef, useState } from "react";
59
import { Button, Text, View } from "react-native";
610

711
export function ImperativeScrolling() {
812
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));
9-
1013
const ref = useRef<CalendarListRef>(null);
1114

15+
const onCalendarDayPress = useCallback((dateId: string) => {
16+
ref.current?.scrollToDate(fromDateId(dateId), true);
17+
}, []);
18+
1219
return (
1320
<View style={{ paddingTop: 20, flex: 1 }}>
1421
<View style={{ flexDirection: "row", gap: 12 }}>
1522
<Button
1623
onPress={() => {
1724
const pastMonth = subMonths(currentMonth, 1);
1825
setCurrentMonth(pastMonth);
19-
ref.current?.scrollToDate(pastMonth, true);
26+
ref.current?.scrollToMonth(pastMonth, true);
2027
}}
2128
title="Past month"
2229
/>
@@ -25,15 +32,15 @@ export function ImperativeScrolling() {
2532
onPress={() => {
2633
const nextMonth = addMonths(currentMonth, 1);
2734
setCurrentMonth(nextMonth);
28-
ref.current?.scrollToDate(nextMonth, true);
35+
ref.current?.scrollToMonth(nextMonth, true);
2936
}}
3037
title="Next month"
3138
/>
3239
</View>
3340
<View style={{ flex: 1, width: "100%" }}>
3441
<Calendar.List
3542
calendarInitialMonthId={toDateId(currentMonth)}
36-
onCalendarDayPress={(dateId) => console.log(`Pressed ${dateId}`)}
43+
onCalendarDayPress={onCalendarDayPress}
3744
ref={ref}
3845
/>
3946
</View>

packages/flash-calendar/src/components/CalendarList.stories.tsx

+22-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
startOfYear,
88
subMonths,
99
} from "date-fns";
10-
import { useMemo, useRef, useState } from "react";
10+
import { useCallback, useMemo, useRef, useState } from "react";
1111
import { Button, Text, View } from "react-native";
1212

1313
import type { CalendarListProps, CalendarListRef } from "@/components";
@@ -16,7 +16,7 @@ import { HStack } from "@/components/HStack";
1616
import { VStack } from "@/components/VStack";
1717
import { paddingDecorator } from "@/developer/decorators";
1818
import { loggingHandler } from "@/developer/loggginHandler";
19-
import { toDateId } from "@/helpers/dates";
19+
import { fromDateId, toDateId } from "@/helpers/dates";
2020
import type { CalendarActiveDateRange } from "@/hooks/useCalendar";
2121
import { useDateRange } from "@/hooks/useDateRange";
2222
import { useTheme } from "@/hooks/useTheme";
@@ -110,8 +110,23 @@ export function SpacingSparse() {
110110
export function ImperativeScrolling() {
111111
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));
112112

113+
const [activeDateId, setActiveDateId] = useState<string | undefined>(
114+
toDateId(addDays(currentMonth, 3))
115+
);
116+
117+
const calendarActiveDateRanges = useMemo<CalendarActiveDateRange[]>(() => {
118+
if (!activeDateId) return [];
119+
120+
return [{ startId: activeDateId, endId: activeDateId }];
121+
}, [activeDateId]);
122+
113123
const ref = useRef<CalendarListRef>(null);
114124

125+
const onCalendarDayPress = useCallback((dateId: string) => {
126+
ref.current?.scrollToDate(fromDateId(dateId), true);
127+
setActiveDateId(dateId);
128+
}, []);
129+
115130
return (
116131
<View style={{ paddingTop: 20, flex: 1 }}>
117132
<VStack alignItems="center" grow spacing={20}>
@@ -120,7 +135,7 @@ export function ImperativeScrolling() {
120135
onPress={() => {
121136
const pastMonth = subMonths(currentMonth, 1);
122137
setCurrentMonth(pastMonth);
123-
ref.current?.scrollToDate(pastMonth, true);
138+
ref.current?.scrollToMonth(pastMonth, true);
124139
}}
125140
title="Past month"
126141
/>
@@ -129,7 +144,7 @@ export function ImperativeScrolling() {
129144
onPress={() => {
130145
const nextMonth = addMonths(currentMonth, 1);
131146
setCurrentMonth(nextMonth);
132-
ref.current?.scrollToDate(nextMonth, true);
147+
ref.current?.scrollToMonth(nextMonth, true);
133148
}}
134149
title="Next month"
135150
/>
@@ -138,14 +153,15 @@ export function ImperativeScrolling() {
138153
onPress={() => {
139154
const thisMonth = startOfMonth(new Date());
140155
setCurrentMonth(thisMonth);
141-
ref.current?.scrollToDate(thisMonth, true);
156+
ref.current?.scrollToMonth(thisMonth, true);
142157
}}
143158
title="Today"
144159
/>
145160
<View style={{ flex: 1, width: "100%" }}>
146161
<Calendar.List
162+
calendarActiveDateRanges={calendarActiveDateRanges}
147163
calendarInitialMonthId={toDateId(currentMonth)}
148-
onCalendarDayPress={loggingHandler("onCalendarDayPress")}
164+
onCalendarDayPress={onCalendarDayPress}
149165
ref={ref}
150166
/>
151167
</View>

packages/flash-calendar/src/components/CalendarList.tsx

+89-22
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { View } from "react-native";
1313

1414
import type { CalendarProps } from "@/components/Calendar";
1515
import { Calendar } from "@/components/Calendar";
16-
import { startOfMonth, toDateId } from "@/helpers/dates";
16+
import { getWeekOfMonth, startOfMonth, toDateId } from "@/helpers/dates";
1717
import type { CalendarMonth } from "@/hooks/useCalendarList";
1818
import { getHeightForMonth, useCalendarList } from "@/hooks/useCalendarList";
1919

@@ -89,8 +89,25 @@ export interface CalendarListProps
8989
renderItem?: FlashListProps<CalendarMonthEnhanced>["renderItem"];
9090
}
9191

92+
interface ImperativeScrollParams {
93+
/**
94+
* An additional offset to add to the final scroll position. Useful when
95+
* you need to slightly change the final scroll position.
96+
*/
97+
additionalOffset?: number;
98+
}
9299
export interface CalendarListRef {
93-
scrollToDate: (date: Date, animated: boolean) => void;
100+
scrollToMonth: (
101+
date: Date,
102+
animated: boolean,
103+
params?: ImperativeScrollParams
104+
) => void;
105+
scrollToDate: (
106+
date: Date,
107+
animated: boolean,
108+
params?: ImperativeScrollParams
109+
) => void;
110+
scrollToOffset: (offset: number, animated: boolean) => void;
94111
}
95112

96113
export const CalendarList = memo(
@@ -224,10 +241,12 @@ export const CalendarList = memo(
224241
]
225242
);
226243

227-
const flashListRef = useRef<FlashList<CalendarMonthEnhanced>>(null);
228-
229-
useImperativeHandle(ref, () => ({
230-
scrollToDate(date, animated) {
244+
/**
245+
* Returns the offset for the given month (how much the user needs to
246+
* scroll to reach the month).
247+
*/
248+
const getScrollOffsetForMonth = useCallback(
249+
(date: Date) => {
231250
const monthId = toDateId(startOfMonth(date));
232251

233252
let baseMonthList = monthList;
@@ -238,31 +257,79 @@ export const CalendarList = memo(
238257
index = baseMonthList.findIndex((month) => month.id === monthId);
239258
}
240259

241-
const currentOffset = baseMonthList
242-
.slice(0, index)
243-
.reduce((acc, month) => {
244-
const currentHeight = getHeightForMonth({
245-
calendarMonth: month,
246-
calendarSpacing,
247-
calendarDayHeight,
248-
calendarMonthHeaderHeight,
249-
calendarRowVerticalSpacing,
250-
calendarWeekHeaderHeight,
251-
calendarAdditionalHeight,
252-
});
253-
254-
return acc + currentHeight;
255-
}, 0);
260+
return baseMonthList.slice(0, index).reduce((acc, month) => {
261+
const currentHeight = getHeightForMonth({
262+
calendarMonth: month,
263+
calendarSpacing,
264+
calendarDayHeight,
265+
calendarMonthHeaderHeight,
266+
calendarRowVerticalSpacing,
267+
calendarWeekHeaderHeight,
268+
calendarAdditionalHeight,
269+
});
256270

271+
return acc + currentHeight;
272+
}, 0);
273+
},
274+
[
275+
addMissingMonths,
276+
calendarAdditionalHeight,
277+
calendarDayHeight,
278+
calendarMonthHeaderHeight,
279+
calendarRowVerticalSpacing,
280+
calendarSpacing,
281+
calendarWeekHeaderHeight,
282+
monthList,
283+
]
284+
);
285+
286+
const flashListRef = useRef<FlashList<CalendarMonthEnhanced>>(null);
287+
288+
useImperativeHandle(ref, () => ({
289+
scrollToMonth(
290+
date,
291+
animated,
292+
{ additionalOffset = 0 } = { additionalOffset: 0 }
293+
) {
257294
// Wait for the next render cycle to ensure the list has been
258295
// updated with the new months.
259296
setTimeout(() => {
260297
flashListRef.current?.scrollToOffset({
261-
offset: currentOffset,
298+
offset: getScrollOffsetForMonth(date) + additionalOffset,
262299
animated,
263300
});
264301
}, 0);
265302
},
303+
scrollToDate(
304+
date,
305+
animated,
306+
{ additionalOffset = 0 } = {
307+
additionalOffset: 0,
308+
}
309+
) {
310+
const currentMonthOffset = getScrollOffsetForMonth(date);
311+
const weekOfMonthIndex = getWeekOfMonth(date, calendarFirstDayOfWeek);
312+
const rowHeight = calendarDayHeight + calendarRowVerticalSpacing;
313+
314+
let weekOffset =
315+
calendarWeekHeaderHeight + rowHeight * weekOfMonthIndex;
316+
317+
/**
318+
* We need to subtract one vertical spacing to avoid cutting off the
319+
* desired date. A simple way of understanding why is imagining we
320+
* want to scroll exactly to the given date, but leave a little bit of
321+
* breathing room (`calendarRowVerticalSpacing`) above it.
322+
*/
323+
weekOffset = weekOffset - calendarRowVerticalSpacing;
324+
325+
flashListRef.current?.scrollToOffset({
326+
offset: currentMonthOffset + weekOffset + additionalOffset,
327+
animated,
328+
});
329+
},
330+
scrollToOffset(offset, animated) {
331+
flashListRef.current?.scrollToOffset({ offset, animated });
332+
},
266333
}));
267334

268335
const calendarContainerStyle = useMemo(() => {

0 commit comments

Comments
 (0)