Skip to content

Commit

Permalink
[feat]: Add ability to scroll to a week row (#37)
Browse files Browse the repository at this point in the history
* 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 <mprado@brex.com>
  • Loading branch information
danibonilha and MarceloPrado authored Jun 30, 2024
1 parent 9ef657b commit 9bf22ed
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 43 deletions.
24 changes: 24 additions & 0 deletions .changeset/shiny-pigs-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@marceloterreiro/flash-calendar": major
---

# Flash Calendar 1.0.0 🚢 🎉

This release officially marks the package as ready for production use (`1.0.0`).
While it's been stable since the first release, bumping to `1.0.0` was something
I had in mind for a while.

- New: Add `.scrollToMonth` and `.scrollToDate`, increasing the options available for imperative scrolling.

## Breaking changes

This release introduces one slightly change in behavior if you're app uses
imperative scrolling. Previously, `.scrollToDate` would scroll to the month
containing the date instead of the exact date. Now, `.scrollToDate` will scroll
to the exact date as the name suggests.

If you intentionally want to scroll to the month instead, a new `.scrollToMonth`
method was added (same signature).

I don't expect this to cause any issues for existing apps, but worth mentioned
nonetheless.
21 changes: 16 additions & 5 deletions apps/docs/docs/fundamentals/tips-and-tricks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ These two convertions functions were [battle-tested](/~https://github.com/MarceloP

## Programmatically scrolling to a date

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

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

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

export function ImperativeScrolling() {
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));

const ref = useRef<CalendarListRef>(null);

const onCalendarDayPress = useCallback((dateId: string) => {
ref.current?.scrollToDate(fromDateId(dateId), true);
}, []);

return (
<View style={{ paddingTop: 20, flex: 1 }}>
<View style={{ flexDirection: "row", gap: 12 }}>
<Button
onPress={() => {
const pastMonth = subMonths(currentMonth, 1);
setCurrentMonth(pastMonth);
ref.current?.scrollToDate(pastMonth, true);
ref.current?.scrollToMonth(pastMonth, true);
}}
title="Past month"
/>
Expand All @@ -82,15 +87,15 @@ export function ImperativeScrolling() {
onPress={() => {
const nextMonth = addMonths(currentMonth, 1);
setCurrentMonth(nextMonth);
ref.current?.scrollToDate(nextMonth, true);
ref.current?.scrollToMonth(nextMonth, true);
}}
title="Next month"
/>
</View>
<View style={{ flex: 1, width: "100%" }}>
<Calendar.List
calendarInitialMonthId={toDateId(currentMonth)}
onCalendarDayPress={(dateId) => console.log(`Pressed ${dateId}`)}
onCalendarDayPress={onCalendarDayPress}
ref={ref}
/>
</View>
Expand All @@ -107,6 +112,12 @@ export function ImperativeScrolling() {

</HStack>

You can also pass an `additionalOffset` to fine-tune the scroll position:

```tsx
.scrollToDate(fromDateId("2024-07-01"), true, { additionalOffset: 4 })
```

## Setting Border Radius to `Calendar.Item.Day`

To apply a border radius to the `Calendar.Item.Day` component, it's necessary to
Expand Down
Binary file modified apps/docs/static/videos/imperative-scroll.mp4
Binary file not shown.
7 changes: 3 additions & 4 deletions kitchen-sink/expo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { CalendarListDemo } from "./CalendarList";
import { BottomSheetCalendar } from "./BottomSheetCalendar";
import { CalendarCustomFormatting } from "./CalendarCustomFormatting";
import { ImperativeScrolling } from "./ImperativeScroll";
import { SlowExampleAddressed } from "./SlowExampleAddressed";
// import { SlowExampleAddressed } from "./SlowExampleAddressed";

export default function App() {
const [demo, setDemo] = useState<"calendar" | "calendarList">("calendar");
Expand All @@ -33,10 +33,9 @@ export default function App() {
{demo === "calendar" ? <CalendarDemo /> : <CalendarListDemo />}
</View> */}
{/* <ImperativeScrolling /> */}
{/* <ImperativeScrolling /> */}
<ImperativeScrolling />
{/* <BottomSheetCalendar /> */}
<SlowExampleAddressed />
{/* <SlowExampleAddressed /> */}
</SafeAreaView>
</GestureHandlerRootView>
);
Expand Down
19 changes: 13 additions & 6 deletions kitchen-sink/expo/src/ImperativeScroll.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import { addMonths, subMonths, startOfMonth } from "date-fns";
import type { CalendarListRef } from "@marceloterreiro/flash-calendar";
import { Calendar, toDateId } from "@marceloterreiro/flash-calendar";
import { useRef, useState } from "react";
import {
Calendar,
toDateId,
fromDateId,
} from "@marceloterreiro/flash-calendar";
import { useCallback, useRef, useState } from "react";
import { Button, Text, View } from "react-native";

export function ImperativeScrolling() {
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));

const ref = useRef<CalendarListRef>(null);

const onCalendarDayPress = useCallback((dateId: string) => {
ref.current?.scrollToDate(fromDateId(dateId), true);
}, []);

return (
<View style={{ paddingTop: 20, flex: 1 }}>
<View style={{ flexDirection: "row", gap: 12 }}>
<Button
onPress={() => {
const pastMonth = subMonths(currentMonth, 1);
setCurrentMonth(pastMonth);
ref.current?.scrollToDate(pastMonth, true);
ref.current?.scrollToMonth(pastMonth, true);
}}
title="Past month"
/>
Expand All @@ -25,15 +32,15 @@ export function ImperativeScrolling() {
onPress={() => {
const nextMonth = addMonths(currentMonth, 1);
setCurrentMonth(nextMonth);
ref.current?.scrollToDate(nextMonth, true);
ref.current?.scrollToMonth(nextMonth, true);
}}
title="Next month"
/>
</View>
<View style={{ flex: 1, width: "100%" }}>
<Calendar.List
calendarInitialMonthId={toDateId(currentMonth)}
onCalendarDayPress={(dateId) => console.log(`Pressed ${dateId}`)}
onCalendarDayPress={onCalendarDayPress}
ref={ref}
/>
</View>
Expand Down
28 changes: 22 additions & 6 deletions packages/flash-calendar/src/components/CalendarList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
startOfYear,
subMonths,
} from "date-fns";
import { useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Button, Text, View } from "react-native";

import type { CalendarListProps, CalendarListRef } from "@/components";
Expand All @@ -16,7 +16,7 @@ import { HStack } from "@/components/HStack";
import { VStack } from "@/components/VStack";
import { paddingDecorator } from "@/developer/decorators";
import { loggingHandler } from "@/developer/loggginHandler";
import { toDateId } from "@/helpers/dates";
import { fromDateId, toDateId } from "@/helpers/dates";
import type { CalendarActiveDateRange } from "@/hooks/useCalendar";
import { useDateRange } from "@/hooks/useDateRange";
import { useTheme } from "@/hooks/useTheme";
Expand Down Expand Up @@ -110,8 +110,23 @@ export function SpacingSparse() {
export function ImperativeScrolling() {
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));

const [activeDateId, setActiveDateId] = useState<string | undefined>(
toDateId(addDays(currentMonth, 3))
);

const calendarActiveDateRanges = useMemo<CalendarActiveDateRange[]>(() => {
if (!activeDateId) return [];

return [{ startId: activeDateId, endId: activeDateId }];
}, [activeDateId]);

const ref = useRef<CalendarListRef>(null);

const onCalendarDayPress = useCallback((dateId: string) => {
ref.current?.scrollToDate(fromDateId(dateId), true);
setActiveDateId(dateId);
}, []);

return (
<View style={{ paddingTop: 20, flex: 1 }}>
<VStack alignItems="center" grow spacing={20}>
Expand All @@ -120,7 +135,7 @@ export function ImperativeScrolling() {
onPress={() => {
const pastMonth = subMonths(currentMonth, 1);
setCurrentMonth(pastMonth);
ref.current?.scrollToDate(pastMonth, true);
ref.current?.scrollToMonth(pastMonth, true);
}}
title="Past month"
/>
Expand All @@ -129,7 +144,7 @@ export function ImperativeScrolling() {
onPress={() => {
const nextMonth = addMonths(currentMonth, 1);
setCurrentMonth(nextMonth);
ref.current?.scrollToDate(nextMonth, true);
ref.current?.scrollToMonth(nextMonth, true);
}}
title="Next month"
/>
Expand All @@ -138,14 +153,15 @@ export function ImperativeScrolling() {
onPress={() => {
const thisMonth = startOfMonth(new Date());
setCurrentMonth(thisMonth);
ref.current?.scrollToDate(thisMonth, true);
ref.current?.scrollToMonth(thisMonth, true);
}}
title="Today"
/>
<View style={{ flex: 1, width: "100%" }}>
<Calendar.List
calendarActiveDateRanges={calendarActiveDateRanges}
calendarInitialMonthId={toDateId(currentMonth)}
onCalendarDayPress={loggingHandler("onCalendarDayPress")}
onCalendarDayPress={onCalendarDayPress}
ref={ref}
/>
</View>
Expand Down
111 changes: 89 additions & 22 deletions packages/flash-calendar/src/components/CalendarList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { View } from "react-native";

import type { CalendarProps } from "@/components/Calendar";
import { Calendar } from "@/components/Calendar";
import { startOfMonth, toDateId } from "@/helpers/dates";
import { getWeekOfMonth, startOfMonth, toDateId } from "@/helpers/dates";
import type { CalendarMonth } from "@/hooks/useCalendarList";
import { getHeightForMonth, useCalendarList } from "@/hooks/useCalendarList";

Expand Down Expand Up @@ -89,8 +89,25 @@ export interface CalendarListProps
renderItem?: FlashListProps<CalendarMonthEnhanced>["renderItem"];
}

interface ImperativeScrollParams {
/**
* An additional offset to add to the final scroll position. Useful when
* you need to slightly change the final scroll position.
*/
additionalOffset?: number;
}
export interface CalendarListRef {
scrollToDate: (date: Date, animated: boolean) => void;
scrollToMonth: (
date: Date,
animated: boolean,
params?: ImperativeScrollParams
) => void;
scrollToDate: (
date: Date,
animated: boolean,
params?: ImperativeScrollParams
) => void;
scrollToOffset: (offset: number, animated: boolean) => void;
}

export const CalendarList = memo(
Expand Down Expand Up @@ -224,10 +241,12 @@ export const CalendarList = memo(
]
);

const flashListRef = useRef<FlashList<CalendarMonthEnhanced>>(null);

useImperativeHandle(ref, () => ({
scrollToDate(date, animated) {
/**
* Returns the offset for the given month (how much the user needs to
* scroll to reach the month).
*/
const getScrollOffsetForMonth = useCallback(
(date: Date) => {
const monthId = toDateId(startOfMonth(date));

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

const currentOffset = baseMonthList
.slice(0, index)
.reduce((acc, month) => {
const currentHeight = getHeightForMonth({
calendarMonth: month,
calendarSpacing,
calendarDayHeight,
calendarMonthHeaderHeight,
calendarRowVerticalSpacing,
calendarWeekHeaderHeight,
calendarAdditionalHeight,
});

return acc + currentHeight;
}, 0);
return baseMonthList.slice(0, index).reduce((acc, month) => {
const currentHeight = getHeightForMonth({
calendarMonth: month,
calendarSpacing,
calendarDayHeight,
calendarMonthHeaderHeight,
calendarRowVerticalSpacing,
calendarWeekHeaderHeight,
calendarAdditionalHeight,
});

return acc + currentHeight;
}, 0);
},
[
addMissingMonths,
calendarAdditionalHeight,
calendarDayHeight,
calendarMonthHeaderHeight,
calendarRowVerticalSpacing,
calendarSpacing,
calendarWeekHeaderHeight,
monthList,
]
);

const flashListRef = useRef<FlashList<CalendarMonthEnhanced>>(null);

useImperativeHandle(ref, () => ({
scrollToMonth(
date,
animated,
{ additionalOffset = 0 } = { additionalOffset: 0 }
) {
// Wait for the next render cycle to ensure the list has been
// updated with the new months.
setTimeout(() => {
flashListRef.current?.scrollToOffset({
offset: currentOffset,
offset: getScrollOffsetForMonth(date) + additionalOffset,
animated,
});
}, 0);
},
scrollToDate(
date,
animated,
{ additionalOffset = 0 } = {
additionalOffset: 0,
}
) {
const currentMonthOffset = getScrollOffsetForMonth(date);
const weekOfMonthIndex = getWeekOfMonth(date, calendarFirstDayOfWeek);
const rowHeight = calendarDayHeight + calendarRowVerticalSpacing;

let weekOffset =
calendarWeekHeaderHeight + rowHeight * weekOfMonthIndex;

/**
* We need to subtract one vertical spacing to avoid cutting off the
* desired date. A simple way of understanding why is imagining we
* want to scroll exactly to the given date, but leave a little bit of
* breathing room (`calendarRowVerticalSpacing`) above it.
*/
weekOffset = weekOffset - calendarRowVerticalSpacing;

flashListRef.current?.scrollToOffset({
offset: currentMonthOffset + weekOffset + additionalOffset,
animated,
});
},
scrollToOffset(offset, animated) {
flashListRef.current?.scrollToOffset({ offset, animated });
},
}));

const calendarContainerStyle = useMemo(() => {
Expand Down
Loading

0 comments on commit 9bf22ed

Please sign in to comment.