Skip to content

Commit 2775c83

Browse files
committed
Add TimeRange component: Implement time selection with smooth scrolling and date formatting
1 parent 79921af commit 2775c83

File tree

4 files changed

+167
-8
lines changed

4 files changed

+167
-8
lines changed

App.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,26 @@ import { StyleSheet, View } from 'react-native';
33
import { useRef } from 'react';
44

55
import { GestureHandlerRootView } from 'react-native-gesture-handler';
6-
import { useSharedValue } from 'react-native-reanimated';
6+
import { useDerivedValue, useSharedValue } from 'react-native-reanimated';
77
import { SafeAreaProvider } from 'react-native-safe-area-context';
88
import { BottomTab } from './src/components/bottom-tab';
99
import { CircularDraggableSlider, CircularDraggableSliderRefType } from './src/components/circle-time';
10+
import { TimeRange } from './src/components/time-range';
11+
12+
// Handle timezone offset to ensure correct time display
13+
// Note: This is a simple implementation. For production, consider using a proper timezone library
14+
const TimezoneOffsetMs = -new Date().getTimezoneOffset() * 60000;
15+
16+
/**
17+
* Generate an array of time slots for the time range selector
18+
* Creates 20 time slots starting from 13:00 with 30-minute intervals
19+
*/
20+
const dates = new Array(20).fill(0).map((_, index) => {
21+
const hour = Math.floor(index / 2) + 13;
22+
const minutes = index % 2 === 0 ? 0 : 30;
23+
return new Date(2025, 0, 1, hour, minutes);
24+
});
25+
1026

1127

1228
const LinesAmount = 200;
@@ -16,17 +32,18 @@ export default function App() {
1632
const previousTick = useSharedValue(0);
1733

1834
const circularSliderRef = useRef<CircularDraggableSliderRefType>(null);
35+
const date = useSharedValue(dates[0].getTime());
36+
37+
const clockDate = useDerivedValue(() => {
38+
'worklet';
39+
return date.value + TimezoneOffsetMs;
40+
});
1941

2042
return (
2143
<SafeAreaProvider>
2244
<GestureHandlerRootView style={{ flex: 1 }}>
2345
<View style={{ flex: 1 }}>
2446
<View style={styles.container}>
25-
<View
26-
style={{
27-
marginBottom: 256,
28-
}}>
29-
</View>
3047
<CircularDraggableSlider
3148
ref={circularSliderRef}
3249
bigLineIndexOffset={10}
@@ -53,6 +70,13 @@ export default function App() {
5370
animatedNumber.value = sliderProgress;
5471
}}
5572
/>
73+
<TimeRange
74+
dates={dates}
75+
onDateChange={updatedDate => {
76+
'worklet';
77+
date.value = updatedDate;
78+
}}
79+
/>
5680
</View>
5781
<BottomTab />
5882
</View>
@@ -78,9 +102,9 @@ const styles = StyleSheet.create({
78102
right: 32,
79103
},
80104
container: {
105+
flexDirection: "row",
81106
alignItems: 'center',
82107
backgroundColor: '#000',
83108
flex: 1,
84-
justifyContent: 'center',
85109
},
86110
});

bun.lock

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
"@react-navigation/elements": "^2.6.3",
1010
"@react-navigation/native": "^7.1.8",
1111
"@shopify/react-native-skia": "^2.2.12",
12+
"date-fns": "^4.1.0",
1213
"expo": "~54.0.20",
1314
"expo-constants": "~18.0.10",
1415
"expo-font": "~14.0.9",
1516
"expo-haptics": "~15.0.7",
1617
"expo-image": "~3.0.10",
18+
"expo-linear-gradient": "^15.0.7",
1719
"expo-linking": "~8.0.8",
1820
"expo-splash-screen": "~31.0.10",
1921
"expo-status-bar": "~3.0.8",
@@ -736,6 +738,8 @@
736738

737739
"data-view-byte-offset": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
738740

741+
"date-fns": ["[email protected]", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
742+
739743
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
740744

741745
"decode-uri-component": ["[email protected]", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
@@ -862,6 +866,8 @@
862866

863867
"expo-keep-awake": ["[email protected]", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA=="],
864868

869+
"expo-linear-gradient": ["[email protected]", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA=="],
870+
865871
"expo-linking": ["[email protected]", "", { "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg=="],
866872

867873
"expo-modules-autolinking": ["[email protected]", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-tSMYGnfZmAaN77X8iMLiaSgbCFnA7eh6s2ac09J2N2N0Rcf2RCE27jg0c0XenTMTWUcM4QvLhsNHof/WtlKqPw=="],

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
{
32
"name": "clones",
43
"main": "index.js",
@@ -17,11 +16,13 @@
1716
"@react-navigation/elements": "^2.6.3",
1817
"@react-navigation/native": "^7.1.8",
1918
"@shopify/react-native-skia": "^2.2.12",
19+
"date-fns": "^4.1.0",
2020
"expo": "~54.0.20",
2121
"expo-constants": "~18.0.10",
2222
"expo-font": "~14.0.9",
2323
"expo-haptics": "~15.0.7",
2424
"expo-image": "~3.0.10",
25+
"expo-linear-gradient": "^15.0.7",
2526
"expo-linking": "~8.0.8",
2627
"expo-splash-screen": "~31.0.10",
2728
"expo-status-bar": "~3.0.8",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { format } from 'date-fns';
2+
import { LinearGradient } from 'expo-linear-gradient';
3+
import { useCallback, useMemo } from 'react';
4+
import { StyleSheet, Text, View } from 'react-native';
5+
import Animated, {
6+
interpolate,
7+
useAnimatedScrollHandler,
8+
} from 'react-native-reanimated';
9+
10+
/**
11+
* Props for the TimeRange component
12+
* @property {Date[]} dates - Array of Date objects representing available time slots
13+
* @property {(dateMs: number) => void} onDateChange - Callback function called when time selection changes
14+
*/
15+
type TimePickerProps = {
16+
dates: Date[];
17+
onDateChange?: (dateMs: number) => void;
18+
};
19+
20+
const ITEM_HEIGHT = 30; // Height of each time item in pixels
21+
const TimeRangeHeight = ITEM_HEIGHT * 4; // Total height of visible time range
22+
23+
export const TimeRange: React.FC<TimePickerProps> = ({
24+
dates,
25+
onDateChange,
26+
}) => {
27+
const datesMs = useMemo(() => dates.map(date => date.getTime()), [dates]);
28+
29+
const formattedDates = useMemo(
30+
() => dates.map(date => format(date, 'h:mm aaa').toLowerCase()),
31+
[dates],
32+
);
33+
34+
/**
35+
* Renders individual time items in the list
36+
* @param {Object} param0 - Item data and index
37+
* @returns {JSX.Element} Rendered time item
38+
*/
39+
const renderItem = useCallback(
40+
({ item, index }: { item: string; index: number }) => (
41+
<View key={index} style={styles.timeItem}>
42+
<Text style={styles.timeText}>{item}</Text>
43+
</View>
44+
),
45+
[],
46+
);
47+
48+
/**
49+
* Handles scroll events and interpolates the selected time
50+
* Uses Reanimated worklet for smooth performance
51+
*/
52+
const onScroll = useAnimatedScrollHandler({
53+
onScroll: event => {
54+
const { contentOffset } = event;
55+
const interpolatedDate = interpolate(
56+
contentOffset.y,
57+
datesMs.map((_, i) => i * ITEM_HEIGHT),
58+
datesMs,
59+
);
60+
onDateChange?.(interpolatedDate);
61+
},
62+
});
63+
64+
return (
65+
<View style={styles.container}>
66+
<Animated.FlatList
67+
onScroll={onScroll}
68+
decelerationRate="fast"
69+
snapToAlignment="center"
70+
snapToOffsets={datesMs.map((_, i) => i * ITEM_HEIGHT)}
71+
contentContainerStyle={styles.scrollViewContent}
72+
showsVerticalScrollIndicator={false}
73+
style={{ width: 100 }}
74+
data={formattedDates}
75+
renderItem={renderItem}
76+
disableIntervalMomentum
77+
/>
78+
79+
<LinearGradient
80+
colors={['#111111', '#11111100']}
81+
start={{ x: 0, y: 0 }}
82+
end={{ x: 0, y: 0.5 }}
83+
style={[styles.gradient, styles.bottomGradient]}
84+
/>
85+
<LinearGradient
86+
colors={['#11111100', '#111111']}
87+
start={{ x: 0, y: 0.5 }}
88+
end={{ x: 0, y: 1 }}
89+
style={[styles.gradient, styles.topGradient]}
90+
/>
91+
</View>
92+
);
93+
};
94+
95+
const styles = StyleSheet.create({
96+
bottomGradient: {
97+
bottom: 0,
98+
},
99+
container: {
100+
height: TimeRangeHeight,
101+
position: "absolute",
102+
right: 0
103+
},
104+
gradient: {
105+
height: TimeRangeHeight,
106+
left: 0,
107+
pointerEvents: 'none',
108+
position: 'absolute',
109+
right: 0,
110+
zIndex: 100,
111+
},
112+
scrollViewContent: {
113+
paddingVertical: TimeRangeHeight / 2 - ITEM_HEIGHT / 2,
114+
},
115+
timeItem: {
116+
alignItems: 'center',
117+
height: ITEM_HEIGHT,
118+
justifyContent: 'center',
119+
},
120+
timeText: {
121+
color: '#d8d8d8',
122+
fontFamily: 'Honk-Regular',
123+
fontSize: 20,
124+
},
125+
topGradient: {
126+
top: 0,
127+
},
128+
});

0 commit comments

Comments
 (0)