@hero-design/rn 8.130.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hero-design/rn",
3
- "version": "8.130.2",
3
+ "version": "8.131.0",
4
4
  "license": "MIT",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.js",
@@ -1,22 +1,33 @@
1
1
  import { useTheme } from '@emotion/react';
2
2
  import React from 'react';
3
- import type { StyleProp, ViewProps, ViewStyle } from 'react-native';
4
- import { FlatList, Platform, TouchableWithoutFeedback } from 'react-native';
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 useInitHighlightedAnimation from './hooks/useInitHighlightedAnimation';
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 ? 'regular-bold' : 'regular'}
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 flatListRef = React.useRef<FlatList>(null);
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
- // Init highlighted animation data
112
- const { tabsAnims } = useInitHighlightedAnimation({
180
+ const {
181
+ indicatorStyle,
182
+ pillLeftStyle,
183
+ pillBodyStyle,
184
+ pillRightStyle,
185
+ onTabLayout,
186
+ } = useIndicatorAnimation({
113
187
  selectedIndex,
114
188
  tabsLength: tabs.length,
115
- variant,
189
+ pillCapWidth: theme.__hd__.tabs.radii.highlightedOutline,
116
190
  });
117
191
 
118
- React.useEffect(() => {
119
- const timeoutHandle: number | null = null;
120
- if (selectedIndex !== undefined && selectedIndex !== -1) {
121
- flatListRef.current?.scrollToIndex({
122
- index: selectedIndex,
123
- viewPosition: 0.5,
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
- }, [selectedIndex]);
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 themeInsets={insets} style={barStyle}>
136
- <FlatList<TabType>
137
- testID={testID}
138
- ref={flatListRef}
139
- horizontal
140
- data={tabs}
141
- keyExtractor={(tab) => String(tab.key)}
142
- showsHorizontalScrollIndicator={false}
143
- onScrollToIndexFailed={({ index }) => {
144
- setTimeout(
145
- () =>
146
- flatListRef.current?.scrollToIndex({
147
- index,
148
- viewPosition: 0.5,
149
- }),
150
- 100
151
- );
152
- }}
153
- style={{
154
- borderBottomColor: theme.__hd__.tabs.colors.headerBottom,
155
- borderBottomWidth: theme.__hd__.tabs.sizes.indicator,
156
- }}
157
- contentContainerStyle={{
158
- paddingHorizontal: theme.__hd__.tabs.space.flatListHorizontalPadding,
159
-
160
- // Specify it here again or the indicator won't show
161
- ...(Platform.OS === 'android' && {
162
- borderBottomColor: theme.__hd__.tabs.colors.headerBottom,
163
- borderBottomWidth: theme.__hd__.tabs.sizes.indicator,
164
- }),
165
- }}
166
- renderItem={({ item: tab, index }) => {
167
- const {
168
- key,
169
- testID: tabItemTestID,
170
- activeItem,
171
- inactiveItem: originalInactiveItem,
172
- badge,
173
- } = tab;
174
- const active = selectedIndex === index;
175
- const activeAnimated = tabsAnims[index];
176
- const outlineScale = activeAnimated.interpolate({
177
- inputRange: [0, 1],
178
- outputRange: [0.5, 1],
179
- });
180
-
181
- const inactiveItem = originalInactiveItem ?? activeItem;
182
- const tabItem = getTabItem({
183
- item: active ? activeItem : inactiveItem,
184
- color: active
185
- ? theme.__hd__.tabs.colors.active
186
- : theme.__hd__.tabs.colors.inactive,
187
- active,
188
- });
189
-
190
- return (
191
- <TouchableWithoutFeedback
192
- key={key}
193
- onPress={() => {
194
- onTabPress(key);
195
- }}
196
- testID={tabItemTestID}
197
- >
198
- <HeaderTabItem isFirstItem={index === 0}>
199
- {variant === 'highlighted' && (
200
- <HeaderTabItemOutlineWrapper>
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;