Skip to content

Commit bdcf0e5

Browse files
committed
refactor(tooltip): extract shared useTooltipFade hook
The fade lifecycle (mount-through-exit, opacity, measurement, motion configs, reduce-motion) was duplicated between Tooltip and Tooltip.Rich. Extract it into a useTooltipFade hook so both variants share one implementation. No behavior change.
1 parent d52cbcc commit bdcf0e5

3 files changed

Lines changed: 125 additions & 195 deletions

File tree

src/components/Tooltip/RichTooltip.tsx

Lines changed: 7 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,18 @@ import * as React from 'react';
22
import {
33
Dimensions,
44
View,
5-
LayoutChangeEvent,
65
StyleSheet,
76
Platform,
87
Pressable,
98
ViewStyle,
109
} from 'react-native';
1110

12-
import Animated, {
13-
Easing,
14-
ReduceMotion,
15-
useAnimatedStyle,
16-
useSharedValue,
17-
withTiming,
18-
} from 'react-native-reanimated';
11+
import Animated from 'react-native-reanimated';
1912

13+
import { useTooltipFade } from './hooks';
2014
import { Tokens } from './tokens';
2115
import { getTooltipPosition, Measurement, TooltipChildProps } from './utils';
2216
import { useInternalTheme } from '../../core/theming';
23-
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
2417
import type { ThemeProp } from '../../types';
2518
import { addEventListener } from '../../utils/addEventListener';
2619
import Portal from '../Portal/Portal';
@@ -109,49 +102,15 @@ const RichTooltip = ({
109102
const isWeb = Platform.OS === 'web';
110103

111104
const theme = useInternalTheme(themeOverrides);
112-
const reduceMotion = useReduceMotion();
113-
// `visible` is the show/hide intent; `rendered` keeps the tooltip mounted
114-
// through the exit fade so it can animate out before unmounting.
105+
// `visible` is the show/hide intent; the fade hook keeps the tooltip mounted
106+
// through the exit animation and owns the measurement + opacity.
115107
const [visible, setVisible] = React.useState(false);
116-
const [rendered, setRendered] = React.useState(false);
108+
const { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef } =
109+
useTooltipFade(theme, visible);
117110

118-
const [measurement, setMeasurement] = React.useState({
119-
children: {},
120-
tooltip: {},
121-
measured: false,
122-
});
123111
const showTooltipTimer = React.useRef<NodeJS.Timeout[]>([]);
124112
const hideTooltipTimer = React.useRef<NodeJS.Timeout[]>([]);
125113

126-
const childrenWrapperRef = React.useRef<View>(null);
127-
128-
const opacity = useSharedValue(0);
129-
const reanimatedReduceMotion = reduceMotion
130-
? ReduceMotion.Always
131-
: ReduceMotion.Never;
132-
133-
const enterConfig = React.useMemo(
134-
() => ({
135-
duration: theme.motion.duration[Tokens.motion.enter.duration],
136-
easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]),
137-
reduceMotion: reanimatedReduceMotion,
138-
}),
139-
[theme.motion, reanimatedReduceMotion]
140-
);
141-
const exitConfig = React.useMemo(
142-
() => ({
143-
duration: theme.motion.duration[Tokens.motion.exit.duration],
144-
easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]),
145-
reduceMotion: reanimatedReduceMotion,
146-
}),
147-
[theme.motion, reanimatedReduceMotion]
148-
);
149-
const exitDurationMs = reduceMotion
150-
? 0
151-
: theme.motion.duration[Tokens.motion.exit.duration];
152-
153-
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
154-
155114
const isValidChild = React.useMemo(
156115
() => React.isValidElement<TooltipChildProps>(children),
157116
[children]
@@ -174,41 +133,6 @@ const RichTooltip = ({
174133
};
175134
}, [clearShowTimers, clearHideTimers]);
176135

177-
// Mount as soon as the tooltip is requested.
178-
React.useEffect(() => {
179-
if (visible) {
180-
setRendered(true);
181-
}
182-
}, [visible]);
183-
184-
// Drive the fade and defer unmount until the exit animation has played.
185-
React.useEffect(() => {
186-
if (!rendered) {
187-
return;
188-
}
189-
190-
if (visible) {
191-
opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0;
192-
return;
193-
}
194-
195-
opacity.value = withTiming(0, exitConfig);
196-
const id = setTimeout(() => {
197-
setRendered(false);
198-
setMeasurement({ children: {}, tooltip: {}, measured: false });
199-
}, exitDurationMs) as unknown as NodeJS.Timeout;
200-
201-
return () => clearTimeout(id);
202-
}, [
203-
visible,
204-
rendered,
205-
measurement.measured,
206-
opacity,
207-
enterConfig,
208-
exitConfig,
209-
exitDurationMs,
210-
]);
211-
212136
React.useEffect(() => {
213137
const subscription = addEventListener(Dimensions, 'change', () =>
214138
setVisible(false)
@@ -268,18 +192,6 @@ const RichTooltip = ({
268192
}
269193
}, [scheduleHide, isValidChild, children.props]);
270194

271-
const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
272-
childrenWrapperRef.current?.measure(
273-
(_x, _y, width, height, pageX, pageY) => {
274-
setMeasurement({
275-
children: { pageX, pageY, height, width },
276-
tooltip: { ...layout },
277-
measured: true,
278-
});
279-
}
280-
);
281-
};
282-
283195
const mobilePressProps = {
284196
onPress: handlePress,
285197
};
@@ -307,7 +219,7 @@ const RichTooltip = ({
307219
testID="tooltip-rich-backdrop"
308220
/>
309221
<Animated.View
310-
onLayout={handleOnLayout}
222+
onLayout={onLayout}
311223
style={[
312224
styles.container,
313225
getTooltipPosition(

src/components/Tooltip/Tooltip.tsx

Lines changed: 7 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
11
import * as React from 'react';
22
import {
33
Dimensions,
4-
View,
5-
LayoutChangeEvent,
64
StyleSheet,
75
Platform,
86
Pressable,
97
ViewStyle,
108
} from 'react-native';
119

12-
import Animated, {
13-
Easing,
14-
ReduceMotion,
15-
useAnimatedStyle,
16-
useSharedValue,
17-
withTiming,
18-
} from 'react-native-reanimated';
10+
import Animated from 'react-native-reanimated';
1911

12+
import { useTooltipFade } from './hooks';
2013
import { Tokens } from './tokens';
2114
import { getTooltipPosition, Measurement, TooltipChildProps } from './utils';
2215
import { useInternalTheme } from '../../core/theming';
23-
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
2416
import type { ThemeProp } from '../../types';
2517
import { addEventListener } from '../../utils/addEventListener';
2618
import Portal from '../Portal/Portal';
@@ -86,53 +78,17 @@ const Tooltip = ({
8678
const isWeb = Platform.OS === 'web';
8779

8880
const theme = useInternalTheme(themeOverrides);
89-
const reduceMotion = useReduceMotion();
90-
// `visible` is the show/hide intent; `rendered` keeps the tooltip mounted
91-
// through the exit fade so it can animate out before unmounting.
81+
// `visible` is the show/hide intent; the fade hook keeps the tooltip mounted
82+
// through the exit animation and owns the measurement + opacity.
9283
const [visible, setVisible] = React.useState(false);
93-
const [rendered, setRendered] = React.useState(false);
84+
const { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef } =
85+
useTooltipFade(theme, visible);
9486

95-
const [measurement, setMeasurement] = React.useState({
96-
children: {},
97-
tooltip: {},
98-
measured: false,
99-
});
10087
const showTooltipTimer = React.useRef<NodeJS.Timeout[]>([]);
10188
const hideTooltipTimer = React.useRef<NodeJS.Timeout[]>([]);
10289

103-
const childrenWrapperRef = React.useRef<View>(null);
10490
const touched = React.useRef(false);
10591

106-
const opacity = useSharedValue(0);
107-
const reanimatedReduceMotion = reduceMotion
108-
? ReduceMotion.Always
109-
: ReduceMotion.Never;
110-
111-
const enterConfig = React.useMemo(
112-
() => ({
113-
duration: theme.motion.duration[Tokens.motion.enter.duration],
114-
easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]),
115-
reduceMotion: reanimatedReduceMotion,
116-
}),
117-
[theme.motion, reanimatedReduceMotion]
118-
);
119-
const exitConfig = React.useMemo(
120-
() => ({
121-
duration: theme.motion.duration[Tokens.motion.exit.duration],
122-
easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]),
123-
reduceMotion: reanimatedReduceMotion,
124-
}),
125-
[theme.motion, reanimatedReduceMotion]
126-
);
127-
// The visual fade-out is handled by Reanimated; the actual unmount is
128-
// deferred by this same duration so the fade can play. Reduce-motion skips
129-
// the wait entirely.
130-
const exitDurationMs = reduceMotion
131-
? 0
132-
: theme.motion.duration[Tokens.motion.exit.duration];
133-
134-
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
135-
13692
const isValidChild = React.useMemo(
13793
() => React.isValidElement<TooltipChildProps>(children),
13894
[children]
@@ -152,43 +108,6 @@ const Tooltip = ({
152108
};
153109
}, []);
154110

155-
// Mount as soon as the tooltip is requested.
156-
React.useEffect(() => {
157-
if (visible) {
158-
setRendered(true);
159-
}
160-
}, [visible]);
161-
162-
// Drive the fade and defer unmount until the exit animation has played.
163-
React.useEffect(() => {
164-
if (!rendered) {
165-
return;
166-
}
167-
168-
if (visible) {
169-
// Hold at 0 until measured so the tooltip never flashes at the wrong
170-
// position, then fade in.
171-
opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0;
172-
return;
173-
}
174-
175-
opacity.value = withTiming(0, exitConfig);
176-
const id = setTimeout(() => {
177-
setRendered(false);
178-
setMeasurement({ children: {}, tooltip: {}, measured: false });
179-
}, exitDurationMs) as unknown as NodeJS.Timeout;
180-
181-
return () => clearTimeout(id);
182-
}, [
183-
visible,
184-
rendered,
185-
measurement.measured,
186-
opacity,
187-
enterConfig,
188-
exitConfig,
189-
exitDurationMs,
190-
]);
191-
192111
React.useEffect(() => {
193112
const subscription = addEventListener(Dimensions, 'change', () =>
194113
setVisible(false)
@@ -252,18 +171,6 @@ const Tooltip = ({
252171
}
253172
}, [children.props, handleTouchEnd, isValidChild]);
254173

255-
const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
256-
childrenWrapperRef.current?.measure(
257-
(_x, _y, width, height, pageX, pageY) => {
258-
setMeasurement({
259-
children: { pageX, pageY, height, width },
260-
tooltip: { ...layout },
261-
measured: true,
262-
});
263-
}
264-
);
265-
};
266-
267174
const mobilePressProps = {
268175
onPress: handlePress,
269176
onLongPress: () => handleTouchStart(),
@@ -281,7 +188,7 @@ const Tooltip = ({
281188
{rendered && (
282189
<Portal>
283190
<Animated.View
284-
onLayout={handleOnLayout}
191+
onLayout={onLayout}
285192
style={[
286193
styles.tooltip,
287194
{

0 commit comments

Comments
 (0)