Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ return (
| aggressivelyGetLatestDuration | Set to True to ask DurationScroll to aggressively update the latestDuration ref | Boolean | false | false |
| allowFontScaling | Allow font in the picker to scale with accessibility settings | Boolean | false | false |
| use12HourPicker | Switch the hour picker to 12-hour format with an AM / PM label | Boolean | false | false |
| separateAmPmPicker | When `use12HourPicker` is true, render AM/PM as a dedicated scrollable column after seconds (instead of appending it to each hour). Hours are emitted via `onDurationChange` as a 0–23 value as usual. `hourLimit` and `hourInterval` are honoured: rows in the **hour** column grey/snap based on the currently selected AM/PM. The AM/PM column itself is always freely toggleable so users can switch halves to reach any valid hour. The AM/PM column width can be customised via `pickerColumnWidth.amPm`. Note: `maximumHours` is currently ignored in this mode — the column always shows the full 12-hour cycle. | Boolean | false | false |
| amLabel | Set the AM label if using the 12-hour picker | String | am | false |
| pmLabel | Set the PM label if using the 12-hour picker | String | pm | false |
| repeatDayNumbersNTimes | Set the number of times the list of days is repeated in the picker | Number | 3 | false |
Expand Down Expand Up @@ -609,6 +610,8 @@ For deeper style customization, you can supply the following custom styles to ad
| pickerLabel | Style for the picker's labels | TextStyle |
| pickerAmPmContainer | Style for the picker's labels | ViewStyle |
| pickerAmPmLabel | Style for the picker's labels | TextStyle |
| separateAmPmItem | Style for rows in the standalone AM/PM column when `separateAmPmPicker` is enabled. Layered on top of `pickerItem` | TextStyle |
| selectedSeparateAmPmItem | Style for the centred (selected) row in the standalone AM/PM column when `separateAmPmPicker` is enabled. Layered on top of `separateAmPmItem` | TextStyle |
| pickerItemContainer | Container for each number in the picker | ViewStyle & { height?: number } |
| pickerItem | Style for each number | TextStyle |
| disabledPickerItem | Style for any numbers outside any set limits | TextStyle |
Expand Down
103 changes: 101 additions & 2 deletions examples/example-expo/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ export default function App() {
const [showPickerExample1, setShowPickerExample1] = useState(false);
const [showPickerExample2, setShowPickerExample2] = useState(false);
const [showPickerExample3, setShowPickerExample3] = useState(false);
const [showPickerSeparateAmPm, setShowPickerSeparateAmPm] = useState(false);
const [alarmStringExample1, setAlarmStringExample1] = useState<string | null>(null);
const [alarmStringExample2, setAlarmStringExample2] = useState<string | null>(null);
const [alarmStringExample3, setAlarmStringExample3] = useState("00:00:00");
const [alarmStringSeparateAmPm, setAlarmStringSeparateAmPm] = useState<string | null>(null);
const [hourLimitTestValue, setHourLimitTestValue] = useState(20);
const [hourLimitSeparateTestValue, setHourLimitSeparateTestValue] = useState(20);

// N.B. Uncomment this to use audio (requires development build)
// useEffect(() => {
Expand Down Expand Up @@ -170,6 +173,53 @@ export default function App() {
</View>
);

const renderExampleSeparateAmPm = (
<View style={[styles.container, styles.pageSeparateAmPmContainer, { width: pageWidth }]}>
<Text style={styles.textLight}>
{alarmStringSeparateAmPm !== null ? "Alarm set for" : "No alarm set"}
</Text>
<TouchableOpacity activeOpacity={0.7} onPress={() => setShowPickerSeparateAmPm(true)}>
<View style={styles.touchableContainer}>
{alarmStringSeparateAmPm !== null ? (
<Text style={styles.alarmTextLight}>{alarmStringSeparateAmPm}</Text>
) : null}
<TouchableOpacity activeOpacity={0.7} onPress={() => setShowPickerSeparateAmPm(true)}>
<View style={styles.buttonContainer}>
<Text style={[styles.button, styles.buttonLight]}>{"Set Alarm 🔔"}</Text>
</View>
</TouchableOpacity>
</View>
</TouchableOpacity>
<Text style={styles.captionLight}>{"use12HourPicker + separateAmPmPicker"}</Text>
<TimerPickerModal
amLabel="AM"
closeOnOverlayPress
hideSeconds
hourLimit={{ max: 17, min: 9 }}
initialValue={{ hours: 9, minutes: 30 }}
LinearGradient={LinearGradient}
modalTitle="Set Alarm"
onCancel={() => setShowPickerSeparateAmPm(false)}
onConfirm={(pickedDuration) => {
setAlarmStringSeparateAmPm(formatTime(pickedDuration));
setShowPickerSeparateAmPm(false);
}}
padHoursWithZero={false}
pickerFeedback={pickerFeedback}
pmLabel="PM"
separateAmPmPicker
setIsVisible={setShowPickerSeparateAmPm}
styles={{
selectedSeparateAmPmItem: { color: "#1B6EF1" },
separateAmPmItem: { fontSize: 16, fontWeight: "600" },
theme: "light",
}}
use12HourPicker
visible={showPickerSeparateAmPm}
/>
</View>
);

const renderExample3 = (
<View style={[styles.container, styles.page3Container, { width: pageWidth }]}>
<Text style={styles.textLight}>
Expand Down Expand Up @@ -313,9 +363,48 @@ export default function App() {
</View>
);

const pageIndicesWithDarkBackground = [0, 3, 5];
const renderHourLimitSeparateTest = (
<View style={[styles.container, styles.page6Container, { width: pageWidth }]}>
<Text style={[styles.textDark, styles.testTitle]}>
Cross-midnight hourLimit + separate AM/PM
</Text>
<Text style={styles.testInstructions}>
{"use12HourPicker + separateAmPmPicker\nhourLimit={ min: 20, max: 5 } (8 PM – 5 AM)"}
</Text>
<Text style={styles.testInstructions}>
{
"Hour rows grey based on current AM/PM.\nAM/PM rows grey based on current hour.\nMomentum-scroll snaps within each column."
}
</Text>
<View style={styles.liveValueBox}>
<Text style={styles.liveValueLabel}>Live reported hour value:</Text>
<Text style={styles.liveValueText}>{formatLiveValue(hourLimitSeparateTestValue)}</Text>
</View>
<TimerPicker
amLabel="AM"
hideSeconds
hourLimit={{ max: 5, min: 20 }}
initialValue={{ hours: 20, minutes: 0, seconds: 0 }}
LinearGradient={LinearGradient}
onDurationChange={(d) => setHourLimitSeparateTestValue(d.hours)}
padHoursWithZero={false}
padWithNItems={2}
pickerFeedback={pickerFeedback}
pmLabel="PM"
separateAmPmPicker
styles={{
pickerItem: { fontSize: 28 },
pickerLabel: { fontSize: 22 },
theme: "dark",
}}
use12HourPicker
/>
</View>
);

const pageIndicesWithDarkBackground = [0, 4, 6, 7];
const isDarkBackground = pageIndicesWithDarkBackground.includes(currentPageIndex);
const isFinalPage = currentPageIndex === 5;
const isFinalPage = currentPageIndex === 7;
const isFirstPage = currentPageIndex === 0;

const renderNavigationArrows = (
Expand Down Expand Up @@ -384,10 +473,12 @@ export default function App() {
>
{renderExample1}
{renderExample2}
{renderExampleSeparateAmPm}
{renderExample3}
{renderExample4}
{renderExample5}
{renderHourLimitTest}
{renderHourLimitSeparateTest}
</ScrollView>
{renderNavigationArrows}
</View>
Expand Down Expand Up @@ -419,6 +510,11 @@ const styles = StyleSheet.create({
color: "#C2C2C2",
},
buttonLight: { borderColor: "#8C8C8C", color: "#8C8C8C" },
captionLight: {
color: "#8C8C8C",
fontSize: 13,
marginTop: 24,
},
chevronPressable: {
alignItems: "center",
bottom: 0,
Expand Down Expand Up @@ -468,6 +564,9 @@ const styles = StyleSheet.create({
backgroundColor: "#202020",
paddingHorizontal: 20,
},
pageSeparateAmPmContainer: {
backgroundColor: "#F1F1F1",
},
root: {
flex: 1,
},
Expand Down
64 changes: 46 additions & 18 deletions src/components/DurationScroll/DurationScroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ import { View, Text, FlatList as RNFlatList } from "react-native";
import type { ViewabilityConfigCallbackPairs, FlatListProps } from "react-native";

import { colorToRgba } from "../../utils/colorToRgba";
import { generate12HourNumbers, generateNumbers } from "../../utils/generateNumbers";
import {
generate12HourCycleNumbers,
generate12HourNumbers,
generateAmPmItems,
generateNumbers,
} from "../../utils/generateNumbers";
import { getAdjustedLimit } from "../../utils/getAdjustedLimit";
import { getDurationAndIndexFromScrollOffset } from "../../utils/getDurationAndIndexFromScrollOffset";
import { getInitialScrollIndex } from "../../utils/getInitialScrollIndex";
import { isWithinLimit } from "../../utils/isWithinLimit";
import { getNearestInRange } from "../../utils/getNearestInRange";
import PickerItem from "../PickerItem";
import type { DurationScrollProps, DurationScrollRef, ExpoAvAudioInstance } from "./types";

Expand All @@ -33,11 +38,14 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
decelerationRate = 0.88,
disableInfiniteScroll = false,
FlatList = RNFlatList,
getValidValue,
Haptics,
initialValue = 0,
interval,
is12HourPicker,
isAmPmPicker,
isDisabled,
isItemDisabled,
label,
limit,
LinearGradient,
Expand All @@ -54,6 +62,7 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
repeatNumbersNTimes = 3,
repeatNumbersNTimesNotExplicitlySet,
selectedValue,
separateAmPmPicker,
styles,
testID,
} = props;
Expand Down Expand Up @@ -113,7 +122,27 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
]);

const numbersForFlatList = useMemo(() => {
if (isAmPmPicker) {
return generateAmPmItems({
amLabel: amLabel ?? "am",
padWithNItems,
pmLabel: pmLabel ?? "pm",
});
}

if (is12HourPicker) {
// When AM/PM is rendered as a separate column, the hour column shows
// 12, 1, 2, ..., 11 (no AM/PM suffix).
if (separateAmPmPicker) {
return generate12HourCycleNumbers({
disableInfiniteScroll,
interval,
padNumbersWithZero,
padWithNItems,
repeatNTimes: safeRepeatNumbersNTimes,
});
}

return generate12HourNumbers({
disableInfiniteScroll,
interval,
Expand All @@ -131,13 +160,17 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
repeatNTimes: safeRepeatNumbersNTimes,
});
}, [
amLabel,
disableInfiniteScroll,
is12HourPicker,
isAmPmPicker,
interval,
numberOfItems,
padNumbersWithZero,
padWithNItems,
pmLabel,
safeRepeatNumbersNTimes,
separateAmPmPicker,
]);

const initialScrollIndex = useMemo(
Expand Down Expand Up @@ -230,10 +263,13 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
allowFontScaling={allowFontScaling}
amLabel={amLabel}
is12HourPicker={is12HourPicker}
isAmPmPicker={isAmPmPicker}
isItemDisabled={isItemDisabled}
item={item}
pickerAmPmPositionStyle={labelPositionStyle}
pmLabel={pmLabel}
selectedValue={selectedValue}
separateAmPmPicker={separateAmPmPicker}
styles={styles}
/>
),
Expand All @@ -243,30 +279,22 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
allowFontScaling,
amLabel,
is12HourPicker,
isAmPmPicker,
isItemDisabled,
labelPositionStyle,
pmLabel,
selectedValue,
separateAmPmPicker,
styles,
]
);

// returns the in-range value that's closest (in scroll distance) to `value`,
// honouring wraparound limits where max < min.
const getNearestInRangeValue = useCallback(
(value: number) => {
const { max, min } = adjustedLimited;
if (isWithinLimit(value, min, max)) return value;

if (max < min) {
// wraparound: `value` lies in the gap between max and min
const distanceForwardToMin = min - value;
const distanceBackwardToMax = value - max;
return distanceForwardToMin <= distanceBackwardToMax ? min : max;
}

return value > max ? max : min;
},
[adjustedLimited]
(value: number) =>
getValidValue
? getValidValue(value)
: getNearestInRange(value, adjustedLimited.min, adjustedLimited.max),
[adjustedLimited, getValidValue]
);

const onScroll = useCallback<NonNullable<FlatListProps<string>["onScroll"]>>(
Expand Down
15 changes: 15 additions & 0 deletions src/components/DurationScroll/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,25 @@ export interface DurationScrollProps {
decelerationRate?: number | "normal" | "fast";
disableInfiniteScroll?: boolean;
FlatList?: any;
/**
* Optional override for the column's snap-to-valid logic. When provided,
* `onMomentumScrollEnd` runs the raw value through this function instead of
* the default `limit`-based clamp. Used by `TimerPicker` to inject cross-column
* context (e.g. AM/PM) into hour-column snapping.
*/
getValidValue?: (rawValue: number) => number;
Haptics?: any;
initialValue?: number;
interval: number;
is12HourPicker?: boolean;
isAmPmPicker?: boolean;
isDisabled?: boolean;
/**
* Optional override for per-row "is this disabled?" decision. When provided,
* `PickerItem` calls this with the parsed row value instead of comparing against
* the column-local `limit`. Used by `TimerPicker` for combined-hour greying.
*/
isItemDisabled?: (value: number) => boolean;
label?: string | React.ReactElement;
limit?: Limit;
LinearGradient?: any;
Expand All @@ -36,6 +50,7 @@ export interface DurationScrollProps {
repeatNumbersNTimes?: number;
repeatNumbersNTimesNotExplicitlySet: boolean;
selectedValue?: number;
separateAmPmPicker?: boolean;
styles: ReturnType<typeof generateStyles>;
testID?: string;
}
Expand Down
Loading