@hero-design/rn 8.130.3 → 8.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/es/index.js +405 -224
- package/lib/index.js +405 -224
- package/package.json +1 -1
- package/src/components/Tabs/ScrollableTabsHeader/ScrollableTabsHeader.tsx +217 -131
- package/src/components/Tabs/ScrollableTabsHeader/hooks/useIndicatorAnimation.ts +242 -0
- package/src/components/Tabs/StyledScrollableTabs.tsx +68 -21
- package/src/components/Tabs/index.tsx +2 -0
- package/src/theme/components/tabs.ts +9 -2
- package/types/components/Tabs/ScrollableTabsHeader/ScrollableTabsHeader.d.ts +1 -1
- package/types/components/Tabs/ScrollableTabsHeader/hooks/useIndicatorAnimation.d.ts +75 -0
- package/types/components/Tabs/StyledScrollableTabs.d.ts +13 -8
- package/types/components/Tabs/index.d.ts +3 -1
- package/types/theme/components/tabs.d.ts +9 -2
- package/src/components/Tabs/ScrollableTabsHeader/hooks/useInitHighlightedAnimation.ts +0 -45
- package/types/components/Tabs/ScrollableTabsHeader/hooks/useInitHighlightedAnimation.d.ts +0 -9
package/package.json
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
import { useTheme } from '@emotion/react';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
StyleProp,
|
|
5
|
+
ViewProps,
|
|
6
|
+
ViewStyle,
|
|
7
|
+
LayoutChangeEvent,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import {
|
|
10
|
+
Platform,
|
|
11
|
+
ScrollView,
|
|
12
|
+
TouchableWithoutFeedback,
|
|
13
|
+
View,
|
|
14
|
+
} from 'react-native';
|
|
5
15
|
import type { ItemType, TabType } from '..';
|
|
6
16
|
import Icon from '../../Icon';
|
|
7
17
|
import { isHeroIcon } from '../../Icon/utils';
|
|
8
18
|
import Typography from '../../Typography';
|
|
9
19
|
import {
|
|
10
20
|
HeaderTabItem,
|
|
21
|
+
HeaderTabItemActiveBorder,
|
|
11
22
|
HeaderTabItemIndicator,
|
|
12
|
-
HeaderTabItemOutline,
|
|
13
|
-
HeaderTabItemOutlineWrapper,
|
|
14
23
|
HeaderTabItemWrapper,
|
|
15
24
|
HeaderTabWrapper,
|
|
25
|
+
HeaderTabPillLeft,
|
|
26
|
+
HeaderTabPillBody,
|
|
27
|
+
HeaderTabPillRight,
|
|
16
28
|
} from '../StyledScrollableTabs';
|
|
17
29
|
import TabWithBadge from '../TabWithBadge';
|
|
18
|
-
import
|
|
19
|
-
import useInitUnderlinedAnimation from './hooks/useInitUnderlinedAnimation';
|
|
30
|
+
import useIndicatorAnimation from './hooks/useIndicatorAnimation';
|
|
20
31
|
|
|
21
32
|
const getTabItem = ({
|
|
22
33
|
item,
|
|
@@ -41,7 +52,7 @@ const getTabItem = ({
|
|
|
41
52
|
if (typeof item === 'string') {
|
|
42
53
|
return (
|
|
43
54
|
<Typography.Body
|
|
44
|
-
variant={active ? '
|
|
55
|
+
variant={active ? 'small-bold' : 'small'}
|
|
45
56
|
numberOfLines={1}
|
|
46
57
|
style={{ color }}
|
|
47
58
|
>
|
|
@@ -88,148 +99,223 @@ export interface ScrollableTabHeaderProps extends ViewProps {
|
|
|
88
99
|
*/
|
|
89
100
|
variant?: 'underlined' | 'highlighted';
|
|
90
101
|
}
|
|
102
|
+
|
|
103
|
+
type TabItemProps = {
|
|
104
|
+
tab: TabType;
|
|
105
|
+
index: number;
|
|
106
|
+
active: boolean;
|
|
107
|
+
variant: 'underlined' | 'highlighted';
|
|
108
|
+
onTabPress: (key: string) => void;
|
|
109
|
+
onLayout: (event: LayoutChangeEvent) => void;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const TabItemComponent = React.memo(
|
|
113
|
+
({ tab, index, active, variant, onTabPress, onLayout }: TabItemProps) => {
|
|
114
|
+
const theme = useTheme();
|
|
115
|
+
const isHighlighted = variant === 'highlighted';
|
|
116
|
+
|
|
117
|
+
const getTextColor = () => {
|
|
118
|
+
if (isHighlighted) {
|
|
119
|
+
if (tab.disabled)
|
|
120
|
+
return theme.__hd__.tabs.colors.highlightedDisabledText;
|
|
121
|
+
if (active) return theme.__hd__.tabs.colors.highlightedActiveText;
|
|
122
|
+
}
|
|
123
|
+
return active
|
|
124
|
+
? theme.__hd__.tabs.colors.active
|
|
125
|
+
: theme.__hd__.tabs.colors.inactive;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const inactiveItem = tab.inactiveItem ?? tab.activeItem;
|
|
129
|
+
const tabItem = getTabItem({
|
|
130
|
+
item: active ? tab.activeItem : inactiveItem,
|
|
131
|
+
color: getTextColor(),
|
|
132
|
+
active,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const handlePress = React.useCallback(
|
|
136
|
+
() => onTabPress(tab.key),
|
|
137
|
+
[onTabPress, tab.key]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<TouchableWithoutFeedback
|
|
142
|
+
key={tab.key}
|
|
143
|
+
onPress={handlePress}
|
|
144
|
+
testID={tab.testID}
|
|
145
|
+
disabled={tab.disabled}
|
|
146
|
+
>
|
|
147
|
+
<HeaderTabItem
|
|
148
|
+
testID={`tab-item-${index}`}
|
|
149
|
+
isFirstItem={index === 0}
|
|
150
|
+
themeVariant={variant}
|
|
151
|
+
onLayout={onLayout}
|
|
152
|
+
>
|
|
153
|
+
<HeaderTabItemWrapper>
|
|
154
|
+
<TabWithBadge config={tab.badge} tabItem={tabItem} />
|
|
155
|
+
</HeaderTabItemWrapper>
|
|
156
|
+
</HeaderTabItem>
|
|
157
|
+
</TouchableWithoutFeedback>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
|
|
91
162
|
const ScrollableTabHeader = ({
|
|
92
163
|
onTabPress,
|
|
93
|
-
selectedIndex,
|
|
164
|
+
selectedIndex: rawSelectedIndex,
|
|
94
165
|
tabs,
|
|
95
166
|
barStyle,
|
|
96
167
|
testID,
|
|
97
168
|
insets = { top: 0, bottom: 0, right: 0, left: 0 },
|
|
98
169
|
variant = 'highlighted',
|
|
99
170
|
}: ScrollableTabHeaderProps) => {
|
|
171
|
+
const selectedIndex =
|
|
172
|
+
rawSelectedIndex !== undefined && rawSelectedIndex >= 0
|
|
173
|
+
? rawSelectedIndex
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
100
176
|
const theme = useTheme();
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
// Init underlined animation data
|
|
104
|
-
const { underlinedTranslateX, underlinedOpacity } =
|
|
105
|
-
useInitUnderlinedAnimation({
|
|
106
|
-
tabsLength: tabs.length,
|
|
107
|
-
selectedIndex,
|
|
108
|
-
variant,
|
|
109
|
-
});
|
|
177
|
+
const scrollViewRef = React.useRef<ScrollView>(null);
|
|
178
|
+
const isHighlighted = variant === 'highlighted';
|
|
110
179
|
|
|
111
|
-
|
|
112
|
-
|
|
180
|
+
const {
|
|
181
|
+
indicatorStyle,
|
|
182
|
+
pillLeftStyle,
|
|
183
|
+
pillBodyStyle,
|
|
184
|
+
pillRightStyle,
|
|
185
|
+
onTabLayout,
|
|
186
|
+
} = useIndicatorAnimation({
|
|
113
187
|
selectedIndex,
|
|
114
188
|
tabsLength: tabs.length,
|
|
115
|
-
|
|
189
|
+
pillCapWidth: theme.__hd__.tabs.radii.highlightedOutline,
|
|
116
190
|
});
|
|
117
191
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return () => {
|
|
128
|
-
if (timeoutHandle) {
|
|
129
|
-
clearTimeout(timeoutHandle);
|
|
192
|
+
// Scroll to the selected tab after its layout is known.
|
|
193
|
+
const handleTabLayout = React.useCallback(
|
|
194
|
+
(index: number, event: LayoutChangeEvent) => {
|
|
195
|
+
if (index === selectedIndex) {
|
|
196
|
+
scrollViewRef.current?.scrollTo({
|
|
197
|
+
x: event.nativeEvent.layout.x,
|
|
198
|
+
animated: true,
|
|
199
|
+
});
|
|
130
200
|
}
|
|
131
|
-
|
|
132
|
-
|
|
201
|
+
onTabLayout(index, event);
|
|
202
|
+
},
|
|
203
|
+
[selectedIndex, onTabLayout]
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Memoize per-tab layout handlers so TabItemComponent memo is not broken.
|
|
207
|
+
const tabLayoutHandlers = React.useMemo(
|
|
208
|
+
() =>
|
|
209
|
+
tabs.map(
|
|
210
|
+
(_, i) => (event: LayoutChangeEvent) => handleTabLayout(i, event)
|
|
211
|
+
),
|
|
212
|
+
// Handlers only need to change when tab count or selection changes.
|
|
213
|
+
[tabs, handleTabLayout]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const scrollViewStyle = React.useMemo(
|
|
217
|
+
() => ({
|
|
218
|
+
borderBottomColor: theme.__hd__.tabs.colors.headerBottom,
|
|
219
|
+
borderBottomWidth: isHighlighted
|
|
220
|
+
? theme.__hd__.tabs.borderWidths.highlightedHeaderBottom
|
|
221
|
+
: theme.__hd__.tabs.sizes.indicator,
|
|
222
|
+
}),
|
|
223
|
+
[theme, isHighlighted]
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const contentContainerStyle = React.useMemo(
|
|
227
|
+
() => ({
|
|
228
|
+
paddingHorizontal: theme.__hd__.tabs.space.flatListHorizontalPadding,
|
|
229
|
+
position: 'relative' as const,
|
|
230
|
+
...(Platform.OS === 'android' && {
|
|
231
|
+
borderBottomColor: theme.__hd__.tabs.colors.headerBottom,
|
|
232
|
+
borderBottomWidth: isHighlighted
|
|
233
|
+
? theme.__hd__.tabs.borderWidths.highlightedHeaderBottom
|
|
234
|
+
: theme.__hd__.tabs.sizes.indicator,
|
|
235
|
+
}),
|
|
236
|
+
}),
|
|
237
|
+
[theme, isHighlighted]
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const wrapperStyle = React.useMemo(
|
|
241
|
+
() => [
|
|
242
|
+
isHighlighted && {
|
|
243
|
+
paddingTop: theme.__hd__.tabs.space.highlightedBarTopPadding,
|
|
244
|
+
},
|
|
245
|
+
barStyle,
|
|
246
|
+
],
|
|
247
|
+
[isHighlighted, theme, barStyle]
|
|
248
|
+
);
|
|
133
249
|
|
|
134
250
|
return (
|
|
135
|
-
<HeaderTabWrapper
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<HeaderTabItemOutline
|
|
202
|
-
themeActive={active}
|
|
203
|
-
style={{
|
|
204
|
-
flex: 1,
|
|
205
|
-
transform: [
|
|
206
|
-
{
|
|
207
|
-
scaleX: outlineScale,
|
|
208
|
-
},
|
|
209
|
-
],
|
|
210
|
-
}}
|
|
211
|
-
/>
|
|
212
|
-
</HeaderTabItemOutlineWrapper>
|
|
213
|
-
)}
|
|
214
|
-
|
|
215
|
-
<HeaderTabItemWrapper>
|
|
216
|
-
<TabWithBadge config={badge} tabItem={tabItem} />
|
|
217
|
-
</HeaderTabItemWrapper>
|
|
218
|
-
|
|
219
|
-
{variant === 'underlined' && (
|
|
220
|
-
<HeaderTabItemIndicator
|
|
221
|
-
style={{
|
|
222
|
-
opacity: underlinedOpacity[index],
|
|
223
|
-
transform: [{ translateX: underlinedTranslateX[index] }],
|
|
224
|
-
}}
|
|
225
|
-
/>
|
|
226
|
-
)}
|
|
227
|
-
</HeaderTabItem>
|
|
228
|
-
</TouchableWithoutFeedback>
|
|
229
|
-
);
|
|
230
|
-
}}
|
|
231
|
-
/>
|
|
251
|
+
<HeaderTabWrapper
|
|
252
|
+
testID="tab-header-wrapper"
|
|
253
|
+
themeInsets={insets}
|
|
254
|
+
style={wrapperStyle}
|
|
255
|
+
>
|
|
256
|
+
<View style={isHighlighted ? { overflow: 'hidden' } : undefined}>
|
|
257
|
+
{/* ScrollView renders all tab items at once (no virtualization).
|
|
258
|
+
This is intentional: tabs are small, counts are typically low
|
|
259
|
+
(2–8), and the shared pill animation requires all tab layouts
|
|
260
|
+
to be measured simultaneously. A FlatList would not provide
|
|
261
|
+
meaningful memory savings here and would complicate layout
|
|
262
|
+
measurement. */}
|
|
263
|
+
<ScrollView
|
|
264
|
+
ref={scrollViewRef}
|
|
265
|
+
testID={testID}
|
|
266
|
+
horizontal
|
|
267
|
+
showsHorizontalScrollIndicator={false}
|
|
268
|
+
contentContainerStyle={contentContainerStyle}
|
|
269
|
+
style={scrollViewStyle}
|
|
270
|
+
>
|
|
271
|
+
<View style={{ flexDirection: 'row', position: 'relative' }}>
|
|
272
|
+
{isHighlighted && (
|
|
273
|
+
<>
|
|
274
|
+
<HeaderTabPillLeft
|
|
275
|
+
testID="tab-pill-background"
|
|
276
|
+
style={pillLeftStyle}
|
|
277
|
+
/>
|
|
278
|
+
<HeaderTabPillBody style={pillBodyStyle} />
|
|
279
|
+
<HeaderTabPillRight
|
|
280
|
+
testID="tab-pill-background-right"
|
|
281
|
+
style={pillRightStyle}
|
|
282
|
+
/>
|
|
283
|
+
</>
|
|
284
|
+
)}
|
|
285
|
+
{tabs.map((tab, index) => (
|
|
286
|
+
<TabItemComponent
|
|
287
|
+
key={tab.key}
|
|
288
|
+
tab={tab}
|
|
289
|
+
index={index}
|
|
290
|
+
active={selectedIndex === index}
|
|
291
|
+
variant={variant}
|
|
292
|
+
onTabPress={onTabPress}
|
|
293
|
+
onLayout={tabLayoutHandlers[index]}
|
|
294
|
+
/>
|
|
295
|
+
))}
|
|
296
|
+
{isHighlighted ? (
|
|
297
|
+
<HeaderTabItemActiveBorder
|
|
298
|
+
testID="tab-active-border"
|
|
299
|
+
style={{
|
|
300
|
+
position: 'absolute' as const,
|
|
301
|
+
bottom: 0,
|
|
302
|
+
...indicatorStyle,
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
) : (
|
|
306
|
+
<HeaderTabItemIndicator
|
|
307
|
+
testID="tab-underline-indicator"
|
|
308
|
+
style={{
|
|
309
|
+
position: 'absolute' as const,
|
|
310
|
+
...indicatorStyle,
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
)}
|
|
314
|
+
</View>
|
|
315
|
+
</ScrollView>
|
|
316
|
+
</View>
|
|
232
317
|
</HeaderTabWrapper>
|
|
233
318
|
);
|
|
234
319
|
};
|
|
320
|
+
|
|
235
321
|
export default ScrollableTabHeader;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Animated } from 'react-native';
|
|
3
|
+
import type { LayoutChangeEvent } from 'react-native';
|
|
4
|
+
|
|
5
|
+
type Layout = { x: number; width: number };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Drives two visual layers that slide to the selected tab on every press:
|
|
9
|
+
*
|
|
10
|
+
* Layer 1 — bottom border / underline (indicatorStyle)
|
|
11
|
+
* ─────────────────────────────────────────────────────
|
|
12
|
+
* Uses the "width:1 + scaleX" trick: the element has a fixed stylesheet
|
|
13
|
+
* width of 1px and scaleX is set to the target pixel width, giving a visual
|
|
14
|
+
* width of 1 × scaleX pixels without touching any layout property.
|
|
15
|
+
* Both translateX and scaleX are transform properties → native driver.
|
|
16
|
+
* Caveat: scaleX also scales border-radius, so this layer has no border-radius.
|
|
17
|
+
*
|
|
18
|
+
* Layer 2 — pill background (pillLeftStyle / pillBodyStyle / pillRightStyle)
|
|
19
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
* The pill is split into three absolutely-positioned children so that
|
|
21
|
+
* border-radius is never distorted by scale:
|
|
22
|
+
*
|
|
23
|
+
* ┌──────────────────────────────────────────────────────┐
|
|
24
|
+
* │ [cap-left 8px] [body width-1 + scaleX] [cap-right 8px] │
|
|
25
|
+
* └──────────────────────────────────────────────────────┘
|
|
26
|
+
*
|
|
27
|
+
* cap-left — fixed 8px wide, borderTopLeftRadius:8, translateX = pillX
|
|
28
|
+
* body — width:1 + scaleX trick (scaleX = tabWidth - 16),
|
|
29
|
+
* transformOrigin 'left center',
|
|
30
|
+
* translateX = pillX + 8 (via Animated.add)
|
|
31
|
+
* cap-right — fixed 8px wide, borderTopRightRadius:8,
|
|
32
|
+
* translateX = pillX + tabWidth - 8 (via Animated.add)
|
|
33
|
+
*
|
|
34
|
+
* All four animated values use the native driver (translateX and scaleX are
|
|
35
|
+
* transform properties). `width` is never animated, so no JS driver needed.
|
|
36
|
+
*
|
|
37
|
+
* Driver summary:
|
|
38
|
+
* indicatorX native translateX — slides the bottom border
|
|
39
|
+
* indicatorScaleX native scaleX — stretches the bottom border
|
|
40
|
+
* pillX native translateX — slides all three pill pieces
|
|
41
|
+
* pillBodyScaleX native scaleX — stretches the pill body
|
|
42
|
+
* pillRightOffsetX native translateX — positions the right cap
|
|
43
|
+
* (Animated.add: pillX + tabWidth - 8)
|
|
44
|
+
*/
|
|
45
|
+
const useIndicatorAnimation = ({
|
|
46
|
+
selectedIndex,
|
|
47
|
+
tabsLength,
|
|
48
|
+
pillCapWidth,
|
|
49
|
+
}: {
|
|
50
|
+
selectedIndex: number | undefined;
|
|
51
|
+
tabsLength: number;
|
|
52
|
+
/** Width of each rounded cap, should equal theme radii.highlightedOutline. */
|
|
53
|
+
pillCapWidth: number;
|
|
54
|
+
}) => {
|
|
55
|
+
// Layer 1 — native driver (bottom border / underline).
|
|
56
|
+
const indicatorX = React.useRef(new Animated.Value(0)).current;
|
|
57
|
+
const indicatorScaleX = React.useRef(new Animated.Value(1)).current;
|
|
58
|
+
|
|
59
|
+
// Layer 2 — native driver (pill background, three-piece split).
|
|
60
|
+
// pillX: left edge of the pill (shared by all three pieces as base).
|
|
61
|
+
// pillBodyScaleX: scaleX for the body piece (tabWidth - 2 * CAP_WIDTH).
|
|
62
|
+
// pillRightOffset: additional x offset for the right cap (tabWidth - CAP_WIDTH).
|
|
63
|
+
const pillX = React.useRef(new Animated.Value(0)).current;
|
|
64
|
+
const pillBodyScaleX = React.useRef(new Animated.Value(1)).current;
|
|
65
|
+
const pillRightOffset = React.useRef(new Animated.Value(0)).current;
|
|
66
|
+
|
|
67
|
+
// Stable ref so callbacks don't capture stale closures.
|
|
68
|
+
const layoutsRef = React.useRef<(Layout | undefined)[]>([]);
|
|
69
|
+
const runningAnimRef = React.useRef<Animated.CompositeAnimation | null>(null);
|
|
70
|
+
const pendingIndexRef = React.useRef<number | undefined>(undefined);
|
|
71
|
+
const initializedRef = React.useRef(false);
|
|
72
|
+
|
|
73
|
+
// Resize layout cache when tabsLength changes.
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
layoutsRef.current = Array.from(
|
|
76
|
+
{ length: tabsLength },
|
|
77
|
+
(_, i) => layoutsRef.current[i]
|
|
78
|
+
);
|
|
79
|
+
}, [tabsLength]);
|
|
80
|
+
|
|
81
|
+
const animateTo = React.useCallback(
|
|
82
|
+
(index: number, animate: boolean) => {
|
|
83
|
+
const layout = layoutsRef.current[index];
|
|
84
|
+
if (!layout) return;
|
|
85
|
+
|
|
86
|
+
runningAnimRef.current?.stop();
|
|
87
|
+
runningAnimRef.current = null;
|
|
88
|
+
|
|
89
|
+
// Layer 1: bottom-border element has width:1, so scaleX = pixel width.
|
|
90
|
+
const indicatorScaleXValue = layout.width;
|
|
91
|
+
// Layer 2 body: width:1 element, scaleX fills space between the two caps.
|
|
92
|
+
const bodyScaleX = Math.max(layout.width - pillCapWidth * 2, 0);
|
|
93
|
+
// Layer 2 right cap: offset from pillX to reach the right edge.
|
|
94
|
+
// Clamped to 0 so the right cap never slides left of the pill's origin
|
|
95
|
+
// when the tab is narrower than one cap width.
|
|
96
|
+
const rightOffset = Math.max(layout.width - pillCapWidth, 0);
|
|
97
|
+
|
|
98
|
+
if (!animate || !initializedRef.current) {
|
|
99
|
+
// First render — snap all values immediately without animation.
|
|
100
|
+
indicatorX.setValue(layout.x);
|
|
101
|
+
indicatorScaleX.setValue(indicatorScaleXValue);
|
|
102
|
+
pillX.setValue(layout.x);
|
|
103
|
+
pillBodyScaleX.setValue(bodyScaleX);
|
|
104
|
+
pillRightOffset.setValue(rightOffset);
|
|
105
|
+
initializedRef.current = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// All five animations run on the native driver (UI thread):
|
|
110
|
+
// indicatorX — slides the bottom border
|
|
111
|
+
// indicatorScaleX — stretches the bottom border
|
|
112
|
+
// pillX — slides all three pill pieces together
|
|
113
|
+
// pillBodyScaleX — resizes the body piece to fill between caps
|
|
114
|
+
// pillRightOffset — keeps the right cap at the pill's right edge
|
|
115
|
+
const anim = Animated.parallel([
|
|
116
|
+
Animated.timing(indicatorX, {
|
|
117
|
+
toValue: layout.x,
|
|
118
|
+
useNativeDriver: true,
|
|
119
|
+
}),
|
|
120
|
+
Animated.timing(indicatorScaleX, {
|
|
121
|
+
toValue: indicatorScaleXValue,
|
|
122
|
+
useNativeDriver: true,
|
|
123
|
+
}),
|
|
124
|
+
Animated.timing(pillX, {
|
|
125
|
+
toValue: layout.x,
|
|
126
|
+
useNativeDriver: true,
|
|
127
|
+
}),
|
|
128
|
+
Animated.timing(pillBodyScaleX, {
|
|
129
|
+
toValue: bodyScaleX,
|
|
130
|
+
useNativeDriver: true,
|
|
131
|
+
}),
|
|
132
|
+
Animated.timing(pillRightOffset, {
|
|
133
|
+
toValue: rightOffset,
|
|
134
|
+
useNativeDriver: true,
|
|
135
|
+
}),
|
|
136
|
+
]);
|
|
137
|
+
runningAnimRef.current = anim;
|
|
138
|
+
anim.start(({ finished }) => {
|
|
139
|
+
if (finished) runningAnimRef.current = null;
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
[
|
|
143
|
+
indicatorX,
|
|
144
|
+
indicatorScaleX,
|
|
145
|
+
pillX,
|
|
146
|
+
pillBodyScaleX,
|
|
147
|
+
pillRightOffset,
|
|
148
|
+
pillCapWidth,
|
|
149
|
+
]
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Animate to selected tab whenever selectedIndex changes.
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
if (selectedIndex === undefined) return;
|
|
155
|
+
if (layoutsRef.current[selectedIndex]) {
|
|
156
|
+
animateTo(selectedIndex, initializedRef.current);
|
|
157
|
+
} else {
|
|
158
|
+
// Layout not yet measured — store as pending and resolve in onTabLayout.
|
|
159
|
+
pendingIndexRef.current = selectedIndex;
|
|
160
|
+
}
|
|
161
|
+
}, [selectedIndex, animateTo]);
|
|
162
|
+
|
|
163
|
+
// Stop any in-flight animation on unmount.
|
|
164
|
+
React.useEffect(() => {
|
|
165
|
+
return () => {
|
|
166
|
+
runningAnimRef.current?.stop();
|
|
167
|
+
};
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const onTabLayout = React.useCallback(
|
|
171
|
+
(index: number, event: LayoutChangeEvent) => {
|
|
172
|
+
const { x, width } = event.nativeEvent.layout;
|
|
173
|
+
const prev = layoutsRef.current[index];
|
|
174
|
+
// Skip if layout hasn't meaningfully changed (sub-pixel tolerance).
|
|
175
|
+
if (
|
|
176
|
+
prev &&
|
|
177
|
+
Math.abs(prev.x - x) < 0.5 &&
|
|
178
|
+
Math.abs(prev.width - width) < 0.5
|
|
179
|
+
) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
layoutsRef.current[index] = { x, width };
|
|
183
|
+
|
|
184
|
+
// Animate if this tab is the selected one (covers the pending case where
|
|
185
|
+
// selectedIndex was set before the layout was measured).
|
|
186
|
+
if (index === selectedIndex || index === pendingIndexRef.current) {
|
|
187
|
+
if (index === pendingIndexRef.current)
|
|
188
|
+
pendingIndexRef.current = undefined;
|
|
189
|
+
animateTo(index, initializedRef.current);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If no tab is selected yet, snap indicators to tab 0 on its first
|
|
193
|
+
// layout so they appear at a sensible default position.
|
|
194
|
+
if (
|
|
195
|
+
!initializedRef.current &&
|
|
196
|
+
index === 0 &&
|
|
197
|
+
selectedIndex === undefined
|
|
198
|
+
) {
|
|
199
|
+
indicatorScaleX.setValue(width);
|
|
200
|
+
pillX.setValue(x);
|
|
201
|
+
pillBodyScaleX.setValue(Math.max(width - pillCapWidth * 2, 0));
|
|
202
|
+
pillRightOffset.setValue(Math.max(width - pillCapWidth, 0));
|
|
203
|
+
initializedRef.current = true;
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[animateTo, selectedIndex, pillCapWidth]
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Layer 1: transformOrigin 'left center' pins scaleX expansion to left edge.
|
|
210
|
+
const indicatorStyle = {
|
|
211
|
+
transformOrigin: 'left center',
|
|
212
|
+
transform: [{ translateX: indicatorX }, { scaleX: indicatorScaleX }],
|
|
213
|
+
} as const;
|
|
214
|
+
|
|
215
|
+
// Layer 2: three pieces, all absolutely positioned, all native driver.
|
|
216
|
+
// Animated.add computes derived positions without creating JS-driver nodes.
|
|
217
|
+
const pillLeftStyle = {
|
|
218
|
+
transform: [{ translateX: pillX }],
|
|
219
|
+
} as const;
|
|
220
|
+
|
|
221
|
+
const pillBodyStyle = {
|
|
222
|
+
transformOrigin: 'left center',
|
|
223
|
+
transform: [
|
|
224
|
+
{ translateX: Animated.add(pillX, pillCapWidth) },
|
|
225
|
+
{ scaleX: pillBodyScaleX },
|
|
226
|
+
],
|
|
227
|
+
} as const;
|
|
228
|
+
|
|
229
|
+
const pillRightStyle = {
|
|
230
|
+
transform: [{ translateX: Animated.add(pillX, pillRightOffset) }],
|
|
231
|
+
} as const;
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
indicatorStyle,
|
|
235
|
+
pillLeftStyle,
|
|
236
|
+
pillBodyStyle,
|
|
237
|
+
pillRightStyle,
|
|
238
|
+
onTabLayout,
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export default useIndicatorAnimation;
|