1import * as Haptics from "expo-haptics";
2import { SymbolView } from "../../../../components/AppSymbol";
3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { View, type AccessibilityActionEvent } from "react-native";
5import { Gesture, GestureDetector } from "react-native-gesture-handler";
6import Animated, {
7 runOnJS,
8 useAnimatedStyle,
9 useSharedValue,
10 withTiming,
11} from "react-native-reanimated";
12import type { ComponentProps } from "react";
13
14import { AppText as Text } from "../../../../components/AppText";
15
16type SymbolName = ComponentProps<typeof SymbolView>["name"];
17
18const THUMB_SIZE = 26;
19const TRACK_HEIGHT = 4;
20const SNAP_ANIMATION = { duration: 120 } as const;
21
22function clampFraction(value: number): number {
23 "worklet";
24 return Math.min(1, Math.max(0, value));
25}
26
27export function FontSizeSliderRow(props: {
28 readonly disabled?: boolean;
29 readonly icon: SymbolName;
30 readonly label: string;
31 readonly valueLabel: string;
32 readonly min: number;
33 readonly max: number;
34 readonly step: number;
35 readonly value: number;
36 readonly onChange: (value: number) => void;
37}) {
38 const latest = useRef(props);
39 latest.current = props;
40
41 const { min, max, step, value, disabled } = props;
42 const fraction = (value - min) / (max - min);
43
44 const progress = useSharedValue(clampFraction(fraction));
45 const trackWidth = useSharedValue(0);
46 const dragging = useSharedValue(false);
47
48 useEffect(() => {
49 if (!dragging.value) {
50 progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION);
51 }
52 }, [dragging, fraction, progress]);
53
54 const commit = useCallback((next: number) => {
55 const current = latest.current;
56 if (current.disabled || next === current.value) {
57 return;
58 }
59 Haptics.selectionAsync().catch(() => undefined);
60 current.onChange(next);
61 }, []);
62
63 const gesture = useMemo(() => {
64 const snapValue = (raw: number): number => {
65 "worklet";
66 const stepped = Math.round((raw - min) / step) * step + min;
67 return Math.min(max, Math.max(min, stepped));
68 };
69 const fractionAt = (x: number): number => {
70 "worklet";
71 const usable = trackWidth.value - THUMB_SIZE;
72 if (usable <= 0) {
73 return 0;
74 }
75 return clampFraction((x - THUMB_SIZE / 2) / usable);
76 };
77 const valueAtFraction = (f: number): number => {
78 "worklet";
79 return snapValue(min + f * (max - min));
80 };
81 const fractionOfValue = (v: number): number => {
82 "worklet";
83 return clampFraction((v - min) / (max - min));
84 };
85
86 const pan = Gesture.Pan()
87 .enabled(!disabled)
88 .activeOffsetX([-8, 8])
89 .failOffsetY([-12, 12])
90 .onUpdate((event) => {
91 dragging.value = true;
92 const f = fractionAt(event.x);
93 progress.value = f;
94 })
95 .onFinalize((_event, success) => {
96 if (!dragging.value) {
97 return;
98 }
99 dragging.value = false;
100 if (!success) {
101 progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION);
102 return;
103 }
104 const next = valueAtFraction(progress.value);
105 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
106 runOnJS(commit)(next);
107 });
108
109 const tap = Gesture.Tap()
110 .enabled(!disabled)
111 .onEnd((event) => {
112 const next = valueAtFraction(fractionAt(event.x));
113 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
114 runOnJS(commit)(next);
115 });
116
117 return Gesture.Race(pan, tap);
118 }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]);
119
120 const fillStyle = useAnimatedStyle(() => ({
121 width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE),
122 }));
123 const thumbStyle = useAnimatedStyle(() => ({
124 transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }],
125 }));
126
127 const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
128 if (event.nativeEvent.actionName === "increment") {
129 commit(Math.min(max, value + step));
130 } else if (event.nativeEvent.actionName === "decrement") {
131 commit(Math.max(min, value - step));
132 }
133 };
134
135 return (
136 <View className={disabled ? "gap-1 p-4 opacity-[0.45]" : "gap-1 p-4"}>
137 <View className="flex-row items-center gap-4">
138 <SymbolView
139 name={props.icon}
140 size={22}
141 tintColorClassName={"accent-icon"}
142 type="monochrome"
143 weight="regular"
144 />
145 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
146 <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text>
147 </View>
148 <View className="flex-row items-center gap-3">
149 <SymbolView
150 name="textformat.size.smaller"
151 size={15}
152 tintColorClassName={"accent-icon-muted"}
153 type="monochrome"
154 weight="regular"
155 />
156 <GestureDetector gesture={gesture}>
157 <View
158 accessible
159 accessibilityActions={[
160 { name: "increment", label: `Increase ${props.label}` },
161 { name: "decrement", label: `Decrease ${props.label}` },
162 ]}
163 accessibilityLabel={props.label}
164 accessibilityRole="adjustable"
165 accessibilityState={{ disabled: Boolean(disabled) }}
166 accessibilityValue={{ min, max, now: value, text: props.valueLabel }}
167 className="h-11 flex-1 justify-center"
168 onAccessibilityAction={handleAccessibilityAction}
169 onLayout={(event) => {
170 trackWidth.value = event.nativeEvent.layout.width;
171 }}
172 >
173 <View
174 className="w-full rounded-full bg-secondary-border"
175 style={{ height: TRACK_HEIGHT }}
176 >
177 <Animated.View
178 className="absolute inset-y-0 left-0 rounded-full bg-primary"
179 style={fillStyle}
180 />
181 </View>
182 <Animated.View
183 className="absolute left-0 rounded-full border-border bg-primary-foreground"
184 style={[
185 {
186 borderWidth: 1,
187 height: THUMB_SIZE,
188 marginTop: -THUMB_SIZE / 2,
189 shadowColor: "#000000",
190 shadowOffset: { height: 2, width: 0 },
191 shadowOpacity: 0.18,
192 shadowRadius: 3,
193 top: "50%",
194 width: THUMB_SIZE,
195 },
196 thumbStyle,
197 ]}
198 />
199 </View>
200 </GestureDetector>
201 <SymbolView
202 name="textformat.size.larger"
203 size={22}
204 tintColorClassName={"accent-icon-muted"}
205 type="monochrome"
206 weight="regular"
207 />
208 </View>
209 </View>
210 );
211}